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
nullfor 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:
- Every external API call.
- Every database or queue the service reads from or writes to.
- 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
- SEC, Press Release 2013-222: SEC Charges Knight Capital With Violations of Market Access Rule: confirms the approximately 440 million dollar pre-tax loss, the 45-minute window, and that millions of erroneous orders were routed while filling 212 customer orders.
- Martin Fowler, The Practical Test Pyramid: source for the contract-testing approach to keeping mocked integration tests aligned with what the real dependency actually returns.


