rgoussu@goussu: ~/library/go/testing
~/library/go/testing cat integration-testing.md

Integration testing — httptest, testcontainers-go, real databases

# Go integration testing — httptest.Server for real HTTP in-process, testcontainers-go and dockertest for real dependencies, suite segregation and DB patterns.

Conceptsaved 2026-08-09 #go#testing#httptest#testcontainers#storage

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.Server is the killer feature: httptest.NewServer(handler) binds your real mux/middleware/handlers to 127.0.0.1:0 and gives you a base URL; the client side is a plain http.Client. Real serialization, real status codes, no mocked transport.
  • httptest.NewRecorder is the even cheaper variant: invoke a handler directly with a ResponseRecorder when 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 with pool.Retry until ready. Fewer batteries, fewer abstractions.
  • Segregate the slow suite: testing.Short() + go test -short to 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 -cover and set GOCOVERDIR to collect coverage from an integration-tested binary, then merge with go tool covdata — see go tool cover.

Details

Database test patterns

  • Migrations in TestMain: run golang-migrate or pressly/goose against the fresh container before any test — the schema under test is the schema you ship. Both tools embed cleanly (iofs source / 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 CASCADE in cleanup. Slower, but honest about transaction boundaries; the default for repository-layer tests.
  • Parallelism: either one database per t.Parallel test (cheap with CREATE 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