Testcontainers
| Field | Value |
|---|---|
| Type | Skill Resource |
| Source | ~/.copilot/skills/backend/references/testcontainers.md |
| Description | Not specified |
Source Content
Testcontainers
Marcus ran his integration suite locally and it passed every time. In CI, two tests flipped red on alternating runs. Both had started a Postgres container on a hardcoded port. They stepped on each other the moment tests ran in parallel.
Ana’s suite runs the same integration tests in parallel across a full CI matrix. Every test isolates its own container or its own transaction. The module cache means a cold CI runner still starts in under two seconds.
This file covers what references/testing.md used to hold inline: container lifecycle, parallel test isolation, and caching so repeated runs stay fast. It assumes the table-driven and unit-test patterns from references/testing.md — this file is specifically the “real Postgres in a container” layer.
Lifecycle: setup and teardown
testcontainers-go’s postgres.Run starts a real Postgres in a Docker container and returns a handle with a connection string. t.Cleanup guarantees teardown even when the test fails or panics — this is the one line that keeps a flaky container from leaking into the next test run.
func TestInvoiceRepository_RoundTrip(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:16-alpine", postgres.WithDatabase("test"), postgres.WithUsername("test"), postgres.WithPassword("test"), ) if err != nil { t.Fatalf("start postgres: %v", err) } t.Cleanup(func() { _ = pg.Terminate(ctx) })
dsn, err := pg.ConnectionString(ctx, "sslmode=disable") if err != nil { t.Fatalf("dsn: %v", err) } // migrate, open pgxpool, exercise repo.Save then repo.ByID, assert equality. _ = dsn}- Gate integration tests behind
testing.Short()sogo test -shortruns the fast unit suite and CI runs the full suite as a separate step. t.Cleanupruns in LIFO order and always runs, even ont.Fatal— never rely on code after the test body to close the container.- Pin the image tag (
postgres:16-alpine), never:latest— the same ADR-024 rule that governs Dockerfile tags inreferences/go-scaffolding.mdapplies to test images.
Parallel test isolation
Two isolation strategies, picked by how much state a test mutates.
One container per package (TestMain)
Start the container once in TestMain. Run every test in the package against it, and isolate each test with a transaction that rolls back at the end instead of committing. Fastest option — one container start pays for the whole package.
func TestMain(m *testing.M) { ctx := context.Background() pg, err := postgres.Run(ctx, "postgres:16-alpine") if err != nil { log.Fatalf("start postgres: %v", err) } defer pg.Terminate(ctx)
sharedDSN, _ = pg.ConnectionString(ctx, "sslmode=disable") os.Exit(m.Run())}
func withRollback(t *testing.T, fn func(tx pgx.Tx)) { pool, _ := pgxpool.New(context.Background(), sharedDSN) tx, _ := pool.Begin(context.Background()) t.Cleanup(func() { tx.Rollback(context.Background()) }) // never committed — always rolls back fn(tx)}- Correct when tests only read or write inside their own transaction and never rely on another test’s committed data.
- Wrong the moment a test needs to see another goroutine’s committed write (e.g., testing a trigger that fires on commit) — use the per-test container instead.
One container per test
Each test gets its own postgres.Run, gated behind t.Parallel(). Slower per-test (container start cost every time) but fully isolated — no test can see another’s state, committed or not.
func TestInvoiceRepository_ConcurrentWrites(t *testing.T) { t.Parallel() if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx := context.Background() pg, err := postgres.Run(ctx, "postgres:16-alpine") if err != nil { t.Fatalf("start postgres: %v", err) } t.Cleanup(func() { _ = pg.Terminate(ctx) }) // exercise a scenario that needs a fully isolated database, e.g. testing // the actual serialization-failure retry loop from references/database.md}Use per-test containers for anything that tests concurrency or commit-visibility. That includes the 40001 retry path from references/database.md — a shared transaction can’t exercise those honestly.
Module and image caching for faster repeated runs
Two independent caches speed up repeated local and CI runs:
- Docker image cache —
postgres:16-alpinepulls once per machine; CI runners with a warm Docker layer cache skip the pull entirely. Pin the same tag across every test file so the cache actually hits. - Testcontainers “reuse” mode — set
TESTCONTAINERS_REUSE_ENABLE=trueand passtestcontainers.WithReuse(true)to keep one container alive across multiple local test runs instead of starting fresh each time. Use this for local dev loops only — never in CI, where a stale reused container hides a broken migration.
pg, err := postgres.Run(ctx, "postgres:16-alpine", testcontainers.WithReuse(true), // local dev only; never in CI)- Go’s own build cache and module cache (
GOMODCACHE) already speed up compiling the test binary itself — make sure CI restores~/.cache/go-buildand$GOMODCACHEbetween runs, same as any other Go build step. - A cold CI runner with both caches warm starts the full integration suite in low single-digit seconds; without them, every run re-pulls the image and re-downloads modules.
How to use this reference
- Default to one container per package with rollback-per-test — fastest, correct for the common case.
- Switch to one container per test the moment a test needs commit-visibility or concurrency (serialization retries, triggers).
- Pin image tags; never
:latest. - Enable
WithReuselocally for a faster inner loop; leave it off in CI. - See
references/testing.mdfor the unit-test layer this sits above, andreferences/database.mdfor the transaction patterns these tests exercise.