Lk
Lab notes

Case study

Your Integration Tests Are Just Unit Tests in a Trench Coat

Mocking every dependency in your integration tests defeats the purpose. Here's what I found at a client that had 94% coverage and zero confidence in their deployments.

The test suite was green. All 1,847 tests passed. Code coverage sat at 94%. The CI badge on the README was a proud, unbroken green.

And yet, every deploy to production was preceded by 20 minutes of manual smoke testing by someone on the team. When I asked the lead engineer why, she said something I've heard more times than I can count: "The tests pass, but we don't really trust them."

The mock problem

I was brought in to help a logistics startup speed up their release cadence. They were doing biweekly deploys and wanted to get to daily. The testing infrastructure looked solid on paper — unit tests, integration tests, a handful of E2E tests. Good separation. Reasonable coverage.

Then I actually read the integration tests.

Here's a simplified version of what I found:

describe('OrderService.createOrder', () => {
  it('should create an order and notify the warehouse', async () => {
    const mockDb = {
      orders: { create: jest.fn().mockResolvedValue({ id: 'order-123' }) },
    };
    const mockWarehouseClient = {
      notify: jest.fn().mockResolvedValue({ acknowledged: true }),
    };
    const mockPaymentGateway = {
      charge: jest.fn().mockResolvedValue({ status: 'captured' }),
    };
 
    const service = new OrderService(mockDb, mockWarehouseClient, mockPaymentGateway);
    const result = await service.createOrder(testOrderData);
 
    expect(result.id).toBe('order-123');
    expect(mockWarehouseClient.notify).toHaveBeenCalledWith('order-123');
  });
});

Every external dependency was a mock. The database was a mock. The warehouse API was a mock. The payment gateway was a mock. The test verified that the OrderService called the right methods in the right order with the right arguments — which is useful — but it told you nothing about whether an actual order would succeed when real systems were involved.

The test file was called order-service.integration.test.ts. It lived in the __integration__ directory. It ran in the "integration" CI stage. But it was a unit test. A perfectly fine unit test, dressed up in a label it hadn't earned.

Multiply that by 400 test files and you have a team that believed they had integration coverage when they had none.

Why this keeps happening

It's not laziness. It's path of least resistance.

Setting up real dependencies for tests is genuinely hard. You need a database with a known state. You need to either run dependent services or use contract stubs. You need test data that makes sense across system boundaries. That's real work, and when you're sprinting toward a deadline, wrapping everything in jest.fn() gets you a green build today.

The problem compounds quietly. Each new mock is easy to justify on its own. But over months, the test suite drifts into a parallel universe where every external call succeeds, every response matches the expected shape, and latency doesn't exist. The tests aren't wrong, exactly — they just don't test what you think they test.

What actually catches integration bugs

I don't have a universal prescription, but at this client we made three changes that had outsized impact.

First, we introduced Testcontainers for the database layer. Instead of mocking Prisma calls, the integration tests spun up a real PostgreSQL container, ran migrations, and executed actual queries. This immediately caught two bugs: a unique constraint violation that the mock had never surfaced, and a query that worked on the mockResolvedValue but failed on real data because of a missing index on a filtered column.

Second, we replaced the warehouse API mock with a contract test. The warehouse team published a Pact contract. Our tests verified that our requests conformed to that contract, and their tests verified that their service honored it. Neither team had to run the other's code, but both teams found out within minutes when someone shipped an incompatible change.

Third, we deleted about 200 mock-heavy "integration" tests and relabeled the rest as unit tests. This sounds drastic, but it was mostly an honesty exercise. The tests still ran. They still caught logic errors. They just stopped pretending to be something they weren't.

Note

A useful litmus test: if your integration test passes with the network cable unplugged, it's not an integration test.

The trust gap

The real cost of mislabeled tests isn't the bugs they miss — it's the false confidence they create. That 94% coverage number was a security blanket. It let the team skip real validation because the dashboard said everything was fine.

After the changes, coverage dropped to 71%. The team was initially uncomfortable with that number. Three weeks in, nobody cared. Deploys went from biweekly to every other day. The 20-minute manual smoke test was down to 5 minutes, mostly verifying UI flows that weren't tested yet.

Lower coverage, higher confidence. That's the trade-off that matters.

Where I'd draw the line

I'm not arguing that mocks are bad. Mocks are great for isolating logic, for testing error paths you can't easily trigger with real systems, for keeping your unit test suite fast. Mock your HTTP client in a unit test. Mock the clock. Mock the filesystem if you need to.

But when you name something an integration test, it should integrate with something. The whole point is to verify that the seams between your code and the outside world hold together. If every seam is papered over with a mock, you're just testing your assumptions about how those systems behave — not how they actually behave.

Next time your green CI badge feels more like a decoration than a guarantee, open a few of those integration test files. Count the mocks. You might find a trench coat with three unit tests stacked on top of each other.