IAnswerable

Integration Tests Against Real Services in Docker

26 Mar 2026

Mocking 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.