Integration Testing
Date: 2026-08-17
Testing pieces working together rather than in isolation. It’s where most real bugs live, because most real bugs are at the seams — and it’s the tier that gives the best confidence per minute of test runtime.
An integration test exercises several components together with their real collaborators — a real database, a real render, a real HTTP handler — rather than mocked substitutes.
The seams are where things break. Individually correct units frequently don’t fit: a field named differently, a null nobody expected, a transaction boundary in the wrong place.
What it looks like
A component, rendered properly:
test('shows a validation error for a bad postcode', async () => {
render(<CheckoutForm />)
await user.type(
screen.getByLabelText('Postcode'), 'XXX')
await user.click(
screen.getByRole('button', { name: 'Continue' }))
expect(
await screen.findByText(/postcode wasn't recognised/i)
).toBeVisible()
})Note what’s being asserted: what a user sees, found the way a user would find it — by label and by role. No component internals, no state inspection — Unit Testing.
An API route, with a real database:
test('rejects an order for out-of-stock items', async () => {
await db.products.insert({ id: 1, stock: 0 })
const res = await request(app)
.post('/orders')
.send({ productId: 1, quantity: 1 })
expect(res.status).toBe(409)
expect(await db.orders.count()).toBe(0)
})The second assertion matters as much as the first — it verifies nothing was written, which a status-code check alone doesn’t.
Query by role, not by test id
// resilient, and tests accessibility
screen.getByRole('button', { name: 'Add to basket' })
screen.getByLabelText('Postcode')
// fragile, tests nothing about usability
screen.getByTestId('add-btn')
container.querySelector('.btn-primary')Querying by accessible role and name means the test fails if the element stops being reachable by assistive technology. It’s an accessibility check arriving free with every test you were writing anyway — Screen Readers, Accessible Forms.
Test IDs are a fallback, legitimate when there’s genuinely no accessible handle — and that absence is usually itself the bug.
Real dependencies versus fakes
| Dependency | Approach |
|---|---|
| Your database | Real — a test instance or container |
| Your own modules | Real — that’s the point |
| Third-party HTTP APIs | Intercept at the network layer |
| Payment providers | Their sandbox, or a fake |
| Time | Freeze it |
| Randomness | Seed it |
Use a real database. An in-memory substitute has different behaviour around transactions, constraints, collation and concurrency — which is exactly the class of bug this tier exists to catch — Transactions and ACID.
Containers make this easy, and it’s their strongest use case in testing — Containers.
Intercept HTTP at the network layer
// intercepts the actual request
server.use(
http.get('/api/prices', () =>
HttpResponse.json({ price: 4999 }))
)Better than mocking your fetch wrapper, because your real client code runs — including the URL construction, headers, error handling and parsing, which is where client bugs actually are.
Test isolation
The recurring failure at this tier, because real state persists between tests:
EACH TEST
starts from a known state
cleans up, or runs in a transaction
that rolls back
does not depend on order
can run in parallel
Transaction rollback per test is the cleanest pattern where the framework supports it — fast, and complete.
A test that passes alone and fails in the suite is a state-leak, and it’s the most common source of flakiness here — Flaky Tests, Test Data.
The cost, honestly
UNIT milliseconds
INTEGRATION tens to hundreds of ms
E2E seconds
Integration tests are 10–100× slower than unit tests, which is why the pyramid puts them in the middle. In practice the confidence per second is usually the best of the three tiers, which is the argument for weighting towards them anyway — Testing Strategy.
Keep the suite parallelisable and it stays viable well past the point people expect.