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?”
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
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:
Other services TestContainers supports out of the box:
- Redis —
@testcontainers/redisfor caching logic tests - RabbitMQ —
@testcontainers/rabbitmqfor 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:
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.
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
globalSetupand 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:latestwhen 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
beforeAlland 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 aTRUNCATE ... RESTART IDENTITY CASCADEinbeforeEach— 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.
10 Next Steps
TestContainers sits in the integration testing tier. Go deeper on what surrounds it: