Understand what integration testing covers that unit tests miss, when to write integration tests, and the key differences in test setup, speed, and confidence between the two layers.
Unit tests verify that individual functions behave correctly in isolation. But software fails at the seams — where modules connect, where code meets the database, where HTTP requests are parsed and responses assembled. A perfectly unit-tested codebase can still ship a broken API endpoint because the unit tests never exercise the wiring between layers.
Integration tests close this gap. They exercise multiple real components together — a real HTTP server, a real database query, a real module dependency chain — and verify that the pieces fit. This lesson explains what integration tests are, which bugs they catch that unit tests miss, and how to structure them so they run reliably without becoming slow and flaky.
Unit tests mock dependencies. That is their strength — isolation and speed. But it means the contracts between modules are never verified.
1// The unit test mocks the repository perfectly:
2test('createUser returns user with hashed password', async () => {
3 const mockRepo = { save: jest.fn().mockResolvedValue({ id: 'u1' }) };
4 const result = await createUser({ email: '[email protected]', password: 'pass' }, mockRepo);
5 expect(result.id).toBe('u1');
6});
7// PASSES — but the real repository uses 'insert', not 'save'!
8
9// The integration test catches this immediately:
10test('POST /users creates a user in the database', async () => {
11 const res = await request(app).post('/users').send({
12 email: '[email protected]',
13 password: 'SecurePass1!',
14 });
15 // FAILS — TypeError: repo.save is not a function
16 // because real UserRepository has insert(), not save()
17 expect(res.status).toBe(201);
18});Bugs that only integration tests catch:
Integration tests fall into three practical categories, each testing a different seam:
Category 1 — Module-to-Module: Two or more real modules wired together, no external I/O.
1// UserService + EmailValidator + PasswordHasher all real, only DB mocked
2test('register sets correct initial user state', async () => {
3 const fakeRepo = { save: jest.fn().mockResolvedValue({ id: 'u1' }) };
4 const service = new UserService(new EmailValidator(), new PasswordHasher(), fakeRepo);
5 const user = await service.register({ email: '[email protected]', password: 'Pass1!' });
6 expect(user.emailVerified).toBe(false);
7 expect(user.passwordHash).not.toBe('Pass1!'); // real hasher ran
8});Category 2 — Service-to-Database: Real service + real test database. Catches SQL issues and schema mismatches.
1test('findByEmail returns null when no user exists', async () => {
2 const repo = new UserRepository(testDb);
3 const user = await repo.findByEmail('[email protected]');
4 expect(user).toBeNull();
5});Category 3 — API Endpoint Tests: HTTP request through the full stack — router, middleware, controller, service, real or test database.
1test('GET /users/:id returns 404 for unknown user', async () => {
2 const res = await request(app).get('/users/nonexistent-id');
3 expect(res.status).toBe(404);
4 expect(res.body.error).toBe('User not found');
5});Use all three categories. Category 1 runs in milliseconds. Category 2 needs a test database. Category 3 is the most valuable but slowest — keep these focused on API contracts.
Integration tests that share a database create flaky, order-dependent failures. Each test suite must own its data:
1// jest.integration.config.ts
2import type { Config } from 'jest';
3
4const config: Config = {
5 preset: 'ts-jest',
6 testEnvironment: 'node',
7 testMatch: ['**/*.integration.test.ts'],
8 globalSetup: './src/test/global-setup.ts',
9 globalTeardown: './src/test/global-teardown.ts',
10 setupFilesAfterFramework: ['./src/test/setup.ts'],
11 testTimeout: 30000,
12 maxWorkers: 1, // run in band to prevent DB conflicts
13};
14
15export default config;
16
17// src/test/global-setup.ts
18export default async function globalSetup() {
19 process.env['TEST_DATABASE_URL'] = 'postgresql://test:test@localhost:5433/testdb';
20 await runMigrations(process.env['TEST_DATABASE_URL']);
21}
22
23// src/test/setup.ts
24import { db } from '../database';
25
26beforeEach(async () => {
27 await db.execute('TRUNCATE users, orders, products CASCADE');
28});
29
30afterAll(async () => {
31 await db.end(); // close connection pool
32});Isolation strategies (fastest to most isolated):
Setting up correctly from the start prevents hours of debugging:
1npm install --save-dev supertest @types/supertest
2npm install --save-dev better-sqlite3 @types/better-sqlite3
3# OR for full Postgres:
4npm install --save-dev @testcontainers/postgresql1{
2 "scripts": {
3 "test:unit": "jest --config jest.config.ts",
4 "test:integration": "jest --config jest.integration.config.ts --runInBand",
5 "test:all": "npm run test:unit && npm run test:integration"
6 }
7}1src/
2├── modules/
3│ └── users/
4│ ├── user.service.ts
5│ ├── user.service.test.ts ← unit test
6│ └── user.service.integration.test.ts ← integration test
7├── test/
8│ ├── global-setup.ts
9│ ├── global-teardown.ts
10│ ├── setup.ts
11│ └── helpers/
12│ ├── create-app.ts ← builds app for testing
13│ ├── seed.ts ← test data factories
14│ └── test-db.ts ← database connection1// src/test/helpers/create-app.ts
2import express from 'express';
3import { userRouter } from '../../modules/users/user.router';
4
5export function createTestApp() {
6 const app = express();
7 app.use(express.json());
8 app.use('/users', userRouter);
9 return app;
10}A practical decision framework for every new test you write:
1Does this test require real I/O or real module wiring?
2
3YES → Integration test:
4 - Calls real database
5 - Makes real HTTP requests (via supertest)
6 - Reads real files
7 - Exercises real middleware chain
8 - Verifies two real modules work together
9
10NO → Unit test:
11 - Pure function logic
12 - Algorithm correctness
13 - Input validation rules
14 - Error message formatting
15 - Conditional business rules
16 - React component rendering1// Pure logic → UNIT TEST
2function calculateShipping(weightKg: number, distanceKm: number): number {
3 return weightKg * 0.5 + distanceKm * 0.1;
4}
5// No I/O → unit test, runs in < 1ms
6
7// Database call → INTEGRATION TEST
8async function findOrdersByUserId(userId: string): Promise<Order[]> {
9 return db.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
10}
11// Real SQL → integration test with test DB
12
13// HTTP endpoint → INTEGRATION TEST
14// GET /orders — router → middleware → controller → service → DB
15// Every layer is real → integration test with supertest
16
17// Module wiring → INTEGRATION TEST when both real deps are wired together
18const service = new OrderService(
19 new RealOrderRepository(db),
20 new RealEmailService(smtpConfig)
21);
22// Use when verifying two real modules communicate correctlyA complete integration test for a user registration endpoint using Supertest and an in-memory SQLite database with full cleanup between tests.
1// src/test/helpers/test-db.ts
2import Database from 'better-sqlite3';
3
4let db: Database.Database;
5
6export function getTestDb(): Database.Database {
7 if (!db) {
8 db = new Database(':memory:');
9 db.exec(`
10 CREATE TABLE IF NOT EXISTS users (
11 id TEXT PRIMARY KEY,
12 email TEXT UNIQUE NOT NULL,
13 password_hash TEXT NOT NULL,
14 created_at TEXT NOT NULL
15 );
16 `);
17 }
18 return db;
19}
20
21export function resetTestDb(): void {
22 getTestDb().exec('DELETE FROM users');
23}
24
25// src/modules/users/user.repository.ts
26import type { Database } from 'better-sqlite3';
27import { randomUUID } from 'crypto';
28
29export interface NewUser { email: string; passwordHash: string; }
30export interface User extends NewUser { id: string; createdAt: string; }
31
32export class UserRepository {
33 constructor(private db: Database) {}
34
35 insert(user: NewUser): User {
36 const id = randomUUID();
37 const createdAt = new Date().toISOString();
38 this.db.prepare(
39 'INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)'
40 ).run(id, user.email, user.passwordHash, createdAt);
41 return { id, ...user, createdAt };
42 }
43
44 findByEmail(email: string): User | undefined {
45 return this.db.prepare(
46 'SELECT * FROM users WHERE email = ?'
47 ).get(email) as User | undefined;
48 }
49}
50
51// src/test/helpers/create-app.ts
52import express from 'express';
53import { hashSync } from 'bcryptjs';
54import { UserRepository } from '../../modules/users/user.repository';
55import { getTestDb } from './test-db';
56
57export function createTestApp() {
58 const app = express();
59 app.use(express.json());
60 const repo = new UserRepository(getTestDb());
61
62 app.post('/users', (req, res) => {
63 const { email, password } = req.body as { email?: string; password?: string };
64 if (!email || !password) {
65 return res.status(400).json({ error: 'email and password are required' });
66 }
67 if (repo.findByEmail(email)) {
68 return res.status(409).json({ error: 'Email already registered' });
69 }
70 const user = repo.insert({ email, passwordHash: hashSync(password, 10) });
71 return res.status(201).json({ id: user.id, email: user.email });
72 });
73
74 return app;
75}
76
77// src/modules/users/user.integration.test.ts
78import request from 'supertest';
79import { createTestApp } from '../../test/helpers/create-app';
80import { resetTestDb, getTestDb } from '../../test/helpers/test-db';
81import { UserRepository } from './user.repository';
82
83const app = createTestApp();
84
85beforeEach(() => resetTestDb());
86
87describe('POST /users', () => {
88 it('returns 201 with id and email on successful registration', async () => {
89 const res = await request(app)
90 .post('/users')
91 .send({ email: '[email protected]', password: 'SecurePass1!' });
92
93 expect(res.status).toBe(201);
94 expect(res.body.id).toBeDefined();
95 expect(res.body.email).toBe('[email protected]');
96 });
97
98 it('persists the user to the database', async () => {
99 await request(app)
100 .post('/users')
101 .send({ email: '[email protected]', password: 'SecurePass1!' });
102
103 const repo = new UserRepository(getTestDb());
104 const user = repo.findByEmail('[email protected]');
105 expect(user).toBeDefined();
106 expect(user!.email).toBe('[email protected]');
107 });
108
109 it('returns 409 when email is already registered', async () => {
110 await request(app).post('/users').send({ email: '[email protected]', password: 'Pass1!' });
111 const res = await request(app).post('/users').send({ email: '[email protected]', password: 'Pass1!' });
112
113 expect(res.status).toBe(409);
114 expect(res.body.error).toContain('already registered');
115 });
116
117 it('returns 400 when email is missing', async () => {
118 const res = await request(app).post('/users').send({ password: 'Pass1!' });
119 expect(res.status).toBe(400);
120 expect(res.body.error).toContain('email');
121 });
122
123 it('never exposes the password hash in the response', async () => {
124 const res = await request(app)
125 .post('/users')
126 .send({ email: '[email protected]', password: 'Pass1!' });
127 expect(res.body).not.toHaveProperty('passwordHash');
128 expect(res.body).not.toHaveProperty('password_hash');
129 });
130});This integration test exercises the full stack: HTTP request parsing, route handler, business logic, and a real SQLite in-memory database. resetTestDb() in beforeEach gives each test a clean slate. One test queries the database directly to verify persistence — something a unit test with a mocked repository cannot do. The 'never exposes the password hash' test catches a security regression that pure logic tests would miss because it depends on the serialisation behaviour of the HTTP response.
The next lesson covers testing Express REST APIs with Supertest in depth: chaining assertions on status, headers, and body, testing authentication middleware, and handling response pagination.
Your progress is saved in this browser, on this device only.