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.
- name
testcontainers - 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.
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, notpostgres: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.sqlor 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
| Option | Start cost | Use when |
|---|---|---|
| One container per test run, shared by all files (default) | Once | Almost always |
| One container per test class/file | Per file | Files need different images or config |
| One container per test | Every test | Only when state cannot be reset (rare: testing the engine’s own startup, destructive admin ops) |
Where “once per run” lives in each ecosystem:
| Stack | Mechanism |
|---|---|
| JUnit 5 | Singleton: 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 Boot | Same singleton or @Container @ServiceConnection static fields; Spring caches the context per configuration, so keep configuration identical across classes |
| Vitest / Jest | globalSetup starts the container and exposes its URL (provide() in Vitest, env var in Jest) |
| pytest | scope="session" fixture |
| Go | TestMain 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:
| Signal | When |
|---|---|
Docker HEALTHCHECK (wait for healthy) | Image defines a real health check |
| HTTP endpoint returns 200 | Services with a health route |
| Log message, with occurrence count | Engines that log readiness; Postgres prints “ready to accept connections” once during init and again when really ready, so count 2 |
| Listening port | Last 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
| Strategy | Speed | Breaks when | Choose when |
|---|---|---|---|
| Transaction per test, rolled back | Fastest | Code under test commits, uses several connections, or opens its own transaction; HTTP-level tests run the app in another thread | Repository/DAO tests that share one connection |
| Truncate all tables before each test | Fast for small schemas | Hundreds of tables; parallel tests on the same database | Simple default for service and API tests |
| Template database per test or per worker | Fast (file copy) | Needs Postgres (CREATE DATABASE ... TEMPLATE) and no open connections to the template | Parallel suites on Postgres |
| Module snapshot/restore (Node, Go Postgres modules) | Fast | Open client connections block it; never use the postgres system database | Same as template, with less code |
| Unique data per test (random tenant IDs, emails) | No reset cost | Tests that count rows globally | Add 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 Got.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
| Technique | Effect | How |
|---|---|---|
| Reusable containers | Skips startup between runs on a dev machine | testcontainers.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 writes | command postgres -c fsync=off -c synchronous_commit=off -c full_page_writes=off |
| tmpfs data dir | Faster I/O | mount tmpfs at /var/lib/postgresql/data (module/container tmpfs option) |
| Smaller images | Faster pulls | -alpine / slim variants that match prod’s major version |
| Parallel start | Lower wall clock | Start 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
| Runner | Setup |
|---|---|
GitHub Actions ubuntu-latest | Docker is preinstalled; nothing extra |
| GitHub Actions macOS/Windows | No Linux Docker daemon by default; run integration tests on Linux runners |
| GitLab CI | docker: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 mounted | Set TESTCONTAINERS_HOST_OVERRIDE if the mapped ports are not reachable on localhost |
| Rootless Podman | DOCKER_HOST to the Podman socket; TESTCONTAINERS_RYUK_DISABLED=true if Ryuk cannot run, and then stop containers explicitly |
| No Docker allowed | Testcontainers 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
localhosthard-coded in app config: breaks under Docker-in-Docker where the host isdocker. 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 DATABASEwhile the pool still holds connections: close the pool first or useWITH (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
- Run the suite twice in a row: the second run passes without manual cleanup.
- Run in random order (
--sequence.shufflein Vitest,-p randomlywith pytest-randomly,go test -shuffle=on, JUnitMethodOrderer.Random): passes. - Run with full parallelism: passes, and the run time drops.
- Run in CI from a cold cache: passes, and
docker ps -aon the runner shows no leftover containers after the job (Ryuk cleaned up). - 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
# 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.