Skip to main content

🔗 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 / afterAll setup 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.

A request flows from client through route, controller and service into the database and a response returns along the same path HTTP request Route Express Controller + service Repository SQL / ORM Test DB real, isolated Integration boundary — exercised together in one test supertest sends this assertions verify this
An integration test drives the real request path end to end — everything inside the dashed box runs for real, and a genuine (but isolated) test database sits at the far end.

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.

AspectUnit testIntegration testEnd-to-end test
ScopeOne function or classSeveral units working togetherThe whole app, browser included
DependenciesMocked / stubbedReal (DB, services you own)All real, plus the UI
SpeedMillisecondsTens to hundreds of msSeconds
CatchesLogic bugsWiring & contract bugsUser-flow bugs
Count you wantManyA solid middle layerA 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.

graph TD A["Unit tests — many, fast, isolated"] --> B["Integration tests — the confident middle"] B --> C["End-to-end tests — few, slow, whole-system"] B --> D["Route plus controller plus DB together"] B --> E["Real database, faked third parties"]

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/cart request 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.

HookRunsTypical job
beforeAllOnce, before the file's testsConnect to the DB, run migrations
beforeEachBefore every testReset data, seed fixtures
afterEachAfter every testRoll back a transaction, clean up
afterAllOnce, after the file's testsDisconnect, 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.

sequenceDiagram participant R as Test runner participant DB as Test database R->>DB: beforeAll connect and migrate R->>DB: beforeEach truncate all tables R->>DB: run test one and assert R->>DB: beforeEach truncate all tables R->>DB: run test two and assert R->>DB: afterAll disconnect and clean up

⚠️ 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 beforeEach and rolled back in afterEach. 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 *_test schema)
  • 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 setTimeout and hope; await the real condition instead
  • Let a real outbound HTTP call sneak into a test — block the network and intercept
  • Forget afterAll cleanup — 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 in beforeEach
  • Guarantee isolation: reset between tests, seed your own fixtures, never depend on order

📚 Additional Resources

🚀 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.