Skip to content
gohacks.dev
Testing

Integration Tests in Go With Testcontainers and Docker

Write Go integration tests against a real PostgreSQL started by testcontainers-go from TestMain: wait strategies, isolation, cleanup and CI notes.

13 min read
Summarize in AI
Flat illustration of a test report with one failing test, with the Docker logo.

A mock can tell you that your code calls INSERT. It cannot tell you that the SQL is valid, that the unique constraint exists, that the driver maps a numeric column the way you expect, or that two statements deadlock. For those questions you need the real database, and the real database used to mean a shared test server that someone forgot to reset. Testcontainers for Go replaces it with a throwaway container that your test starts, uses and removes.

Tested with Go 1.25.6, testcontainers-go v0.44.0, pgx v5.11.0, PostgreSQL 18, Docker Desktop 29.9.2, macOS on Apple Silicon (arm64)

This guide builds that setup for PostgreSQL on Go 1.25.6 and covers the decisions around it: when to start the container, how to know it is ready, how to keep tests independent, how to clean up, what changes in CI and where this approach stops being the right tool. The code ran against Docker Desktop on an Apple Silicon Mac. For the cheaper alternatives and their limits, see testing against SQLite in memory; for the Postgres-specific techniques that make the suite fast, see testing against a real Postgres.

What testcontainers-go actually does

The library talks to the Docker API through the same socket the docker command uses. From a test you describe a container (image, environment, exposed ports, a readiness check), and it creates and starts it, waits, and returns a handle. Docker publishes each exposed port on a random free port on the host, so two test runs, or two packages, never fight over 5432. You ask the handle for the host and the mapped port, or for a ready-made connection string, and connect as you would to any server.

It also starts a small helper container called Ryuk, the resource reaper. If your test process is killed before it can clean up, Ryuk notices the lost connection and removes the containers that process created. That is the difference between this approach and a docker run in a shell script: leaked containers are the common failure of the hand-rolled version.

There are two layers. A generic API (testcontainers.GenericContainer) runs any image. On top of that sit modules for common services, including PostgreSQL, MySQL, Redis, Kafka and MongoDB, which know the right environment variables, ports and readiness checks. Use a module when one exists; it saves you from writing a wait strategy that is subtly wrong.

The code under test

A small store over pgx's connection pool. It creates its table on start-up and offers three operations:

store.goGo
package main

import (
	"context"

	"github.com/jackc/pgx/v5/pgxpool"
)

type Store struct{ pool *pgxpool.Pool }

const schema = `
CREATE TABLE IF NOT EXISTS accounts (
	id    serial PRIMARY KEY,
	email text NOT NULL UNIQUE,
	plan  text NOT NULL DEFAULT 'free'
)`

func NewStore(ctx context.Context, dsn string) (*Store, error) {
	pool, err := pgxpool.New(ctx, dsn)
	if err != nil {
		return nil, err
	}
	if _, err := pool.Exec(ctx, schema); err != nil {
		pool.Close()
		return nil, err
	}
	return &Store{pool: pool}, nil
}

func (s *Store) Create(ctx context.Context, email string) (int, error) {
	var id int
	err := s.pool.QueryRow(ctx, `INSERT INTO accounts (email) VALUES ($1) RETURNING id`, email).Scan(&id)
	return id, err
}

func (s *Store) Plan(ctx context.Context, email string) (string, error) {
	var plan string
	err := s.pool.QueryRow(ctx, `SELECT plan FROM accounts WHERE email = $1`, email).Scan(&plan)
	return plan, err
}

func (s *Store) Truncate(ctx context.Context) error {
	_, err := s.pool.Exec(ctx, `TRUNCATE accounts RESTART IDENTITY`)
	return err
}

func main() {}

Nothing in it knows about containers. It takes a DSN. That is the point: the production code stays free of test infrastructure, and the same constructor takes the DSN of the container in tests and of the managed database in production. A Truncate helper exists for tests only; in a real project that would live in a _test.go file.

Starting the container once, in TestMain

store_test.goGo
package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgconn"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
)

var testStore *Store

func TestMain(m *testing.M) {
	ctx := context.Background()
	start := time.Now()
	ctr, err := postgres.Run(ctx, "postgres:18",
		postgres.WithDatabase("app"),
		postgres.WithUsername("app"),
		postgres.WithPassword("app"),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).WithStartupTimeout(60*time.Second)),
	)
	if err != nil {
		fmt.Fprintln(os.Stderr, "start postgres:", err)
		os.Exit(1)
	}
	dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		fmt.Fprintln(os.Stderr, "dsn:", err)
		os.Exit(1)
	}
	testStore, err = NewStore(ctx, dsn)
	if err != nil {
		fmt.Fprintln(os.Stderr, "store:", err)
		os.Exit(1)
	}
	fmt.Printf("container ready: %v\n", time.Since(start) < 60*time.Second)

	code := m.Run()

	_ = ctr.Terminate(ctx)
	os.Exit(code)
}

func reset(t *testing.T) {
	t.Helper()
	if err := testStore.Truncate(context.Background()); err != nil {
		t.Fatal(err)
	}
}

func TestCreateAndPlan(t *testing.T) {
	reset(t)
	ctx := context.Background()
	id, err := testStore.Create(ctx, "[email protected]")
	if err != nil || id != 1 {
		t.Fatalf("id=%d err=%v", id, err)
	}
	plan, err := testStore.Plan(ctx, "[email protected]")
	if err != nil || plan != "free" {
		t.Fatalf("plan=%q err=%v", plan, err)
	}
}

func TestUniqueEmail(t *testing.T) {
	reset(t)
	ctx := context.Background()
	if _, err := testStore.Create(ctx, "[email protected]"); err != nil {
		t.Fatal(err)
	}
	_, err := testStore.Create(ctx, "[email protected]")
	var pgErr *pgconn.PgError
	if !errors.As(err, &pgErr) || pgErr.Code != "23505" {
		t.Fatalf("want unique violation, got %v", err)
	}
	t.Logf("sqlstate %s, constraint %s", pgErr.Code, pgErr.ConstraintName)
}

TestMain runs once per package before any test. Starting the container there means every test in the package shares it, and the cost of starting is paid once rather than per test. The call to postgres.Run takes the image reference first and options after it. Note the image: postgres:18, not postgres:latest. A floating tag turns a green build red on the day the upstream image changes, and the failure will not be in your code.

WithDatabase, WithUsername and WithPassword set the environment variables the official image reads on first start. The wait strategy deserves a longer look.

Why the log line is waited for twice

The module's default readiness check is already sensible, but the lab spells one out to show what it does: it waits for database system is ready to accept connections with WithOccurrence(2). The official PostgreSQL image starts a temporary server while it runs the initialisation scripts, shuts it down, and starts the real server. The message therefore appears twice. A check that stops at the first occurrence reports ready while the server is about to restart, and the first connection fails with "the database system is shutting down" or a reset. That is the single most common reason a Testcontainers suite is flaky on its first query.

Always prefer waiting for the application's own signal to waiting for a TCP port. Docker opens the published port immediately, before the process inside is listening, so a port check passes too early on some services. A log line, an HTTP health endpoint (wait.ForHTTP) or a SQL probe (wait.ForSQL) reflects the service being ready to serve.

After the container is up, ConnectionString returns a DSN with the mapped port already filled in, and the lab passes sslmode=disable as an extra parameter because the container does not offer TLS. Then NewStore connects and creates the table, m.Run() executes the tests and ctr.Terminate stops and removes the container.

Because TestMain has no *testing.T, errors are printed to standard error and the process exits non-zero. That is the one awkward part of this design: you cannot call t.Fatal here. Keep this function small and put everything else in helpers that tests call.

The recorded run

Shell
export TESTCONTAINERS_RYUK_DISABLED=true
go mod tidy >/dev/null 2>&1
go test -count=1 -v . 2>&1 | sed -E 's/^ +[a-z_]+\.go:[0-9]+: +/    /' | grep -E '^(container ready|--- |    sqlstate|ok|FAIL)' | sed -E 's/ \([0-9.]+s\)//; s/^ok.*/ok/'
Text
container ready: true
--- PASS: TestCreateAndPlan
    sqlstate 23505, constraint accounts_email_key
--- PASS: TestUniqueEmail
ok

Both tests passed against a real server, and the second logged sqlstate 23505, constraint accounts_email_key. That detail is the reason the real database matters: the 23505 code and the generated constraint name come from PostgreSQL, and a mock of the store interface would have returned whatever the test author imagined. The whole run, including compiling the test binary and starting and stopping the container with the image already on disk, took about 6 seconds of wall time on this machine. That is one measurement, not a benchmark; the first run on a machine without the image also pays for the download, which can dominate everything else.

Keeping tests independent

One container for the package makes the tests share state, so each test has to start from a known point. The lab calls reset first, which runs TRUNCATE accounts RESTART IDENTITY. The identity restart is why TestCreateAndPlan can assert that the new account has ID 1 regardless of which test ran before it.

There are four ways to get isolation, with different costs:

StrategySpeedGood forWatch out for
Truncate tables before each testfastmost suites, few tablestests cannot run in parallel against the same tables
Transaction per test, rolled backfastestcode that accepts a transactioncode that commits itself, or uses several connections
Schema or database per testmediumparallel testsmigration cost for each new schema
Container per testslowesttests that change server settingsseconds per test, memory

A transaction per test is covered in rolling back transactions in tests. A database created from a template (CREATE DATABASE test_1 TEMPLATE app_template) gives full isolation at the price of a few tens of milliseconds, and lets tests run with t.Parallel. The Postgres module can also snapshot a database after migrations and restore it between tests, which is a documented feature of the module; I did not run it for this article, so check the module documentation for its current API before relying on it.

A container per test is rarely worth it. The exception is when a test needs a server configured in a way the others must not see, such as a different max_connections, a particular extension or a version you are about to upgrade to.

Cleanup and the reaper

The lab ended TestMain with an explicit Terminate, which is correct and enough when the process exits normally. The reaper covers the other cases: you interrupt the run with Ctrl-C, a CI job is cancelled, or the test panics in a goroutine and the process dies. In the lab, run.sh exported TESTCONTAINERS_RYUK_DISABLED=true, because the reaper is a separate image that has to be pulled and I wanted the run to depend only on the Postgres image. That is a choice for a sealed lab. In a project, leave the reaper enabled, so a killed run does not leave containers behind.

If you do disable it, for example on a CI system where containers are destroyed with the job anyway, make sure something else owns the clean-up. A stray Postgres per aborted run consumes memory until the machine is rebooted, and on a developer laptop it adds up quickly.

Inside a normal test function, with a *testing.T, prefer testcontainers.CleanupContainer(t, ctr) right after creating the container, which registers a cleanup that terminates it even if the test fails halfway. TestMain cannot do that, which is one reason some teams start containers in a helper called from each test file's first test via sync.Once.

Running other services

A Redis cache is the same shape, using the Redis module:

Go
// from the module documentation; not run in this article
ctr, err := redis.Run(ctx, "redis:7-alpine")
uri, err := ctr.ConnectionString(ctx)

For a service without a module, use GenericContainer with an image, ExposedPorts: []string{"8080/tcp"}, environment variables and a wait strategy, then ctr.MappedPort(ctx, "8080") to find the host port. Write the wait strategy against something observable, since the generic API cannot know when your image is ready. A service you build yourself can be started from a Dockerfile with FromDockerfile, which builds the image as part of the test and is useful for a worker or a sidecar of your own.

Several containers in one test, such as a database and a message broker, can share a Docker network created with the network package, and address each other by alias. Keep the number small: every container adds start-up time, and a test that needs five of them is usually describing a system test, which belongs in a separate stage.

Where the tests live and when they run

Container tests take seconds and need Docker, so a plain go test ./... on a laptop without Docker should not fail with a confusing error. Two habits help.

First, put them in their own package, such as internal/store/storetest, or behind a build tag (//go:build integration). Developers run the fast suite constantly and the integration suite before pushing. The tag approach is covered by the Go documentation of build constraints.

Second, skip with a clear message instead of failing when Docker is unavailable. The testcontainers module reports a provider error; the helper can call t.Skip in local development and t.Fatal when an environment variable such as CI is set, so a misconfigured CI job cannot pass by skipping everything.

Running in CI

On GitHub Actions' hosted Ubuntu runners Docker is installed, and the tests work with no extra setup. Three things go wrong elsewhere.

  • No Docker socket. A job running inside a container needs the host's socket mounted, or a Docker-in-Docker service. Testcontainers reads DOCKER_HOST, and it supports a few alternative providers; check the documentation for the one you use.
  • Slow image pulls. Pull the pinned images in a previous step or cache them, so test time does not include a download and a rate limit from a registry cannot fail the build. Pinning the tag is what makes caching possible.
  • Resource limits. Several packages starting a database each, in parallel, can exhaust a small runner's memory. go test -p 2 limits how many packages run at once, or share one container across packages by starting it outside go test, at the cost of the tidy lifecycle.

Compare the cost with a services: block in the CI configuration, which starts a database once per job. That is simpler and faster, but then developers need to start the same database by hand to run the tests locally, and the connection details live in two places. The testcontainers approach makes the test suite self-contained: go test is the only command anyone needs.

For the surrounding setup, Docker Compose for Go development shows the other way to run a database locally, which suits manual exploration better than automated tests.

What not to test this way

A container test is the right tool for code whose correctness depends on a real service: SQL, migrations, transactions, queue semantics, cache expiry. It is the wrong tool for business rules that merely use the database. Test those with an in-memory fake behind an interface, as described in mocking with small interfaces, and keep the container for the thin layer that translates between your types and the server. A suite that runs every test through Postgres will take minutes, and a slow suite is a suite people stop running.

Do not assert on timing, because a container on a loaded CI host is slow in unpredictable ways. Do not depend on data left behind by another test, and do not share a container between packages that write to the same tables. And do not leave the reaper off on a machine you care about.

A troubleshooting checklist

When a container test fails, work down this list:

  1. "Cannot connect to the Docker daemon": Docker is not running, or DOCKER_HOST points at the wrong socket. Run docker ps first.
  2. A connection error on the first query only: the wait strategy returned too early. Check the occurrence count, or use a SQL-based probe.
  3. A test passes alone and fails in the suite: leaked data. Add the reset to the failing test and look for a missing one elsewhere.
  4. Everything times out on CI only: the image is being pulled during the start-up timeout. Pre-pull it, or raise the start-up timeout.
  5. Containers remain after a failed run: the reaper was off or could not start. List them with docker ps and remove them, then re-enable it.

Putting it together

Start the real service from TestMain, pin its image, wait on the service's own readiness signal, give every test a known starting state, and let the library clean up. Keep these tests in their own package or tag, and write most of your tests against fakes. The project documentation at golang.testcontainers.org lists every module and option, the source is at github.com/testcontainers/testcontainers-go, and the PostgreSQL image's start-up behaviour is described in its Docker Hub documentation.

Also inDatabases
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.