Integration tests — what they catch that unit tests can't
Unit tests verify pieces work correctly in isolation, which means they're structurally unable to catch the entire category of bug that only shows up when those correct pieces are actually wired together.
3 min read
A bug every unit test involved would still miss
// UserRepository.save — unit tested, passes
function save(user) {
return db.query("INSERT INTO users (email, name) VALUES ($1, $2)", [user.email, user.name]);
}
// EmailService.notify — unit tested, passes
function notify(email, message) {
return mailer.send({ to: email, body: message });
}
// registerUser — orchestrates both, but the WIRING is wrong
async function registerUser(data) {
await save(data);
await notify(data.emailAddress, "Welcome!"); // BUG: should be data.email — this field doesn't exist
}save is correct in isolation. notify is correct in isolation. Every unit test for both, individually, passes — because a unit test for notify calls it directly with a valid email string, never through registerUser's (wrong) field name. The bug lives entirely in the wiring between two individually-correct pieces, which is exactly the category of bug unit tests are structurally unable to catch, since isolating a piece from its collaborators is the whole point of a unit test.
An integration test exercises the real wiring
test("registering a user saves them and sends a welcome email", async () => {
const db = await createTestDatabase(); // a real (test) database, not mocked
const mailer = new FakeMailer(); // a fake — see the test-doubles lesson
await registerUser({ email: "ada@example.com", name: "Ada" }, { db, mailer });
const savedUser = await db.query("SELECT * FROM users WHERE email = $1", ["ada@example.com"]);
expect(savedUser).toBeDefined(); // catches the wiring bug immediately —
expect(mailer.sentMessages).toHaveLength(1); // notify was never actually called with a valid email
});This test calls registerUser the way a real caller would, with real components genuinely connected to each other — a real (if disposable) test database, and a fake mailer that's a real, working implementation rather than a mock told what to return. Because nothing here is mocked out at the boundary between save and notify, the wrong field name actually breaks this test: notify gets called with undefined, the fake mailer records nothing, and the assertion fails — catching exactly the bug the two unit tests, individually, couldn't see.
The real trade-off: integration tests are slower and less precise about the failure
An integration test that fails tells you "something in this multi-piece flow is broken" — not which piece, the way a focused unit test failure does. It's also inherently slower: a real (even disposable/in-memory) test database is orders of magnitude slower than a pure function call. This isn't a reason to skip integration tests — the wiring bug above is real and only they catch it — but it's the reason a healthy suite has both: many fast, precise unit tests for individual logic, and a smaller number of integration tests specifically covering the seams where pieces actually connect. The next-but-one lesson (the test pyramid) gives this trade-off its usual name and shape.
What actually counts as "integration" varies, and that's fine
"Integration test" gets used for anything from "two of my own functions calling each other for real" to "my whole backend talking to a real database and a real (sandboxed) third-party API." The unifying idea, regardless of scope, is the same: exercising a real connection between pieces instead of substituting a double at the boundary — the specific thing a unit test, by design, doesn't do.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What category of bug can two individually-passing unit tests still fail to catch?
2. What does an integration test do differently from a unit test to catch wiring bugs?
3. What's the real trade-off of integration tests compared to unit tests?