Should I test that?

Are Testcontainers worth it?

Verdict

Yes

Yes, Testcontainers is worth it for tests of code that sends SQL to a database: run each query test against the production database engine in a container, and start one container for the whole test run instead of one per test.

Why

Yes, Testcontainers is worth it for tests of code that sends SQL to a database; rule R11 gives Test. The typical case is a backend on PostgreSQL or MySQL whose database tests run against H2 or a mocked repository. Blast radius is users and Change frequency is regularly, because customers see the rows queries return and most features change a query. Detectability is eventually, because SQL the test engine runs differently returns plausible rows without an error, and Reversibility is with-effort, because a script repairs the affected rows. Test cost is moderate: a container test takes about an hour to write.

When the decision changes
WhenDecisionWhy
Every query filters customer records by tenant IDTest mandatory: a container test with rows of two tenants for each queryBlast radius rises to safety-or-legal and Detectability to never, because a missing filter shows another customer's records without an error
Production runs Snowflake, which has no official container image, and a customer-facing report query can drop rows without an errorTest it differently: after each deploy, compare the report's totals with a control query on the raw tables and alert when they differTest cost rises to heavy, because only a paid Snowflake account runs the SQL, while Detectability stays eventually and Reversibility with-effort
The code only loads records by ID, and a broken mapping shows an error page that users reportTest minimally: one test that starts the app against a container and loads one recordDetectability moves to same-day and Reversibility to trivial, because a lookup by ID stores nothing
The SQL is a one-time data migration that rewrites existing rowsTest it differently: rehearse the migration on a restored copy of production data and compare row counts before and afterChange frequency falls to once, and a container with an empty schema shows only that the SQL runs
The app is a side project that only you run, against a database on your laptopDo not add Testcontainers; open the pages that read the database after each changeBlast radius falls to none, because you bear every failure alone

What breaks if you don't test

Tests on H2 pass SQL that production runs differently, and skip SQL that H2 cannot run. H2 states that database engines behave a little differently, and its PostgreSQL mode lists ON CONFLICT DO NOTHING but not DO UPDATE, so an upsert with DO UPDATE ships untested or gets rewritten for H2. A filter that treats NULL or letter case differently on the two engines returns a shorter list that looks right, and a customer notices the missing record weeks later.

What you lose if you over-test

A container per test class restarts PostgreSQL for every class: at 3 seconds per start with the image cached, 40 classes add two minutes to each build. Pure logic moved into container tests runs slower and finds nothing new. Every developer machine and CI runner also needs a Docker engine.

How to test

  1. Start one container for the whole run, as in the singleton container pattern or Spring Boot's service connections.
  2. Pin the image to the version production runs.
  3. Run your real migrations against the container first, so a migration that fails on the production engine fails the build.
  4. Give each test its own rows, rolled back after the test.
  5. Write container tests only for code that sends SQL.

When the answer changes

  • The production database has no official container image, such as Snowflake.
  • A query decides which tenant's records come back.
  • The SQL runs once, as a data migration.

Cost estimate + Code example

What one shared container costs a team of five

Numbers I assumed for a service that moves 60 database tests from H2 to PostgreSQL in a container:

ItemAssumptionHours in year one
SetupOne developer-day to switch the connection and fix H2-specific tests, from my practice8
Image upkeepTwo production upgrades, 2 hours each4
Waiting15 seconds per CI run for image pull and start on a hosted Linux runner, my measurement; 10 watched runs a day, 230 days10
One engine-difference bug in productionCause 6 hours, fix 2, repair script 4, customer replies 416

The container costs 22 hours in year one and 14 a year after. The container pays off in year one if it stops two engine-difference bugs, and later if it stops one. On the last service where I replaced H2, the first PostgreSQL run failed three tests that had passed for months. In Vitest, the shared container is a global setup file:

// vitest.globalSetup.ts: one PostgreSQL for the whole run, not one per file.
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import type { TestProject } from "vitest/node";
import { migrate } from "./src/db/migrate";

export default async function setup(project: TestProject) {
  const pg = await new PostgreSqlContainer("postgres:16.4-alpine").start(); // production's version
  await migrate(pg.getConnectionUri()); // the real migrations, not a test schema
  project.provide("databaseUrl", pg.getConnectionUri()); // tests call inject("databaseUrl")
  return async () => {
    await pg.stop();
  };
}

declare module "vitest" {
  export interface ProvidedContext {
    databaseUrl: string;
  }
}

FAQ

Should I use Testcontainers for integration tests?

Yes, use Testcontainers for integration tests of code that talks to a service you run yourself, such as PostgreSQL or Kafka. Each test then runs your SQL on the production engine. Replace third-party APIs with a fake HTTP server instead.

Is Testcontainers better than H2 for tests?

Testcontainers catches more bugs than H2 for an application that runs PostgreSQL or MySQL in production, because the tests run on the production engine. H2's PostgreSQL mode does not accept every PostgreSQL statement.

Do Testcontainers slow down the test suite?

A suite that shares one PostgreSQL container pays the start once per run, about 15 seconds on a hosted CI runner with the image pull, in my measurements. A container per test class pays it once per class.

Can I run Testcontainers in CI?

Yes, Testcontainers runs on any CI runner with a Docker engine, such as GitHub-hosted Ubuntu runners. Its documentation covers GitLab CI, CircleCI, Bitbucket Pipelines and AWS CodeBuild.