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.

Senior engineer insight

The real value of Testcontainers isn’t just isolation — it’s that it forces your migrations to be runnable from scratch. On one CoverNZ claims platform project, Testcontainers revealed that migration 47 had a silent dependency on production seed data that had never been in source control. Every developer’s local setup had accumulated that seed data organically, so nobody noticed until a new joiner’s environment failed. Testcontainers found it because it started from zero every time, exactly as a new server or disaster-recovery environment would.

Once you switch to Testcontainers for integration tests, start tracking container startup time in CI. Median startup under 8 seconds means your Docker image and host are healthy; anything over 20 seconds is a sign that image layers are bloated or the runner is under-resourced — both of which compound into slow pipelines at scale.

The most common mistake: teams start a new container per test file instead of per suite, then blame Testcontainers for being slow when the real problem is a missing globalSetup.

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 Revenue NZ number format', async () => { await expect( repo.processContribution('invalid', 500) ).rejects.toThrow('Invalid Revenue NZ'); }); });

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.

From the field

A Wellington-based fintech building open banking integrations for NZ banks assumed their PostgreSQL repository layer was solid — unit tests mocked the database, coverage was at 85%, and the CI pipeline was green every day. When they onboarded a new bank with a slightly different date format for transaction timestamps, five repository methods started returning wrong results in production. The mocks had been written to match what the developers expected the database to return, not what it actually returned under different locale settings.

They introduced Testcontainers and ran their migrations against a fresh container with the TimeZone = 'Pacific/Auckland' PostgreSQL setting matched to production. Within two hours they found three additional date-handling bugs that the mocks had been hiding for months. The lesson that generalised: mocks validate your assumptions; Testcontainers validates whether those assumptions are correct. If you’re not testing against the real engine with production-equivalent configuration, your green test suite is a confidence illusion.

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.

Why teams fail here

  • Container-per-file startup: Teams skip globalSetup and start a fresh container for every test file. A suite of 20 test files pays the 10-second startup cost 20 times. The suite takes four minutes when it should take 45 seconds, and the team blames Testcontainers rather than their configuration.
  • Using a generic Postgres version instead of matching production: Spinning up postgres:latest when production runs PostgreSQL 13 on Amazon RDS means your tests may pass against behaviour that changed in a later version — particularly around JSON operators, index behaviour, or collation. Pin your image version to match production exactly.
  • Forgetting to truncate between tests: Running migrations in beforeAll and then letting test data accumulate across the suite. Test 5 fails because Test 3 inserted a row with a unique constraint that Test 5 also tries to insert. The fix is a TRUNCATE ... RESTART IDENTITY CASCADE in beforeEach — it runs in under 10 milliseconds against a local container.
  • No Docker socket in CI without checking runner capabilities: GitHub Actions works out of the box, but self-hosted runners on some NZ government agency CI systems (running on locked-down Windows Server hosts) may not have Docker available or may block unprivileged container execution. Teams discover this at sprint demo when CI was “always run on the developer’s machine” and the pipeline has never actually been tested.

Key takeaway

A test suite that passes against mocks proves your code is internally consistent — Testcontainers is the tool that proves it’s actually correct.

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.