Skip to content

Testcontainers

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/references/testcontainers.md
DescriptionNot 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() so go test -short runs the fast unit suite and CI runs the full suite as a separate step.
  • t.Cleanup runs in LIFO order and always runs, even on t.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 in references/go-scaffolding.md applies 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 cachepostgres:16-alpine pulls 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=true and pass testcontainers.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-build and $GOMODCACHE between 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 WithReuse locally for a faster inner loop; leave it off in CI.
  • See references/testing.md for the unit-test layer this sits above, and references/database.md for the transaction patterns these tests exercise.