🔗 Integration Testing Strategies
Unit tests prove each part works on its own. Integration tests prove the parts work together — that your route talks to its controller, the controller talks to the database, and real data flows from an HTTP request all the way to a row on disk and back. This lesson is your map of how to test those seams.
Week 12 · Day 2 (Tuesday: Integration Testing) · Lecture 1
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain how an integration test differs from a unit test and from an end-to-end test
- Identify the integration boundary in a typical web app: request → route → controller → database
- Choose which dependencies to use for real and which to fake, and justify the trade-off
- Design tests that stay isolated — no shared state, no order dependence
- Structure the
beforeAll/beforeEach/afterAllsetup and teardown lifecycle correctly - Recognize and fix flaky integration tests
Estimated Time: 65 minutes
Practice: Sketch the integration boundary for a small API and write a first slice test that spans route, controller, and database.
In This Lesson
Why Integration Tests?
Imagine a jigsaw puzzle. A unit test checks that a single piece is cut correctly — the right shape, the right picture on its face. But a box full of perfect pieces is not a finished puzzle. An integration test checks that adjacent pieces actually lock together and that the picture lines up across the seam.
In a full-stack app the "seams" are everywhere: the HTTP route hands the request to a controller, the controller calls a service, the service reads and writes through a repository, and the repository talks SQL to a real database. Every one of those hand-offs is a place where two things that each work alone can still fail together — a mismatched field name, a wrong data type, a forgotten await, a status code the caller didn't expect.
🚀 A famous integration failure
In 1999 NASA lost the Mars Climate Orbiter — a spacecraft worth hundreds of millions of dollars. One team's software produced numbers in imperial units while another team's software expected metric. Each program was individually correct. The bug lived only in the space between them. Unit tests would never have found it. An integration test that fed one module's output into the other would have caught it in seconds.
That is the whole value proposition: integration tests catch the class of bug that unit tests, by design, cannot see.
The Integration Boundary
Before you write a single test, you need to know exactly what you are integrating. The clearest way to think about it is to trace one request from the outside world to the database and back. Everything that request touches is inside your integration boundary.
Notice what stays outside the box: the browser UI, and any third-party service you don't own (a payment gateway, an email provider). Those are usually faked at the edge so your test stays fast and deterministic. Everything you own — routes, controllers, services, repositories, database — runs for real. That is the sweet spot of an integration test: broad enough to catch wiring bugs, narrow enough to stay fast and repeatable.
Integration vs. Unit vs. E2E
These three test types form a spectrum from "one tiny piece, in isolation" to "the whole system, like a real user." Each has a job. Integration testing lives comfortably in the middle.
| Aspect | Unit test | Integration test | End-to-end test |
|---|---|---|---|
| Scope | One function or class | Several units working together | The whole app, browser included |
| Dependencies | Mocked / stubbed | Real (DB, services you own) | All real, plus the UI |
| Speed | Milliseconds | Tens to hundreds of ms | Seconds |
| Catches | Logic bugs | Wiring & contract bugs | User-flow bugs |
| Count you want | Many | A solid middle layer | A focused few |
This is the famous testing pyramid: a wide base of fast unit tests, a healthy middle band of integration tests, and a small cap of slow end-to-end tests. Integration tests give you the best confidence-per-second in the whole suite — they touch real components without paying the full cost of spinning up a browser.
A concrete example. Consider adding an item to a shopping cart:
- Unit test:
Cart.addItem()increments the quantity correctly given a product object. - Integration test: a
POST /api/cartrequest runs through the route, controller, and repository, and afterwards the row really exists in the database. - E2E test: a real browser clicks "Add to cart" and the cart badge updates to "1".
What to Run Real, What to Fake
The single most important decision in an integration test is which dependencies are real. Get this wrong and you either write a slow, brittle end-to-end test by accident, or a "unit test in disguise" that mocks so much it proves nothing.
Prefer a real database
For anything data-related, use a real database — the same engine you run in production (Postgres, MongoDB, whatever it is), just a dedicated test instance. Mocking the database is the classic mistake: a mock happily returns whatever you told it to, so it can never catch a broken SQL query, a violated constraint, a bad migration, or a type mismatch. Those are exactly the bugs integration tests exist to find.
// ❌ Mocking the DB — this test can never catch a real query bug
const db = { findUserByEmail: jest.fn().mockResolvedValue({ id: 1 }) };
// ✅ A real (test) database — the query actually runs against Postgres
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
💡 Two easy ways to get a real test database
Testcontainers spins up a throwaway Postgres/MySQL/Mongo container from inside your test run, then tears it down — real engine, zero manual setup. A dedicated test database (a separate schema named app_test) is simpler if you already have the engine installed locally and in CI. Both are covered in depth in the Database testing lesson two steps ahead.
Fake what you don't own
Third-party HTTP services are the opposite call. You don't want your test suite to charge a real credit card or send a real email, and you don't want it to fail when someone else's server is down. Fake those at the network edge — with a library like nock — so the response is fast and deterministic.
const nock = require('nock');
// Intercept the outbound call to the payment provider and return a canned success
nock('https://api.payments.example.com')
.post('/v1/charges')
.reply(200, { id: 'ch_test_123', status: 'succeeded' });
✅ The rule of thumb
Run the code you own for real (routes, controllers, services, database). Fake the code you don't (external APIs, payment, email, SMS). This keeps the test honest about your system while staying fast and reliable.
The Setup & Teardown Lifecycle
Every integration test needs a known starting point and a clean exit. Test runners give you four lifecycle hooks to arrange that. Using them correctly is the difference between a suite that is rock-solid and one that fails randomly on Tuesdays.
| Hook | Runs | Typical job |
|---|---|---|
beforeAll | Once, before the file's tests | Connect to the DB, run migrations |
beforeEach | Before every test | Reset data, seed fixtures |
afterEach | After every test | Roll back a transaction, clean up |
afterAll | Once, after the file's tests | Disconnect, stop the container |
The expensive, once-per-file work (opening a connection, applying the schema) goes in beforeAll and is undone in afterAll. The cheap, per-test reset that guarantees isolation goes in beforeEach. Here is the canonical shape:
const request = require('supertest');
const app = require('../src/app'); // the Express app, NOT a running server
const db = require('../src/db');
beforeAll(async () => {
await db.connect(); // open the pool once
await db.migrate(); // ensure the schema exists
});
afterAll(async () => {
await db.disconnect(); // close the pool so Jest can exit cleanly
});
beforeEach(async () => {
await db.truncateAll(); // every test starts from an empty, known state
});
describe('POST /api/users', () => {
test('creates a user and persists it', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Ada', email: 'ada@example.com', password: 'Str0ng!pass' })
.expect(201);
// Assert on the HTTP response...
expect(res.body.id).toBeDefined();
// ...AND verify the row actually landed in the database
const saved = await db.findUserByEmail('ada@example.com');
expect(saved).toBeTruthy();
expect(saved.name).toBe('Ada');
});
});
The sequence diagram below shows the order these hooks fire around a two-test file. Read it top to bottom.
⚠️ Test the app, not a live port
Notice the code imports app, not server. Split them: app.js builds and exports the Express instance; server.js calls app.listen(). Tests import app and hand it to supertest, which spins up an ephemeral port automatically. You never bind to port 3000, so tests can run in parallel without fighting over it.
Keeping Tests Isolated
The golden rule of a healthy suite: each test must pass or fail on its own, in any order, with no memory of the tests before it. When that breaks, you get the worst kind of bug — a test that passes alone but fails in the suite, or passes on your machine but fails in CI.
Reset the database between tests
There are two common ways to guarantee a clean slate before each test:
- Truncate / delete: in
beforeEach, empty every table. Simple and obviously correct. - Transaction rollback: wrap each test in a transaction opened in
beforeEachand rolled back inafterEach. Nothing is ever committed, so the next test sees a pristine database. Faster than truncating because no data is actually written to disk.
// Transaction-per-test: fast isolation with automatic cleanup
beforeEach(async () => {
await db.query('BEGIN');
});
afterEach(async () => {
await db.query('ROLLBACK'); // undo everything this test did
});
Seed fixtures deliberately
A test that needs data should create exactly the data it needs, in its own setup — never rely on rows a previous test happened to leave behind. Use a small factory so each test gets fresh, unique data:
// A factory produces valid test data with a unique email every time
function makeUser(overrides = {}) {
const unique = Math.random().toString(36).slice(2, 8);
return {
name: 'Test User',
email: `user-${unique}@example.com`, // unique avoids collisions
password: 'Str0ng!pass',
...overrides, // let a test tweak one field
};
}
test('an admin can be created', async () => {
const admin = await db.createUser(makeUser({ role: 'admin' }));
expect(admin.role).toBe('admin');
});
📖 The smell of shared state
If a test only passes when you run the file top to bottom, it is secretly depending on state from an earlier test. The fix is always the same: move the setup that test needs into its own beforeEach and reset between tests. Order-independence is not a nice-to-have — it is the definition of a trustworthy suite.
Fighting Flaky Tests
A flaky test is one that sometimes passes and sometimes fails without any code change. Flaky integration tests erode trust in the whole suite — people start ignoring red builds. Most flakiness comes from three sources.
1. Timing and async races
Never setTimeout and hope the work finished. Await the actual promise, or poll for the real condition with a bounded loop.
// ✅ Poll for a condition instead of guessing a delay
async function waitFor(check, { attempts = 10, interval = 50 } = {}) {
for (let i = 0; i < attempts; i++) {
if (await check()) return;
await new Promise(r => setTimeout(r, interval));
}
throw new Error('Condition was not met in time');
}
await waitFor(() => db.getJobStatus(id).then(s => s === 'done'));
2. Leftover state
Covered above — reset in beforeEach, and make sure afterAll actually closes connections so nothing leaks into the next file.
3. Real external calls
If a test reaches a real third-party server, it will fail whenever that server is slow or down. Intercept those calls so they are deterministic. A good habit is to fail loudly on any un-mocked outbound request during tests, so a stray real call can't sneak in.
const nock = require('nock');
beforeAll(() => nock.disableNetConnect()); // block all real HTTP during tests
afterAll(() => nock.enableNetConnect());
afterEach(() => nock.cleanAll()); // no interceptor bleeds into the next test
Practice & Quiz
🏋️ Exercise 1: Draw the boundary, write the first slice
Goal: For a tiny "notes" API with POST /api/notes and GET /api/notes/:id, write one integration test that creates a note over HTTP and then confirms it was persisted, using the correct lifecycle hooks.
const request = require('supertest');
const app = require('../src/app');
const db = require('../src/db');
// TODO: connect in beforeAll, truncate in beforeEach, disconnect in afterAll
// TODO: POST a note, expect 201, then read it back from the DB and assert
💡 Hint
Expensive work (connect, migrate) goes in beforeAll; the per-test reset (truncateAll) goes in beforeEach. After the POST, don't trust the response alone — query the database directly and assert the row exists.
✅ Solution
beforeAll(async () => {
await db.connect();
await db.migrate();
});
afterAll(async () => {
await db.disconnect();
});
beforeEach(async () => {
await db.truncateAll();
});
test('creates and persists a note', async () => {
const res = await request(app)
.post('/api/notes')
.send({ title: 'Buy milk', body: 'Two liters' })
.expect('Content-Type', /json/)
.expect(201);
expect(res.body.id).toBeDefined();
// Verify it really reached the database
const saved = await db.findNoteById(res.body.id);
expect(saved).toBeTruthy();
expect(saved.title).toBe('Buy milk');
});
🏋️ Exercise 2: Fix the flaky test
Goal: This test passes on its own but fails when run after another test that creates a user with the same email. Explain why, then fix it so it is order-independent.
test('finds the user', async () => {
// assumes a user with this email already exists from an earlier test
const user = await db.findUserByEmail('ada@example.com');
expect(user.name).toBe('Ada');
});
✅ Solution
The test depends on data left behind by another test — shared state. Make it create its own data first:
test('finds the user', async () => {
// Arrange its OWN data — no dependence on other tests
await db.createUser({ name: 'Ada', email: 'ada@example.com', password: 'Str0ng!pass' });
const user = await db.findUserByEmail('ada@example.com');
expect(user.name).toBe('Ada');
});
🎯 Quick Quiz
Question 1: For a repository that runs raw SQL, why is a real test database better than a mocked one?
Question 2: Which hook should open the database connection?
Question 3: A test passes alone but fails when the whole file runs. What is the most likely cause?
Best Practices & Pitfalls
✅ Do
- Run the code you own for real; fake only third-party services you don't control
- Use a real, dedicated test database (Testcontainers or a
*_testschema) - Reset to a known state in
beforeEach— truncate or roll back a transaction - Let each test create its own data with a factory that generates unique values
- Assert on both the HTTP response and the resulting database state
- Import the
app, hand it to supertest, and never bind a live port
❌ Don't
- Mock the database in an integration test — you'd be testing your mock, not your query
- Share state between tests or rely on test execution order
- Sleep with
setTimeoutand hope; await the real condition instead - Let a real outbound HTTP call sneak into a test — block the network and intercept
- Forget
afterAllcleanup — leaked connections make later files hang
⚠️ The "mock everything" trap
If your "integration" test mocks the database, the service, and the network, you have written an expensive unit test that proves nothing about how the pieces fit. When you catch yourself mocking a dependency you own, ask whether this should be a real integration test or an honest unit test — don't build something stuck in between.
Summary
🎉 Key Takeaways
- Integration tests verify that multiple units work together across the request → route → controller → database boundary
- They catch wiring and contract bugs that unit tests, by design, cannot
- Run real what you own; fake third parties at the network edge
- Use a real test database — never mock it
- Put once-per-file work in
beforeAll/afterAll, and the per-test reset inbeforeEach - Guarantee isolation: reset between tests, seed your own fixtures, never depend on order
📚 Additional Resources
- Martin Fowler — IntegrationTest (the terminology, clearly)
- supertest — HTTP assertions against an Express app
- Jest — Setup and Teardown hooks
- Testcontainers for Node.js — real databases in tests
🚀 What's Next?
You now know what to test and how to structure the suite. Next we get hands-on with the tool that drives the HTTP layer: Testing API Endpoints — using supertest to send real requests to your Express app, assert on status and body, and cover authentication and error paths.
🎉 Great work!
You can now see the seams in your application — and you have a plan for testing every one of them.