Engineering

Why integration tests catch what unit tests miss

Editorial · Reveneau · November 9, 2026

Why integration tests catch what unit tests miss

We once spent a full day chasing a bug that had nothing to do with the code everyone was staring at. A billing service marked an invoice as paid, and a downstream reporting service kept counting it as outstanding. Both services had passing tests. Both services were, on their own, correct. The billing service had started sending a status field as the string "paid" instead of the number 1, and the reporting service was still checking for 1. Neither side was wrong about what it did. They were wrong about each other.

That is the shape of bug we want to name here, because it does not show up in a unit test suite, no matter how many you write or how well you write them.

The bug class: two correct things that disagree

A unit test checks a function or a class by itself. To do that, it replaces every dependency with a stand-in, usually called a mock, that returns exactly what the test author tells it to return. The test can then prove one thing cleanly: given this input, this function produces this output.

That is a real and useful proof. It is also blind to a specific category of failure, because the mock only knows what the test author believes the real dependency does. If that belief is wrong, the mock is wrong in exactly the same way, and the test passes while agreeing with a version of reality that does not exist.

Four patterns cover most of what shows up in practice:

  • Field shape drift. One side sends null for a missing value, the other side checks whether the field is present at all, and the absence check never fires because the field is always present, just sometimes empty.
  • Ordering assumptions. A service assumes an event about a user's account arrives before the event confirming the account exists, because that has always happened to be true in testing, until a slow network makes it not true once.
  • Error semantics. One side treats a specific error code as "try again later," the other side treats the same code as "this will never succeed," and each is doing something reasonable in isolation.
  • Partial success. A batch call returns 8 successes and 2 failures inside one 200 response, and the caller was written to treat any 200 as a full success, because that is what its mock always returned.

None of these are logic errors inside a single function. Each is an assumption two components make separately, and that separation is invisible until the two run together against real inputs.

Why the mock hides the exact bug you need to find

The uncomfortable part is not that mocks are inaccurate. It is that a mock is usually written by the same person, or the same reasoning process, that wrote the code calling it. If a developer believes a payment API returns a status field on every response, the mock will return a status field on every response, because that belief shaped both.

Martin Fowler's practical test pyramid guide makes the same point from the systems-design side: mocked integration tests are fast and easy to reason about precisely because they remove the real collaborator, and the tradeoff for that speed is that the test can no longer tell you whether the mock still resembles what it is standing in for. The guide's answer is contract testing, running the same assertions against the mock and against the real service, specifically because a mock left unchecked drifts away from reality over time and nobody notices until production disagrees with it.

This is also where AI-written code needs a different kind of scrutiny than it usually gets. A generated function can be locally correct: it does exactly what its own description says, and a unit test written from that same description will pass. What a single function's code cannot tell you is what the neighboring service actually returns on a timeout, on a partial failure, or on a field the documentation never mentioned. That knowledge does not live in the function. It lives at the boundary, which is exactly where a unit test does not look.

The decision rule: one test per real boundary, covering failure first

You do not need an integration test for every function. Most functions are local logic, and a unit test finds a local logic error cheaply and quickly. What you need is much narrower and much more specific: one integration test per real boundary the service depends on, and for each boundary, the failure and partial-failure responses before the success response.

A boundary is any point where your code hands data to, or receives data from, something it does not control:

  1. Every external API call.
  2. Every database or queue the service reads from or writes to.
  3. Every other internal service it talks to over the network.

List those boundaries for a given service. That list is usually short, ten or fewer for most services, and it is the actual scope of the work, not "test everything" and not "test nothing beyond units." For each boundary, write tests for the shapes that break things in practice: a timeout, a response missing an expected field, a partial-success payload, a duplicate delivery of something already processed, and only then the plain success case, because the success case is normally already exercised elsewhere.

This is the same reasoning we described for third-party integrations in why your staging environment is lying to you: a sandbox that always returns a clean 200 in 40 milliseconds cannot teach you what the real service does under load, so the test has to assert the failure shape on purpose rather than wait to encounter it by accident.

What it costs when nobody draws this line

The clearest public example of an interface mismatch is not a startup story, it is a regulatory record. On August 1, 2012, Knight Capital deployed new trading code to eight servers but missed one, which kept running old code. The SEC's enforcement order describes what happened next: the mismatch between what the updated servers and the one un-updated server each believed a trading signal meant caused the firm's routing system to send millions of erroneous orders into the market. In about 45 minutes, while trying to fill 212 customer orders, the system executed more than 4 million orders across 154 stocks, and Knight Capital realized a pre-tax loss of approximately 440 million dollars.

No single server was running broken code. Each one did exactly what its own version of the software said to do. The failure was entirely at the boundary between two versions that no longer agreed, which is the same category as a billing service and a reporting service disagreeing about what "paid" means, at a scale that ended the company as an independent firm within days.

Most interface mismatches cost an afternoon of debugging, not a firm. The mechanism is identical either way: nobody wrote down what the boundary was supposed to guarantee, so nobody could write a test that would have caught the day it stopped being true.

How we scope this in practice

When we take on a build, drawing the boundary list is one of the first steps, not a cleanup task saved for later. Every service gets its dependencies named explicitly, and the eval suite we write against the specification includes the failure and partial-failure cases for each one, not only the success path a demo would show. That list is also what tells us how much integration coverage a given service actually needs: a service with two external calls needs a handful of boundary tests, not the same blanket policy applied everywhere regardless of how much the service actually touches outside itself.

A function proves itself. A boundary only proves itself when both sides are checked against each other, which is the whole reason integration tests exist as a separate discipline and not just a slower version of the same unit test.

Sources

Common questions

What is an interface mismatch bug?

It is a bug where two components are each individually correct but disagree about the shape, meaning, or timing of the data passed between them. One example: a service returns a field as null when a value is missing, and the caller was written to expect the field to be absent from the response entirely, so its check for "did this arrive" never fires.

Why can a unit test not catch an interface mismatch?

A unit test runs one component in isolation and replaces everything it talks to with a stand-in, called a mock, that returns exactly what the test author expects. If the author's assumption about the real dependency is wrong, the mock repeats that same wrong assumption back, and the test passes while the real integration would fail.

What is the difference between a unit test and an integration test?

A unit test checks one function or class on its own, with every dependency replaced by a stand-in. An integration test runs two or more real components together, so it can catch a mismatch in what one component sends and what the other expects, which is invisible when each is tested alone.

How many integration tests does a service need?

One per real boundary the service depends on, at minimum: each external API it calls, each database or queue it reads or writes, and each other internal service it talks to. For each boundary, cover the case where the call fails, the case where it returns something unexpected, and the case where it is slow, not only the case where it succeeds.

What is a real-world example of an interface mismatch causing serious damage?

Knight Capital lost approximately 440 million dollars in 45 minutes in August 2012 after a deployment left old trading code active on one server while new code ran on the others. The servers disagreed about what a particular signal meant, and that mismatch, not a bug inside any single function, drove millions of erroneous orders into the market before anyone could stop it.

Should every function have both a unit test and an integration test?

No. Most functions only need a unit test, because most bugs are local logic errors that a unit test finds cheaply. Integration tests are for boundaries specifically: the points where your code hands data to, or receives data from, something it does not control, which is a much smaller set of places than the total number of functions in a codebase.

Are integration tests slower than unit tests, and does that matter?

Yes, usually by a wide margin, because an integration test touches a real database, a real queue, or a real network call instead of a stand-in. That is a reason to keep the count focused on real boundaries rather than everywhere, not a reason to skip them, since the bug class they catch does not show up any other way.

How does this change with AI-generated code?

Generated code is often locally correct: the function does what its own description asks. It is weaker at knowing what a neighboring service actually returns on a timeout, a partial failure, or a field the documentation does not mention, because that knowledge is not visible from the function's own code. That makes the boundary, not the function, the place worth checking first.

What is a contract test and how does it relate to integration testing?

A contract test checks that a stand-in used in a fast unit test still matches what the real dependency actually returns, by running the same assertions against both. It lets a team keep most tests fast and mocked while still catching the day the real service's behavior changes and the mock quietly falls out of date.

What should a team do first if it has no integration tests at all?

List every external call the service makes: other services, databases, queues, third-party APIs. Pick the one most recently involved in an incident or the one carrying the most request volume, and write tests for its failure responses first, not its success response, since the success path is usually already covered by other tests.