Senior SDET · Learning

TestContainers

TestContainers spins up real databases, message queues, and third-party services in Docker containers — just for your tests — and tears them down when done. It bridges the gap between unit tests (too fast to be realistic) and full integration tests (too slow and unreliable). It’s the best solution to “but does it work with a real database?”

Senior SDET ISTQB CTAL-TAE ~25 min read · ~60 min with exercises

1 The Hook

A Christchurch health tech company has a patient records API. Unit tests mock the database — fast, isolated, passes in CI every time. E2E tests hit a shared test database. The problem: that shared database is three months behind on schema migrations, is reset inconsistently between runs, and frequently has stale data left by other tests.

Two testers regularly break each other’s test runs. The fix for each breakage is “just reset the database” — which then breaks whoever is running tests against it. The team has an unwritten rule: never run the full test suite on a Friday afternoon.

A senior SDET introduces TestContainers. Each test run gets its own fresh PostgreSQL container built from the actual migration files. The container starts, the schema migrates, the tests run, the container stops. Nobody can break another person’s run because nobody shares state. The shared test database issue disappears entirely.

2 The Rule

A unit test with a mocked database tests your code, not your database interaction. TestContainers lets you test both — with a real database engine, the actual schema, and real constraint behaviour — while keeping each test run fully isolated and reproducible.

3 The Analogy

Analogy

TestContainers is like a pop-up kitchen for each test.

Instead of sharing one communal kitchen that everyone leaves messy — wrong ingredients out, dishes not washed, last night’s meal still in the pan — each test gets its own clean kitchen that appears, is used, and is demolished when done.

No shared state. No cleanup arguments. No stale leftovers from whoever ran tests before you. The kitchen is always exactly as the recipe specifies, every time.

4 Watch Me Do It

A KiwiSaver repository integration test using @testcontainers/postgresql:

import { PostgreSqlContainer } from '@testcontainers/postgresql'; import { Pool } from 'pg'; import { KiwiSaverRepository } from './KiwiSaverRepository'; describe('KiwiSaverRepository integration tests', () => { let container: any; let pool: Pool; let repo: KiwiSaverRepository; beforeAll(async () => { // Start a fresh PostgreSQL 15 container container = await new PostgreSqlContainer('postgres:15') .withDatabase('kiwisaver_test') .withUsername('test') .withPassword('test') .start(); pool = new Pool({ host: container.getHost(), port: container.getPort(), database: container.getDatabase(), user: container.getUsername(), password: container.getPassword(), }); // Run your real migration files against the fresh database await runMigrations(pool); repo = new KiwiSaverRepository(pool); }, 60_000); // Allow 60s for container startup on first pull afterAll(async () => { await pool.end(); await container.stop(); // Container auto-removed — no cleanup needed }); beforeEach(async () => { // Truncate and reseed for each test — fast because it's local await pool.query('TRUNCATE TABLE members RESTART IDENTITY CASCADE'); await pool.query(` INSERT INTO members (ird_number, contribution_rate, balance) VALUES ('123-456-789', 3.0, 15000.00) `); }); it('calculates correct KiwiSaver balance after contribution', async () => { await repo.processContribution('123-456-789', 500.00); const member = await repo.findByIRD('123-456-789'); expect(member.balance).toBeCloseTo(15500.00, 2); }); it('rejects invalid IRD number format', async () => { await expect( repo.processContribution('invalid', 500) ).rejects.toThrow('Invalid IRD'); }); });

Other services TestContainers supports out of the box:

  • Redis@testcontainers/redis for caching logic tests
  • RabbitMQ@testcontainers/rabbitmq for message queue consumer tests
  • MySQL / MariaDB@testcontainers/mysql
  • LocalStack — fake AWS services (S3, SQS, Lambda) for infrastructure tests

Avoid per-file startup overhead by sharing the container across your entire Jest suite in globalSetup:

// jest.globalSetup.ts — container starts once per suite, not once per file export default async function globalSetup() { const container = await new PostgreSqlContainer('postgres:15').start(); process.env.TEST_DB_HOST = container.getHost(); process.env.TEST_DB_PORT = String(container.getPort()); // Store container reference for teardown (global as any).__TC__ = container; } // jest.globalTeardown.ts export default async function globalTeardown() { await (global as any).__TC__?.stop(); }
Pro tip: On first run, Docker must pull the image. This takes 30–60 seconds. Subsequent runs use the cached image and start in 3–5 seconds. Cache the image in CI using Docker layer caching so the first-pull cost only happens when you change the image version.

5 When to Use It

Use TestContainers when you need confidence that your code works with the real database engine, not just your mock’s assumptions.

Good fits:

  • Database repository classes — SQL queries, joins, constraint enforcement
  • Stored procedures and database functions
  • Migration integrity — verify that a migration runs cleanly against a fresh schema
  • Message queue consumers — test that your consumer handles messages correctly
  • Redis caching logic — TTL, cache invalidation, key patterns
  • Any third-party service that has a Docker image

Not needed for:

  • Pure business logic with no I/O — use unit tests with mocks
  • Full E2E UI tests — use the real test environment or a dedicated integration environment

6 Common Mistakes

🚫 Dismissing TestContainers as too slow

What I used to think: Container startup takes too long to be worth it.
Actually: Container startup is 5–15 seconds once, amortised across your entire integration test suite. If you have 50 database tests, that’s a fraction of a second per test. Compare this to the engineering cost of maintaining a shared test database that breaks pipelines weekly and requires manual resets. The maths is not close.

🚫 Thinking database mocks are good enough

What I used to think: Mocking the database in unit tests is sufficient.
Actually: Database mocks do not test SQL query correctness, index behaviour, unique constraint violations, foreign key enforcement, or migration integrity. A mock that returns { id: 1 } tells you your code calls the right method. TestContainers tells you your SQL actually works against the real engine.

🚫 Assuming Docker is not available in CI

What I used to think: I need to set up Docker on the CI machines to use TestContainers.
Actually: GitHub Actions ubuntu-latest runners have Docker installed by default. TestContainers works out of the box with zero additional CI configuration. GitLab CI requires services: [docker:dind] in your pipeline YAML, but that is a two-line change.

7 Now You Try

Write your answer, run it for AI feedback, then check the model answer.

🗄️ Exercise — Overdue Accounts TestContainers Test

A NZ rates payment system has a repository class that queries a PostgreSQL database. The findOverdueAccounts method returns accounts where payment is more than 30 days overdue. Write a TestContainers integration test covering: (1) an account with a payment exactly 30 days ago — boundary, not overdue; (2) an account 31 days ago — overdue; (3) an account paid today — not overdue.

Show model answer
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { RatesRepository } from './RatesRepository';

describe('RatesRepository.findOverdueAccounts', () => {
  let container: any;
  let pool: Pool;
  let repo: RatesRepository;

  beforeAll(async () => {
    container = await new PostgreSqlContainer('postgres:15')
      .withDatabase('rates_test').withUsername('test').withPassword('test')
      .start();
    pool = new Pool({ host: container.getHost(), port: container.getPort(),
      database: container.getDatabase(), user: container.getUsername(), password: container.getPassword() });
    await pool.query(`
      CREATE TABLE accounts (
        id SERIAL PRIMARY KEY,
        account_number TEXT NOT NULL,
        last_payment_date DATE NOT NULL
      )
    `);
    repo = new RatesRepository(pool);
  }, 60_000);

  afterAll(async () => { await pool.end(); await container.stop(); });

  beforeEach(async () => {
    await pool.query('TRUNCATE TABLE accounts RESTART IDENTITY');
    const today = new Date();
    const d30 = new Date(today); d30.setDate(today.getDate() - 30);
    const d31 = new Date(today); d31.setDate(today.getDate() - 31);

    await pool.query(`
      INSERT INTO accounts (account_number, last_payment_date) VALUES
        ('WLG-001', $1),   -- exactly 30 days ago — boundary, not overdue
        ('WLG-002', $2),   -- 31 days ago — overdue
        ('WLG-003', $3)    -- today — not overdue
    `, [d30.toISOString().split('T')[0],
        d31.toISOString().split('T')[0],
        today.toISOString().split('T')[0]]);
  });

  it('does not return account paid exactly 30 days ago', async () => {
    const overdue = await repo.findOverdueAccounts();
    expect(overdue.map(a => a.accountNumber)).not.toContain('WLG-001');
  });

  it('returns account with payment 31 days ago as overdue', async () => {
    const overdue = await repo.findOverdueAccounts();
    expect(overdue.map(a => a.accountNumber)).toContain('WLG-002');
  });

  it('does not return account paid today', async () => {
    const overdue = await repo.findOverdueAccounts();
    expect(overdue.map(a => a.accountNumber)).not.toContain('WLG-003');
  });
});

Key points: boundary values tested explicitly (30 vs 31 days); dates computed dynamically so tests do not break as time passes; TRUNCATE in beforeEach ensures test isolation; each test asserts only on its own account number so tests remain independent.

8 Self-Check

Click each question to reveal the answer.

Q1: What is the difference between using TestContainers and mocking a database in unit tests?

A database mock replaces the database entirely with a controlled object that returns whatever you tell it to return. It tests that your code calls the right methods with the right arguments. TestContainers uses a real PostgreSQL (or other) container with the real engine. It tests that your SQL is syntactically valid, returns the correct data, respects constraints and indexes, and works with your actual schema from migrations. Mocks test the code; TestContainers tests the code and its interaction with the real database.

Q2: How do you prevent container startup time from slowing down every test file in a Jest suite?

Use Jest’s globalSetup and globalTeardown hooks. Start the container once in globalSetup, store the connection details in process.env, and stop it in globalTeardown. Every test file then connects to the already-running container rather than starting its own. Container startup (5–15 seconds) happens once per suite, not once per test file.

Q3: TestContainers requires Docker. How does this work in a GitHub Actions CI pipeline?

GitHub Actions ubuntu-latest runners have Docker installed and running by default. No additional setup is required. Just install your TestContainers package (npm install @testcontainers/postgresql), write your tests, and run them. TestContainers detects the Docker socket automatically. The first run pulls the Docker image (30–60 seconds); subsequent runs use the cached image.

9 ISTQB Mapping

CTAL-TAE Section 5.2 — Test doubles: TestContainers as a real-service substitute providing genuine fidelity over mocks for database and infrastructure testing.

CTAL-TA v3.1.2 Section 3.2.2 — Integration testing: testing component interactions with real service dependencies, isolation strategies, and environment reproducibility.