Keep integration test suites fast, reliable, and maintainable by applying proven isolation strategies, detecting and eliminating flaky tests, and establishing team conventions for sustainable growth.
Integration tests are inherently slower and more complex than unit tests. As a suite grows from 20 to 200 integration tests, the problems that were manageable become blocking: tests that take 5 minutes total become tests that take 30 minutes. Flaky tests that fail once a week become tests that fail on every third CI run. Shared state bugs that appear occasionally become bugs that appear on every merge.
This lesson covers the practices that keep integration suites trustworthy and fast at scale: the right isolation strategies for different scenarios, systematic flaky test elimination, parallelisation patterns, and team conventions that prevent the suite from degrading over time.
Three isolation strategies exist, each with different speed and complexity tradeoffs:
Strategy 1 — TRUNCATE in beforeEach (recommended default)
1beforeEach(async () => {
2 // Fast: < 10ms for small schemas
3 await prisma.$executeRaw`TRUNCATE "User", "Course", "Enrollment" CASCADE`;
4});
5// Pros: simple, reliable, works with all async patterns
6// Cons: slightly slower than rollback for schemas with 20+ tablesStrategy 2 — Transaction Rollback (fastest, more complex)
1let transactionClient: PrismaClient;
2
3beforeEach(async () => {
4 // Start a transaction that wraps the test
5 transactionClient = await prisma.$begin();
6});
7
8afterEach(async () => {
9 await transactionClient.$rollback();
10});
11// Pros: < 1ms overhead, no table locks
12// Cons: test code must use transactionClient, not the global prisma instance
13// Does NOT work if the code under test manages its own transactionsStrategy 3 — Per-Test Database (most isolated, slowest)
1// Each test file gets its own database name
2const dbName = `test_${process.env['JEST_WORKER_ID']}_${Date.now()}`;
3// Pros: complete isolation, can parallelise freely
4// Cons: slow setup (migration per DB), high resource usage
5// Use only when tests genuinely cannot share a databaseDecision guide:
Flaky tests are the single biggest trust-eroder in a test suite. Developers start ignoring red CI runs when they know tests fail randomly.
1# Run tests in random order to detect order-dependency
2npx jest --randomize --runInBand
3
4# Run a single test file 20 times to detect timing-dependent failures
5npx jest src/users/users.integration.test.ts --count=20
6
7# Run with verbose timing to identify suspiciously slow tests
8npx jest --verbose --detectOpenHandlesCommon flakiness causes and fixes:
1// CAUSE 1: Shared database state between tests
2// Symptom: test passes alone, fails in suite
3// Fix: ensure beforeEach TRUNCATE runs before ALL tests, not just some
4
5// CAUSE 2: Missing await on async database operations
6beforeEach(async () => {
7 prisma.user.deleteMany(); // MISSING AWAIT — test starts before cleanup
8});
9// Fix:
10beforeEach(async () => {
11 await prisma.user.deleteMany(); // correct
12});
13
14// CAUSE 3: Time-dependent assertions
15test('returns recent orders', async () => {
16 const order = await createOrder(prisma, { createdAt: new Date() });
17 const res = await request(app).get('/orders/recent');
18 // 'recent' = last 24 hours — passes today, may fail tomorrow
19 expect(res.body.map((o: any) => o.id)).toContain(order.id);
20});
21// Fix: seed with explicit, test-controlled timestamps
22test('returns orders from the last 24 hours', async () => {
23 const recent = new Date(Date.now() - 3600_000); // 1 hour ago
24 const old = new Date(Date.now() - 90_000_000); // > 24 hours ago
25 const recentOrder = await createOrder(prisma, { createdAt: recent });
26 const oldOrder = await createOrder(prisma, { createdAt: old });
27 const res = await request(app).get('/orders/recent');
28 const ids = res.body.map((o: any) => o.id);
29 expect(ids).toContain(recentOrder.id);
30 expect(ids).not.toContain(oldOrder.id);
31});
32
33// CAUSE 4: Port conflicts in parallel tests
34// Fix: never call app.listen() in integration tests
35// Supertest manages the server lifecycle without binding to a port
36// app.getHttpServer() (NestJS) or passing app to request() (Express) both avoid thisJest runs test files in parallel by default. Integration tests that share a database need coordination:
1// jest.integration.config.ts
2const config: Config = {
3 // Option 1: single process (safest, slowest)
4 maxWorkers: 1,
5
6 // Option 2: parallel with worker-scoped databases
7 maxWorkers: '50%', // use half the CPU cores
8 globalSetup: './src/test/global-setup.ts', // creates one DB per worker
9};
10
11// global-setup.ts for parallel databases
12export default async function globalSetup() {
13 const workerCount = parseInt(process.env['JEST_WORKER_COUNT'] ?? '1');
14 for (let i = 1; i <= workerCount; i++) {
15 const dbUrl = `postgresql://test:test@localhost:5433/testdb_worker_${i}`;
16 await createDatabase(dbUrl);
17 await runMigrations(dbUrl);
18 // Store in temp file so workers can read their DB URL
19 writeFileSync(`.test-db-worker-${i}.url`, dbUrl);
20 }
21}
22
23// In each test file: use worker-specific database
24import { readFileSync } from 'fs';
25const workerId = process.env['JEST_WORKER_ID'] ?? '1';
26const dbUrl = readFileSync(`.test-db-worker-${workerId}.url`, 'utf8');
27const prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } });
28
29// Option 3: use pg-mem (in-memory Postgres) for read-heavy tests
30import { newDb } from 'pg-mem';
31const db = newDb();
32const { Client } = db.adapters.createPg();
33// Runs entirely in memory — no Docker, no ports, complete parallel safetyIntegration tests are expensive. Use them strategically — they verify module contracts, not every code branch.
1// CORRECT: integration test scope
2// ✅ Does the endpoint return the right status code?
3// ✅ Does the database record get created/updated/deleted?
4// ✅ Does authentication middleware block the right requests?
5// ✅ Do validation errors return the right shape?
6// ✅ Do two modules exchange the right data types?
7// ✅ Are foreign key relationships correctly enforced?
8
9// WRONG: using integration tests for unit-test concerns
10// ❌ Testing every branch of the discount calculation logic via HTTP
11// (unit test the calculator directly — 100x faster)
12// ❌ Testing every validation error message via HTTP
13// (unit test the DTO/validator directly)
14// ❌ Testing internal service method call counts
15// (spy on the real service in a unit test)
16
17// EXAMPLE: the right split
18// Unit test: every discount rule (percentage, fixed, free-shipping, expiry, limit)
19// Integration test: POST /checkout applies a coupon and returns the discounted total
20// — one happy-path test and one invalid-coupon test is sufficient
21
22// ANTI-PATTERN: exhaustive integration tests
23describe('POST /checkout (exhaustive — DON\'T DO THIS)', () => {
24 it('applies 10% discount for SAVE10 coupon', ...);
25 it('applies 20% discount for SAVE20 coupon', ...);
26 it('applies 50% discount for HALFOFF coupon', ...);
27 it('applies free shipping for FREESHIP coupon', ...);
28 it('rejects expired coupon', ...);
29 it('rejects coupon at usage limit', ...);
30 // ... 20 more tests — all slow integration tests for pure logic
31 // These should ALL be unit tests on the discount calculator
32});
33
34// CORRECT: minimal integration surface
35describe('POST /checkout (integration — DO THIS)', () => {
36 it('returns 200 with discounted total when valid coupon is applied', ...);
37 it('returns 422 with error message when coupon is invalid', ...);
38 // Two tests — the discount rules are covered by unit tests
39});Integration tests need different CI settings than unit tests: longer timeouts, sequential execution, and a real or in-memory database.
1# .github/workflows/integration-tests.yml
2name: Integration Tests
3
4on:
5 pull_request:
6 branches: [main, develop]
7
8jobs:
9 integration:
10 runs-on: ubuntu-latest
11 timeout-minutes: 15 # fail the job if tests hang
12
13 services:
14 postgres:
15 image: postgres:16-alpine
16 env:
17 POSTGRES_USER: test
18 POSTGRES_PASSWORD: test
19 POSTGRES_DB: testdb
20 ports:
21 - 5433:5432
22 options: >-
23 --health-cmd pg_isready
24 --health-interval 5s
25 --health-timeout 5s
26 --health-retries 5
27
28 steps:
29 - uses: actions/checkout@v4
30
31 - uses: actions/setup-node@v4
32 with:
33 node-version: '20'
34 cache: 'npm'
35
36 - run: npm ci
37
38 - name: Run database migrations
39 run: npx prisma migrate deploy
40 env:
41 DATABASE_URL: postgresql://test:test@localhost:5433/testdb
42
43 - name: Run integration tests
44 run: npm run test:integration
45 env:
46 TEST_DATABASE_URL: postgresql://test:test@localhost:5433/testdb
47 JWT_SECRET: ci-test-secret
48 NODE_ENV: test
49 # Set a shorter timeout per test for CI
50 JEST_TIMEOUT: 15000
51
52 - uses: actions/upload-artifact@v4
53 if: failure()
54 with:
55 name: integration-test-results
56 path: test-results/A before/after transformation of a flaky integration test suite: missing awaits, shared state, time-dependent assertions, and port conflicts all identified and fixed.
1// BEFORE: flaky integration test suite with multiple issues
2
3// Issue 1: missing await in beforeEach — cleanup may not complete before test starts
4beforeEach(() => { // <- missing async
5 prisma.user.deleteMany(); // <- missing await — runs in background!
6 prisma.session.deleteMany();
7});
8
9// Issue 2: shared app instance with real port — conflicts when parallel workers run
10let server: http.Server;
11beforeAll(() => {
12 server = app.listen(3001); // <- hardcoded port! conflicts with parallel tests
13});
14afterAll(() => server.close());
15
16// Issue 3: time-dependent assertion
17test('recent sessions are returned', async () => {
18 await prisma.session.create({ data: { userId: 'u1', createdAt: new Date() } });
19 const res = await request(server).get('/sessions/recent');
20 // This test will fail the day after a session seeded with Date.now() ages out
21 expect(res.body.length).toBeGreaterThan(0);
22});
23
24// Issue 4: test depends on another test's side effect
25test('admin can see all users', async () => {
26 // Assumes the 'creates a user' test ran first and left data
27 const res = await request(server).get('/admin/users').set('Authorization', `Bearer ${adminToken}`);
28 expect(res.body.length).toBeGreaterThan(0); // FLAKY: passes only if previous test ran
29});
30
31// ========================================================
32// AFTER: fixed suite
33
34beforeEach(async () => { // ✅ async function
35 await prisma.$executeRaw`TRUNCATE "User", "Session" CASCADE`; // ✅ awaited, atomic
36});
37
38// ✅ No app.listen() — Supertest manages server lifecycle
39const testApp = createTestApp(); // returns express app, no listen()
40
41test('recent sessions are returned', async () => {
42 // ✅ Seed with explicit controlled timestamp
43 const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
44 await prisma.user.create({ data: { id: 'u1', email: '[email protected]', name: 'A', passwordHash: 'h', createdAt: new Date() } });
45 await prisma.session.create({
46 data: { userId: 'u1', createdAt: thirtyMinutesAgo, expiresAt: new Date(Date.now() + 3600_000) },
47 });
48
49 const res = await request(testApp).get('/sessions/recent').set('Authorization', `Bearer ${adminToken}`);
50 expect(res.status).toBe(200);
51 expect(res.body).toHaveLength(1); // ✅ exact count, not >
52});
53
54test('admin can see all users', async () => {
55 // ✅ Seeds its own data — no dependency on other tests
56 await prisma.user.createMany({
57 data: [
58 { id: 'u1', email: '[email protected]', name: 'A', passwordHash: 'h', createdAt: new Date() },
59 { id: 'u2', email: '[email protected]', name: 'B', passwordHash: 'h', createdAt: new Date() },
60 ],
61 });
62
63 const res = await request(testApp)
64 .get('/admin/users')
65 .set('Authorization', `Bearer ${adminToken}`);
66 expect(res.status).toBe(200);
67 expect(res.body).toHaveLength(2); // ✅ exact count based on own seed
68});Each fix targets a specific flakiness cause: async beforeEach with awaited TRUNCATE eliminates cleanup races; removing app.listen() eliminates port conflicts; explicit timestamps make assertions independent of wall clock time; self-contained test seeds eliminate test ordering dependencies. Run the fixed suite with --randomize 10 times — it should produce identical results every time.
The final integration testing lesson covers running integration tests in CI/CD pipelines with GitHub Actions, database service containers, migration steps, and integration with coverage reporting.
Your progress is saved in this browser, on this device only.