Overview
Integration tests exercise your code against real collaborators — a real HTTP stack, a
real Postgres, a real Kafka. Go lowers the cost of the first dramatically:
net/http/httptest starts your actual handlers on a real socket in-process, so
HTTP-boundary tests run in milliseconds with no container at all. For everything that
genuinely needs an external engine, testcontainers-go (or the lighter dockertest) starts
throwaway Docker containers from the test binary itself, keeping the suite one
go test ./... away.
Key points
httptest.Serveris the killer feature:httptest.NewServer(handler)binds your real mux/middleware/handlers to127.0.0.1:0and gives you a base URL; the client side is a plainhttp.Client. Real serialization, real status codes, no mocked transport.httptest.NewRecorderis the even cheaper variant: invoke a handler directly with aResponseRecorderwhen you don't need a socket — good for handler-only units.- testcontainers-go starts containers programmatically; its modules
(
modules/postgres,modules/kafka,modules/redis, …) encapsulate images, wait strategies and connection-string plumbing. - Ryuk, the reaper: testcontainers runs a sidecar that garbage-collects containers,
networks and volumes even when the test process dies — the reason orphaned containers
are rare. Disable (
TESTCONTAINERS_RYUK_DISABLED) only in locked-down CI with its own cleanup. - dockertest (
ory/dockertest) is the lighter alternative: talk to the Docker daemon, run an image, poll withpool.Retryuntil ready. Fewer batteries, fewer abstractions. - Segregate the slow suite:
testing.Short()+go test -shortto skip, or a build tag (//go:build integration) so integration files don't even compile into the default run. Pick one convention per repo. - One container per package, not per test: start shared infrastructure in
TestMain, keep per-test isolation at the data layer. - Coverage across binaries: since Go 1.20, build with
go build -coverand setGOCOVERDIRto collect coverage from an integration-tested binary, then merge withgo tool covdata— see go tool cover.
Details
Database test patterns
- Migrations in
TestMain: rungolang-migrateorpressly/gooseagainst the fresh container before any test — the schema under test is the schema you ship. Both tools embed cleanly (iofssource /embed.FS). - Per-test transaction rollback: open a tx per test, hand it to the code under test,
roll back in
t.Cleanup. Fast and perfectly isolated — but useless when the code under test manages its own transactions. - Truncate between tests:
TRUNCATE ... RESTART IDENTITY CASCADEin cleanup. Slower, but honest about transaction boundaries; the default for repository-layer tests. - Parallelism: either one database per
t.Paralleltest (cheap withCREATE DATABASE) or accept serial execution for the DB suite; sharing one schema across parallel tests is the classic flake source.
httptest vs containers — choosing
| Boundary | Tool | Cost |
|---|---|---|
| Your HTTP API | httptest.Server around your real router |
Milliseconds, in-process |
| A downstream HTTP service | httptest.Server serving canned/asserting handlers |
Milliseconds — stdlib WireMock |
| SQL database | testcontainers-go modules/postgres |
Seconds to start, shared per package |
| Kafka / brokers | testcontainers-go modules/kafka |
Seconds; prefer contract or fake for pure logic |
Examples
func TestMain(m *testing.M) { os.Exit(run(m)) }
func run(m *testing.M) int {
ctx := context.Background()
pg, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("app_test"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("5432/tcp")),
)
if err != nil {
log.Fatal(err)
}
defer pg.Terminate(ctx)
dsn, _ := pg.ConnectionString(ctx, "sslmode=disable")
if err := migrateUp(dsn); err != nil { // golang-migrate against the container
log.Fatal(err)
}
os.Setenv("TEST_DATABASE_URL", dsn)
return m.Run()
}
func TestCreateOrderEndpoint(t *testing.T) {
srv := httptest.NewServer(api.NewRouter(mustOpenDB(t)))
t.Cleanup(srv.Close)
resp, err := http.Post(srv.URL+"/orders", "application/json",
strings.NewReader(`{"sku":"A-1","qty":2}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
}
Related
- Testing in Go — strategies & tooling map — parent map of the strategy portfolio.
- End-to-end testing — the next boundary out: the deployed system.
- go tool cover — GOCOVERDIR and merging coverage from integration binaries.
- Relational databases in Go — the drivers and query layers these tests exercise.
- Integration testing in Java — Testcontainers' home turf, for contrast.