Integration Tests Against Real Services in Docker
26 Mar 2026Mocking your database in tests means you’re testing your assumptions about SQL, not your actual SQL. Spin up a real Postgres for the duration of the test run instead. testcontainers makes this a few lines, not a CI pipeline rewrite.
The setup
npm install -D testcontainers
import { PostgreSqlContainer } from '@testcontainers/postgresql'
let container
let pool
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine').start()
pool = new Pool({ connectionString: container.getConnectionUri() })
await pool.query(fs.readFileSync('schema.sql', 'utf8'))
}, 60_000)
afterAll(async () => {
await pool.end()
await container.stop()
})
testcontainers pulls the image, starts the container on a random free port, and hands you a real connection string. Your test hits an actual Postgres instance running your actual schema — no mock, no “the mock returned the wrong shape and we didn’t notice for three months.”
Redis, same pattern
import { RedisContainer } from '@testcontainers/redis'
const redis = await new RedisContainer('redis:7-alpine').start()
const client = createClient({ url: redis.getConnectionUrl() })
Any service with an official Docker image — Postgres, MySQL, Redis, RabbitMQ, even Kafka — has a testcontainers module or works with the generic GenericContainer API if a specific module doesn’t exist yet.
Isolate between tests
A shared container across your whole suite is fast but leaks state between tests. Two options, depending on how much isolation you need:
Transaction rollback — fastest, works for most cases. Wrap each test in a transaction, roll back after:
beforeEach(async () => { await pool.query('BEGIN') })
afterEach(async () => { await pool.query('ROLLBACK') })
Fresh container per test file — slower, but total isolation, useful if tests run migrations or need a clean schema:
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine').start()
})
Start with transaction rollback. Only pay for a fresh container per file if you hit actual cross-test pollution that rollback doesn’t solve — usually schema-level changes, not row-level ones.
CI setup
testcontainers needs Docker available on the CI runner. GitHub Actions’ standard runners have Docker preinstalled, so this usually works with zero extra config:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm test # testcontainers pulls and starts Postgres itself
No services: block needed — testcontainers manages the container lifecycle itself, rather than relying on a sidecar service defined in the CI YAML. That also means the same test file runs identically on your laptop and in CI, which a services: block doesn’t guarantee.
The tradeoff you’re accepting
Container startup adds a few seconds per test run — real, but a one-time cost per suite run, not per test, if you scope the container to beforeAll. Weigh that against what a mocked database costs you: every mock is a claim about behavior you didn’t verify, and the exact place bugs hide is where reality diverges from the mock’s assumptions — a column rename, a constraint you forgot, a query that’s syntactically valid but returns the wrong rows.
For unit tests of pure logic, keep using in-memory fakes — they’re fine there, since there’s no real I/O to get wrong. For anything that touches your actual schema or a real service’s actual behavior, a container beats a mock every time you’d otherwise be testing your own assumptions instead of your code.