Skip to the content
Software Made Clear Diagrams that show the mechanism About

Unit vs integration test

ANSWER

Unit tests tell you a piece is wrong; integration tests tell you the pieces do not agree. You need both, because the second kind of failure never shows up in the first kind of test however many of them you write.

IN PLAIN TERMS

Measuring each piece of a flat-pack against its drawing catches the one that was cut wrong; only bolting them together catches the two that were each correct and still do not line up. One test is like the tape measure, the other like the trial fit.

Somewhere a diagram with a pyramid on it told you to write more of one kind of test than the other, and none of that tells you which test to write for the bug in front of you right now. The proportions are not the useful question. The useful one is what each kind of test is actually positioned to catch — a piece of code getting its own logic wrong, or two pieces of code each doing exactly what their own tests promised and still not agreeing with each other. Those are different failures. They need different tests, and no quantity of one kind stands in for the other.

Two different failures#

The distinction that actually matters is not how much code a test touches, it is which class of defect the test is positioned to catch. A unit test isolates one piece — a function, a class, a single module — from everything around it and checks that piece against a contract it alone is responsible for: given this input, does it produce the output the code committed to. An integration test removes that isolation on purpose and lets two or more pieces run against each other, checking not what either side does alone but whether what one side sends is what the other side actually expects.

That second question is the one no amount of unit testing can answer, because each side of a boundary is only ever checked against its own understanding of the contract. A unit test catches a piece behaving wrongly against its own rules. It cannot catch two pieces that each honour their own rules perfectly and still disagree — a serialisation format one side changed while the other still expects the old shape, a null the receiving side never accounted for, an ordering the sending side never actually promised. Every unit test involved in that failure keeps passing, because none of them was ever positioned to see the gap between the two sides. Only a test that puts both sides in the same room can.

What the boundary costs you#

Every test buys confidence and pays for it in speed and coupling, and the two kinds spend that budget in opposite directions. A unit test earns its speed by standing a test double in place of whatever the piece under test depends on — a stub that returns a canned answer, a fake that behaves like the real thing but takes a shortcut a production system could not afford, occasionally a mock that checks not what came back but whether the right calls were made at all. That substitute is what makes the test fast and repeatable, and it is also the exact place the test stops proving anything: the moment the real dependency’s behaviour drifts from what the double was built to return, the unit test keeps passing, because it was only ever checked against the double’s promise, not the dependency’s actual behaviour.

An integration test removes that substitute, or most of it, and proves more as a result — at the cost of the setup, wall-clock time and occasional flakiness that comes from depending on something real. The practical rule this yields: the more a test leans on a double standing in for the other side of a boundary, the more it is testing your own understanding of that side rather than the side itself. Idempotency keys and safe retries works through exactly the kind of boundary where that gap matters most — what a server owes a caller that retries, and what the caller is entitled to assume it will get back. Read from either side in isolation, both halves of that contract can look correct: the server’s own tests exercise the promise it believes it is keeping, the caller’s own tests exercise the retry it believes is safe to send, and neither one, on its own, can tell you whether the two actually agree. Only a test that carries a real request across that boundary can.

Green suite, broken deploy#

The shape this produces is familiar: the suite is green and the deploy is broken. Every unit test passes because every substitute in it behaves the way its author believed the real dependency behaves — and that belief is wrong, or was right once and has quietly gone stale since. The failure was never inside any of the pieces the suite checked one at a time. It was in the gap between two pieces, and nothing in a green suite built entirely of isolated checks was ever positioned to see it.

The characteristic shape is specific enough to recognise: a client library that returns a slightly different payload after an upstream upgrade nobody’s stub was updated to match, a database that rejects an insert a fake accepted without complaint, an ordering nobody on either side actually guaranteed but every substitute quietly assumed anyway. The tell is where the failures cluster — not scattered through the logic the unit suite already covers, but bunched at the boundaries the suite never actually crosses. Each one arrives looking like a one-off surprise. It is the same gap, showing up again wherever a test double stood in for something that changed underneath it.

Standing a double in for a dependency is not the mistake here. It is close to the only way most of a suite can stay fast enough to run on every change, and making every test talk to the real thing would spend the speed a unit suite exists to buy on a guarantee integration tests already provide more directly where it counts. Deciding where that boundary should sit in the first place, and which team owns which side of it, is what the architecture roadmap exists to work through — a roadmap this article points toward rather than belongs to. The fix for what goes wrong here is smaller and more local: something in the pipeline, somewhere, still has to exercise the real dependency instead of a description of it.

Situation Take Because
Logic with branches you can enumerate Unit test Fast, and the failure names the line
Two components must agree on a format Integration test Neither one's own test can catch this
The bug was a wrong assumption about a dependency Integration test The substitute shared the assumption
You need the suite to stay fast Unit test Buy speed here, spend it at the boundary

IF YOU REMEMBER ONE THING

The two kinds of test are positioned to catch two different failures, and no quantity of one stands in for the other. A green suite built entirely of isolated checks is a suite that has never crossed the boundary the bug is actually sitting on.

Questions people also ask

5 QUESTIONS
What is the difference between an integration test and an end-to-end test?

An integration test checks that two or more of your own components agree — a service and its database, or two services across a real connection. An end-to-end test drives the whole system the way an external caller would, through every layer at once, including systems you do not own. Both cross real boundaries; one isolates a boundary on purpose, the other proves a whole path works.

Should I mock the database?

Rarely, if the point is a test that says something true about the database boundary itself. A stub returning canned rows, or a fake standing in for the real engine, hides exactly the failures that boundary produces — a query that parses but means the wrong thing, a constraint the substitute never enforced. A disposable real database tells you more than a substitute agreeing with what you already believed.

How many of each should I write?

There is no fixed split that transfers from one codebase to another — the two failure classes are not evenly distributed across every system. The workable rule is by shape, not by count: a unit test for every branch of logic you can enumerate, an integration test for every boundary where two independently maintained components have to agree on something neither can fully see alone.

Why do my integration tests keep flaking?

Usually because something the test depends on is not actually held still — shared state from an earlier run, a clock or queue with uncontrolled timing, an external service that is itself unreliable, an ordering the test assumes without the system guaranteeing it. The fix is rarely running it again; it is finding which piece of the environment the test trusts without controlling.

Do integration tests replace unit tests?

No — they catch a different class of failure, not a more complete version of the same one. A unit test still finds a piece of logic behaving wrongly against its own contract faster and more precisely than an integration test ever will, because it points straight at the piece instead of at the boundary around it. Replacing one with the other trades one blind spot for the opposite one.