Testing No. 141

Testcontainers skill for Claude Code

Real databases in tests, started once and reset fast. Claude replaces database mocks and H2 stand-ins with the production engine in a container, started once per run rather than per test. It runs the real migrations, picks rollback, truncation, template databases or snapshots for isolation, gives each parallel worker its own database, and sets up GitHub Actions or GitLab Docker-in-Docker so the suite passes cold, shuffled and in parallel.

  • nametestcontainers
  • size202 lines · ≈2,700 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes integration tests against real Postgres, MySQL, Redis, Kafka and other owned dependencies with Testcontainers for Java, Node.js, Python and Go - container lifecycle per suite vs per test, singleton containers, wait strategies, running real migrations, data isolation by transaction rollback, truncation, template databases or snapshots, per-worker databases for parallel runs, reusable containers locally, and CI setup with Docker-in-Docker and Ryuk. Use when replacing database mocks or H2/SQLite stand-ins, when integration tests are slow or flaky, or when setting up Testcontainers in GitHub Actions or GitLab CI.

Try asking

  • Replace the mocked repository tests with Testcontainers Postgres in our Vitest suite
  • Our Spring Boot integration tests start a new container per class and take 9 minutes; fix it
  • Get our Testcontainers tests running in GitLab CI with Docker-in-Docker

Use it when

  • Tests mock the database or use H2/SQLite while production runs Postgres or MySQL
  • Integration tests are slow because containers start too often
  • Tests leak data between each other or fail when run in parallel
  • Setting up Docker-based tests in GitHub Actions, GitLab CI or with Podman

Skip it when

  • You are deciding whether to fake a third-party API at all - use mocking-strategy instead
  • You are writing HTTP endpoint test cases rather than dependency setup - use api-testing instead

How to check the result

The suite passes twice in a row, in shuffled order and fully parallel, then passes from a cold cache in CI with no leftover containers on the runner.

The common mistake it prevents

Starting a fresh container per test, or sharing one database across parallel workers with truncation, so the suite is either very slow or randomly red.

~/.claude/skills/testcontainers/SKILL.md

--- name: testcontainers description: Writes integration tests against real Postgres, MySQL, Redis, Kafka and other owned dependencies with Testcontainers for Java, Node.js,… license: MIT ---

Done well means: tests run against the same database engine and major version as production, containers start once per test run (not per test), every test starts from a known state without depending on order, the suite runs in parallel, and CI needs nothing beyond a Docker daemon. A slow suite is almost always a lifecycle or isolation problem, not a Testcontainers problem.

Ground rules

  • Use Testcontainers for dependencies you own and run: databases, brokers, caches, search, object storage emulators. Stub third-party SaaS APIs at the HTTP layer instead (see mocking-strategy).
  • Pin image tags to production’s engine and major version (postgres:17-alpine, not postgres:latest). Bump them with the production upgrade.
  • Never hard-code host ports. Read the mapped port and host from the container.
  • Prefer the language’s module (PostgreSqlContainer, postgres.Run, PostgresContainer) over a generic container: modules ship correct wait strategies and connection helpers.
  • Schema comes from the project’s real migration tool, run once against the container, never from a hand-maintained schema.sql or ORM auto-create that production never uses.
  • Leave Ryuk (the reaper container) enabled unless the environment cannot run it.

Workflow

- [ ] 1. Inventory: test runner, current DB strategy (mocks, H2, SQLite, shared dev DB), CI runner
- [ ] 2. Choose lifecycle: one container per run (default) and where it starts
- [ ] 3. Start container with module + pinned image; wire connection into app config
- [ ] 4. Run migrations once; build a template or snapshot if using that isolation
- [ ] 5. Pick the isolation strategy per test
- [ ] 6. Make it parallel-safe (per-worker database or schema)
- [ ] 7. Local speed: reuse, tmpfs, durability off
- [ ] 8. CI: Docker availability, image caching, timeouts
- [ ] 9. Verify: run twice, run shuffled, run in parallel, run in CI

2. Lifecycle

OptionStart costUse when
One container per test run, shared by all files (default)OnceAlmost always
One container per test class/filePer fileFiles need different images or config
One container per testEvery testOnly when state cannot be reset (rare: testing the engine’s own startup, destructive admin ops)

Where “once per run” lives in each ecosystem:

StackMechanism
JUnit 5Singleton: static container started in a static {} block of an abstract base class; do not also annotate it with @Container (the extension would stop it after the first class)
Spring BootSame singleton or @Container @ServiceConnection static fields; Spring caches the context per configuration, so keep configuration identical across classes
Vitest / JestglobalSetup starts the container and exposes its URL (provide() in Vitest, env var in Jest)
pytestscope="session" fixture
GoTestMain in the package, or a sync.Once helper shared by packages

Per-language code: references/language-apis.md. Read it when writing the setup for a specific stack.

3. Wait strategies

Modules handle readiness. For generic containers, pick the strongest signal available:

SignalWhen
Docker HEALTHCHECK (wait for healthy)Image defines a real health check
HTTP endpoint returns 200Services with a health route
Log message, with occurrence countEngines that log readiness; Postgres prints “ready to accept connections” once during init and again when really ready, so count 2
Listening portLast resort; a listening port is not a ready service

Set an explicit startup timeout (60 to 120 s) so a stuck pull fails with a clear message instead of hanging the CI job.

4. Migrations

Run the migration tool against the container’s connection once per run (Flyway/Liquibase in Java, prisma migrate deploy, drizzle-kit migrate, Alembic upgrade head, Goose or golang-migrate). Then, depending on the isolation choice, create the template database or take a snapshot.

5. Data isolation

StrategySpeedBreaks whenChoose when
Transaction per test, rolled backFastestCode under test commits, uses several connections, or opens its own transaction; HTTP-level tests run the app in another threadRepository/DAO tests that share one connection
Truncate all tables before each testFast for small schemasHundreds of tables; parallel tests on the same databaseSimple default for service and API tests
Template database per test or per workerFast (file copy)Needs Postgres (CREATE DATABASE ... TEMPLATE) and no open connections to the templateParallel suites on Postgres
Module snapshot/restore (Node, Go Postgres modules)FastOpen client connections block it; never use the postgres system databaseSame as template, with less code
Unique data per test (random tenant IDs, emails)No reset costTests that count rows globallyAdd on top of any strategy above

Truncation in Postgres, keeping migration history:

DO $$ DECLARE r record; BEGIN
  FOR r IN SELECT tablename FROM pg_tables
           WHERE schemaname = 'public' AND tablename NOT IN ('flyway_schema_history','_prisma_migrations','alembic_version')
  LOOP EXECUTE format('TRUNCATE TABLE %I RESTART IDENTITY CASCADE', r.tablename); END LOOP;
END $$;

Template databases:

-- once, after migrations ran against database "app_template"
-- (disconnect every client from app_template first)
CREATE DATABASE test_w3 TEMPLATE app_template;   -- per worker or per test
DROP DATABASE test_w3 WITH (FORCE);              -- Postgres 13+, test containers only

Seed reference data (countries, plans) in the template or a migration; build per-test data with factories (see test-data-factories).

6. Parallelism

  • One container, many databases: each worker gets its own database (or schema) named from the worker ID: VITEST_POOL_ID (Vitest), JEST_WORKER_ID (Jest), PYTEST_XDIST_WORKER (pytest-xdist), a counter for Go t.Parallel() subtests.
  • Do not share one database across parallel workers with truncation; tests wipe each other’s data.
  • The JUnit 5 Testcontainers extension is documented as tested only with sequential execution; with JUnit parallel mode, use the singleton pattern and per-thread databases.
  • Size the container for the worker count (max_connections, memory).

7. Local speed

TechniqueEffectHow
Reusable containersSkips startup between runs on a dev machinetestcontainers.reuse.enable=true in ~/.testcontainers.properties or TESTCONTAINERS_REUSE_ENABLE=true, plus withReuse on the container; Go: WithReuseByName. Experimental; never in CI; data persists, so isolation must reset state
Durability off (Postgres)Faster writescommand postgres -c fsync=off -c synchronous_commit=off -c full_page_writes=off
tmpfs data dirFaster I/Omount tmpfs at /var/lib/postgresql/data (module/container tmpfs option)
Smaller imagesFaster pulls-alpine / slim variants that match prod’s major version
Parallel startLower wall clockStart independent containers concurrently (Startables.deepStart in Java, Promise.all in Node)

Reused containers survive across runs; when the image or config changes, a new one is created and the old one stays until removed (docker ps --filter label=org.testcontainers). Ask before removing containers the user did not create in this session.

8. CI

RunnerSetup
GitHub Actions ubuntu-latestDocker is preinstalled; nothing extra
GitHub Actions macOS/WindowsNo Linux Docker daemon by default; run integration tests on Linux runners
GitLab CIdocker:dind service with command: ["--tls=false"]; variables DOCKER_HOST: tcp://docker:2375, DOCKER_TLS_CERTDIR: "", DOCKER_DRIVER: overlay2
Tests run inside a container with the host socket mountedSet TESTCONTAINERS_HOST_OVERRIDE if the mapped ports are not reachable on localhost
Rootless PodmanDOCKER_HOST to the Podman socket; TESTCONTAINERS_RYUK_DISABLED=true if Ryuk cannot run, and then stop containers explicitly
No Docker allowedTestcontainers Cloud or a remote Docker host via DOCKER_HOST

Docker Hub rate limits: authenticate the runner (docker login) or route pulls through a mirror with hub.image.name.prefix / TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX. Remember the Ryuk image is pulled too.

Split jobs so unit tests do not wait for Docker: test:unit and test:integration.

Gotchas

  • localhost hard-coded in app config: breaks under Docker-in-Docker where the host is docker. Always use the container’s host accessor.
  • Port read before start() resolves, or cached in a static before the container started.
  • In-memory H2/SQLite “for speed” kept for some tests: SQL dialect bugs reach production through those tests.
  • A Spring context cached with one container’s URL while a different container is started for another class: later classes connect to a stopped container.
  • Transaction rollback isolation with an app that commits: data leaks between tests and failures depend on order.
  • DROP DATABASE while the pool still holds connections: close the pool first or use WITH (FORCE).
  • Apple Silicon: images without arm64 builds run under emulation, slowly or not at all; choose multi-arch tags.
  • Time zone and locale differ between container and CI host; set them explicitly in the container and the connection.

Verification

  1. Run the suite twice in a row: the second run passes without manual cleanup.
  2. Run in random order (--sequence.shuffle in Vitest, -p randomly with pytest-randomly, go test -shuffle=on, JUnit MethodOrderer.Random): passes.
  3. Run with full parallelism: passes, and the run time drops.
  4. Run in CI from a cold cache: passes, and docker ps -a on the runner shows no leftover containers after the job (Ryuk cleaned up).
  5. Break a migration on purpose: the integration suite fails at setup with a clear error. Revert it.

Report startup time and total suite time before and after when the change was about speed.

Output contract

## Integration test setup
- Images: postgres:17-alpine (prod 17.x), redis:7-alpine
- Lifecycle: one container per run via <globalSetup | singleton base class | session fixture | TestMain>
- Migrations: <tool + command>, run once
- Isolation: <transaction | truncation | template DB per worker | snapshot>
- Parallelism: <n> workers, database per worker named <pattern>
- Local reuse: <enabled via ~/.testcontainers.properties | off>
- CI: <runner + Docker setup>, cold run <n> s, warm run <n> s

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/language-apis.md 218 lines · 7.4 KB
# Testcontainers setup by language

Read the section for the stack in use. APIs checked against the official docs for
Testcontainers for Java 2.0.x, Node.js (`testcontainers` + `@testcontainers/*` modules),
Python 4.x and Go (`testcontainers-go`). Confirm against the installed version when an
option name does not compile.

## Java (JUnit 5)

Dependencies (2.x artifacts use the `testcontainers-` prefix; 1.x used `postgresql`,
`junit-jupiter`):

```kotlin
testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.5")
testImplementation("org.testcontainers:testcontainers-postgresql:2.0.5")
testRuntimeOnly("org.postgresql:postgresql")   // the module does not bring the driver
```

Singleton shared by every test class (2.x: `org.testcontainers.postgresql.PostgreSQLContainer`,
non-generic; 1.x: `org.testcontainers.containers.PostgreSQLContainer<?>`):

```java
import org.testcontainers.postgresql.PostgreSQLContainer;

public abstract class IntegrationTest {
  static final PostgreSQLContainer POSTGRES =
      new PostgreSQLContainer("postgres:17-alpine")
          .withCommand("postgres", "-c", "fsync=off", "-c", "synchronous_commit=off");

  static {
    POSTGRES.start();                 // stopped by Ryuk when the JVM exits
  }

  @BeforeAll
  static void migrate() {
    Flyway.configure()
        .dataSource(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword())
        .load().migrate();            // idempotent: a no-op after the first class
  }
}
```

Spring Boot (3.1+): `@ServiceConnection` replaces `@DynamicPropertySource` wiring:

```java
@SpringBootTest
@Testcontainers
class OrderApiTest {
  @Container @ServiceConnection
  static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");
}
```

For a suite-wide singleton in Spring, put the container in a `@TestConfiguration` as a
`@Bean @ServiceConnection` and import that configuration everywhere, so the context cache
and the container agree.

Static `@Container` field: one container per class. Instance `@Container` field: one per
test method (avoid). The extension is documented as sequential-only.

Reuse (local only): `~/.testcontainers.properties` with `testcontainers.reuse.enable=true`,
then `.withReuse(true)` and do not call `stop()`.

## Node.js / TypeScript

```bash
npm install -D testcontainers @testcontainers/postgresql
```

Vitest global setup, one container per run, URL passed to workers with `provide`/`inject`:

```ts
// test/global-setup.ts
import type { TestProject } from "vitest/node";
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { execSync } from "node:child_process";

let pg: StartedPostgreSqlContainer;

export async function setup(project: TestProject) {
  pg = await new PostgreSqlContainer("postgres:17-alpine")
    .withCommand(["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off"])
    .start();
  const url = pg.getConnectionUri();
  execSync("npx prisma migrate deploy", { env: { ...process.env, DATABASE_URL: url }, stdio: "inherit" });
  project.provide("databaseUrl", url);
}

export async function teardown() {
  await pg?.stop();
}

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

```ts
// vitest.config.ts
export default defineConfig({ test: { globalSetup: ["./test/global-setup.ts"] } });
// in tests:  import { inject } from "vitest";  const url = inject("databaseUrl");
```

Per-worker database from a template: after migrating, rename the migrated database to
`app_template` (or migrate into a database created for that purpose), then in a
`setupFiles` hook run `CREATE DATABASE test_${process.env.VITEST_POOL_ID} TEMPLATE app_template`.

Module snapshot/restore (single process only, because the container object lives in the
process that started it):

```ts
await pg.snapshot();          // after migrations; close client connections first
await pg.restoreSnapshot();   // before each test
```

Generic container with wait strategy:

```ts
import { GenericContainer, Wait } from "testcontainers";

const redis = await new GenericContainer("redis:7-alpine")
  .withExposedPorts(6379)
  .withWaitStrategy(Wait.forLogMessage("Ready to accept connections"))
  .withStartupTimeout(120_000)
  .start();
const url = `redis://${redis.getHost()}:${redis.getMappedPort(6379)}`;
```

Other waits: `Wait.forHttp("/health", 8080)`, `Wait.forHealthCheck()`,
`Wait.forListeningPorts()`, `Wait.forLogMessage(text, times)`.

Reuse: `.withReuse()` plus `TESTCONTAINERS_REUSE_ENABLE=true` in the developer's shell.
Jest: same pattern in `globalSetup`, but pass the URL through `process.env` (Jest has no
`provide`), and stop the container in `globalTeardown` via a module-level handle or
`globalThis`.

## Python (pytest)

```bash
pip install "testcontainers[postgres]" pytest
```

```python
# conftest.py
import pytest, sqlalchemy
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def pg_url():
    with PostgresContainer("postgres:17-alpine", driver="psycopg") as pg:   # driver=None for a plain URL
        url = pg.get_connection_url()
        run_alembic_upgrade(url)          # alembic command.upgrade(cfg, "head")
        yield url

@pytest.fixture
def db(pg_url):
    engine = sqlalchemy.create_engine(pg_url)
    with engine.connect() as conn:
        trans = conn.begin()
        yield conn                         # transaction-per-test isolation
        trans.rollback()
```

pytest-xdist: each worker process runs its own session fixtures, so this starts one
container per worker. To share one container, start it in the controller (for example in
`pytest_configure` guarded by `not hasattr(config, "workerinput")`) and pass the URL to
workers, then create `test_{PYTEST_XDIST_WORKER}` databases from a template.

Module containers include their own readiness check. For generic `DockerContainer`
images, use the wait strategy API of the installed 4.x release (newer releases move
modules from the older `wait_for_logs` helper to wait strategy objects).

## Go

```bash
go get github.com/testcontainers/testcontainers-go/modules/postgres
```

```go
var connStr string

func TestMain(m *testing.M) {
    ctx := context.Background()
    ctr, err := postgres.Run(ctx, "postgres:17-alpine",
        postgres.WithDatabase("app"),
        postgres.WithUsername("app"),
        postgres.WithPassword("app"),
        postgres.BasicWaitStrategies(),
        postgres.WithSQLDriver("pgx"),   // driver used by Snapshot/Restore; match the one you import
    )
    if err != nil { log.Fatal(err) }
    connStr, err = ctr.ConnectionString(ctx, "sslmode=disable")
    if err != nil { log.Fatal(err) }
    if err := migrate(connStr); err != nil { log.Fatal(err) }
    if err := ctr.Snapshot(ctx); err != nil { log.Fatal(err) }
    shared = ctr

    code := m.Run()
    _ = testcontainers.TerminateContainer(ctr)
    os.Exit(code)
}

func resetDB(t *testing.T) {
    t.Cleanup(func() {
        if err := shared.Restore(context.Background()); err != nil { t.Fatal(err) }
    })
}
```

Inside a single test that owns a container, prefer `testcontainers.CleanupContainer(t, ctr)`
right after creation so cleanup runs even if later setup fails.

Generic container: `testcontainers.Run(ctx, "redis:7-alpine",
testcontainers.WithExposedPorts("6379/tcp"), testcontainers.WithWaitStrategy(wait.ForLog("Ready to accept connections")))`.
Reuse locally with `testcontainers.WithReuseByName("app-pg")`.

`Snapshot`/`Restore` serialize tests; for `t.Parallel()`, create one database per test
from a template instead.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc