iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Integration Tests

Integration tests verify the seams between your real components: API + DB, service A + service B, your code + the message broker. They’re slower and flakier than unit tests but catch bugs unit tests can’t see. CI is where you scale them.

Compose, containers, retries, fixtures

EXAMPLE
# 1) The integration-test pyramid (your share will vary)
#   Unit tests           — fast, isolated, in-process            (many)
#   Integration tests    — real DB, real cache, real broker      (some)
#   E2E / system tests   — full stack with browser/native        (few)
# Integration tests sit in the middle: real services, but scoped to one bounded context.

# 2) docker-compose for test dependencies (Node + Postgres + Redis example)
# docker-compose.test.yml
services:
    postgres:
        image: postgres:16-alpine
        environment:
            POSTGRES_PASSWORD: test
            POSTGRES_DB: app_test
        ports: ['5432:5432']
        healthcheck:
            test: ['CMD', 'pg_isready', '-U', 'postgres']
            interval: 2s
            timeout: 2s
            retries: 30
    redis:
        image: redis:7-alpine
        ports: ['6379:6379']
        healthcheck:
            test: ['CMD', 'redis-cli', 'ping']
            interval: 2s
            retries: 30
    mailhog:                                # fake SMTP for testing emails
        image: mailhog/mailhog:v1.0.1
        ports: ['1025:1025', '8025:8025']

# Launch in CI/local
docker compose -f docker-compose.test.yml up -d --wait        # --wait gates on healthchecks
npm run test:integration
docker compose -f docker-compose.test.yml down -v

# 3) Testcontainers — programmatic, language-native (preferred for many teams)
# Node example
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { GenericContainer } from 'testcontainers';

let pg, redis;

beforeAll(async () => {
    pg = await new PostgreSqlContainer('postgres:16-alpine')
        .withDatabase('app_test').withUsername('app').withPassword('app')
        .start();
    redis = await new GenericContainer('redis:7-alpine').withExposedPorts(6379).start();

    process.env.DATABASE_URL = pg.getConnectionUri();
    process.env.REDIS_URL    = `redis://${redis.getHost()}:${redis.getMappedPort(6379)}`;

    await runMigrations();
}, 60_000);

afterAll(async () => {
    await pg?.stop();
    await redis?.stop();
});

# Each test run gets a clean, ephemeral DB on a random port — no clashing locals.

# 4) Migrations + seed per test
import { execSync } from 'node:child_process';
import { db } from '../src/db.js';

async function reset() {
    await db.query(`TRUNCATE TABLE orders, users RESTART IDENTITY CASCADE`);
    await db.query(`INSERT INTO users (id, email) VALUES (1, 'a@b.com')`);
}

beforeEach(async () => { await reset(); });

# Truncate is faster than drop-and-recreate; CASCADE clears child rows safely.

# 5) Integration test that exercises HTTP + DB + Redis
import request from 'supertest';
import { app } from '../src/server.js';

test('POST /orders persists and increments daily counter', async () => {
    const res = await request(app)
        .post('/orders').send({ totalCents: 4999 })
        .set('Authorization', `Bearer ${tokenFor(1)}`);
    expect(res.status).toBe(201);

    const row = await db.query(`SELECT total_cents FROM orders WHERE id = $1`, [res.body.id]);
    expect(row.rows[0].total_cents).toBe(4999);

    const counter = await redis.get(`daily:orders:${today()}`);
    expect(Number(counter)).toBe(1);
});

# 6) GitHub Actions — service containers
# .github/workflows/test.yml
name: test
on: [push, pull_request]
jobs:
    test:
        runs-on: ubuntu-latest
        services:
            postgres:
                image: postgres:16-alpine
                env:
                    POSTGRES_PASSWORD: test
                    POSTGRES_DB: app_test
                ports: ['5432:5432']
                options: >-
                    --health-cmd "pg_isready -U postgres"
                    --health-interval 5s
                    --health-timeout 5s
                    --health-retries 10
            redis:
                image: redis:7-alpine
                ports: ['6379:6379']
                options: >-
                    --health-cmd "redis-cli ping"
                    --health-interval 5s
                    --health-retries 10
        env:
            DATABASE_URL: postgresql://postgres:test@localhost:5432/app_test
            REDIS_URL: redis://localhost:6379
        steps:
            - uses: actions/checkout@v4
            - uses: actions/setup-node@v4
              with: { node-version: '20', cache: 'npm' }
            - run: npm ci
            - run: npm run migrate:test
            - run: npm run test:integration -- --reporter=spec --bail
            - if: failure()
              uses: actions/upload-artifact@v4
              with: { name: logs, path: ./logs }

# 7) Parallelism
# • Each worker gets its own DB schema OR its own container (testcontainers + node:vitest workers)
# • Avoid SQLite — concurrent writers serialize and amplify flakiness
# • Time-based assertions: freeze the clock (sinon.useFakeTimers / vi.useFakeTimers)

# 8) Reliability — handle flakiness
# • Wait on healthchecks, not arbitrary sleeps
# • Random ports → no clashes across runs
# • Tear down with care: afterAll, no zombie containers
# • Retries: 1–2 retries on integration jobs is sane; >2 hides real bugs
# • Pin image tags by digest in CI

# 9) Mocking external services (third-party APIs)
# • WireMock / Mockoon container for HTTP stubbing
# • LocalStack for AWS
# • MailHog for SMTP
# • Kafka via redpanda (lightweight, Kafka-compatible)
# • Open-source emulators (firebase, pubsub) for cloud SDKs
# Always assert against the recorded interaction, not just the response shape.

# 10) Performance
# • Reuse the same compose stack across the whole job (lifecycle: 'job', not 'test')
# • Skip migrations between tests when truncate is enough
# • Group expensive setups in a fixture; rebind per test
# • Use --shard to split your integration suite across multiple runners

# 11) Data isolation patterns
# A) Transaction rollback (Postgres BEGIN/ROLLBACK around each test)
#    Fast, perfect isolation, but breaks tests that span connections.
# B) TRUNCATE + RESTART IDENTITY before each test (above)
#    Slower, works with any code path.
# C) Per-test schema (CREATE SCHEMA t_${pid}; SET search_path)
#    Highest isolation, more setup; good for parallel workers.

# 12) Common bugs
# • Tests pass locally, fail in CI — almost always Docker networking or env vars
# • localhost vs container DNS — inside compose, the hostname is the service name
# • Migrations forgotten in CI — gate the run with 'npm run migrate:test'
# • Test pollution — one test leaks data; flakiness for 'unrelated' tests downstream
# • Sleep-based waits instead of health checks → race conditions and slow CI
# • Massive fixtures imported into every test → faster to seed minimally per test

Why it matters

Integration tests are where bugs that mock-based unit tests can’t find live — SQL gone wrong, transactions that don’t commit, message envelopes that don’t round-trip. Run them against real containers (Testcontainers or service containers), gate CI on health checks instead of sleeps, and TRUNCATE between tests rather than tearing the whole DB down.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
# Spin up real deps (DB, cache) with services.
services:
    postgres:
        image: postgres:16
        env: { POSTGRES_PASSWORD: postgres }
        ports: ['5432:5432']
steps:
    - run: npm run test:integration
Try it Yourself »

Discussion

Loading…