An integration test verifies that two or more components that work fine on their own also work fine when connected. It does not examine an isolated function, the way a unit test does, but the seam between pieces: one module calling another, the query that reaches the database, the response arriving from an external service. The error it hunts for does not live inside a component, but in the space between them.
That is the short answer. The useful answer is understanding why this intermediate level of testing exists, why it is not enough to have each module well tested on its own, and when it is worth paying its cost. Because integration costs more than a unit test: it is slower, more brittle and harder to diagnose. The question is not whether to use it, but where.
01 · the seamWhat integration tests see that unit tests cannot
Remember the testing pyramid. At the base sit many unit tests, fast and isolated. In the middle, fewer integration tests. At the top, a handful of end to end tests. Integration occupies the second level because its scope is intermediate: more than one unit, less than the whole system.
The problem it solves is concrete. A unit test examines a function without real dependencies: to do so, it replaces everything around it with controlled substitutes. That is exactly what makes it fast and reliable, but also what makes it blind to one kind of failure. If module A assumes that module B returns a date in one format, and B actually returns it in another, the unit tests for both A and B pass. Each one tested its side of the contract with a substitute that honored the wrong assumption. The system fails only when A and B actually talk to each other.
An integration bug is not inside any single component. It lives in the assumption one component makes about another.
This is called a contract: the implicit or explicit agreement about what a component sends and what it expects to receive. Data format, field order, what happens when something goes wrong, how long a response takes. An integration test is, at heart, a check that both sides understand the contract the same way. ISTQB defines integration testing precisely as testing that focuses on the interactions between components or systems [1].
02 · the decisionTest doubles or real dependencies
Here is the decision that defines an integration test: do you use the real component on the other side of the seam, or a controlled substitute? Those substitutes are called test doubles, by analogy with the stunt doubles of film. There are several kinds, and it pays not to mix up the names: a stub returns fixed responses prepared in advance; a mock also verifies that it was called the way you expected; a fake is a lightweight but working implementation, like an in memory database instead of the real one. Gerard Meszaros ordered and named these patterns in his xUnit catalog [2].
The practical rule is simple to state and hard to apply: use real dependencies when what you want to test is precisely the integration with that dependency, and use doubles when that dependency is just noise for the test you care about.
If you are testing that your code stores and retrieves data from a database correctly, use a real database, or at least a faithful fake, because the point of the test is that conversation. Replacing the database with a stub that always says "saved" would prove nothing: you would be testing the stub. On the other hand, if you are testing the logic of a module that, along the way, sends an email, do not spin up a real mail server: put in a double that records that a send was attempted. The email is not what you are verifying.
The more doubles you add, the faster and more stable the test, and the less it resembles reality. That is the tradeoff, always.
A double encodes an assumption about how the real component behaves. If that assumption is wrong, or goes stale when the component changes, your test stays green while the real system is already broken. A test that passes on a false premise is worse than no test at all: it gives confidence with nothing behind it. That is why real dependencies, though more expensive, remain irreplaceable at exactly the seams that matter most to you.
03 · the costWhy catching an integration failure late is expensive
All of this rests on an economic reason. The cost of fixing a defect grows as it moves through the stages of development. A failure you catch while writing the code costs little: you fix it in minutes, with all the context fresh in your head. The same failure discovered in production, after passing through integration, system testing and deployment, costs far more: you have to reproduce it, trace which components are involved, coordinate the fix and redeploy. This idea, that the cost of correction scales with the distance between when a defect is introduced and when it is detected, is a classic principle of software engineering [3].
Integration failures are especially expensive to catch late for one reason: they are hard to attribute. When the whole system fails, you do not know whether the blame lies with A, with B, or with how they talk to each other. The symptom appears far from the cause. A good set of integration tests shortens that distance: instead of discovering the problem when a user reports something odd, you discover it when the test that exercises that specific seam turns red. You know which two pieces stopped understanding each other.
04 · the balanceWhen to use them and how many to keep
The shape of the pyramid already carries the answer: you want fewer integration tests than unit tests and more than end to end ones. Not because they are worth less, but because each one costs more to write and to maintain, and takes longer to run. If you try to cover everything with integration, you end up with a slow, brittle suite that the team stops running. If you have none, every deployment is a bet on seams that nobody verified.
The practical guidance is to prioritize by risk. Write integration tests at the seams where a failure would be expensive or hard to detect: the boundary with the database, the conversation with a payment service, the point where your code receives data from a third party you do not control. Leave out what a unit test already covers well: the internal logic of a module does not need a real database to be verified.
Integration testing replaces neither unit testing nor end to end testing. It sits between the two to cover the blind spot of both: the place where correct pieces, each on its own, stop understanding each other once they finally connect. That blind spot is real, it is frequent, and it is expensive if you discover it late. That is why this level of the pyramid exists.
Sources
- International Software Testing Qualifications Board (ISTQB). Certified Tester Foundation Level (CTFL) Syllabus, v4.0 (2023). Section on test levels: definition of integration testing as a focus on the interactions between components or systems. istqb.org.
- Meszaros, G. (2007). xUnit Test Patterns: Refactoring Test Code. Addison-Wesley. Catalog of test doubles: stub, mock, fake, spy and dummy.
- International Organization for Standardization. ISO/IEC/IEEE 12207:2017, Systems and software engineering: Software life cycle processes. Reference framework for verification processes and early defect detection across the life cycle.