How to Use testcontainers-go for Docker-Based Integration Tests

Use testcontainers-go to automate spinning up Docker containers for integration tests, handling lifecycle management and port mapping automatically.

The disposable database problem

Integration tests break when they rely on a shared development database. One developer runs a test that deletes all users. Another developer runs a test that expects a clean schema. The CI pipeline fails because the test database is locked or out of sync. You could mock the database driver, but mocks lie. They verify that you called db.Query with the right arguments, not that your SQL actually works against a real engine. You could ask every team member to install PostgreSQL locally, but that breaks onboarding and makes CI configuration drift from local machines.

The alternative is to treat infrastructure like test fixtures. You spin up a real database, run your test, and tear it down. Docker makes this possible, but managing container lifecycles, port mapping, and readiness checks inside a test function is tedious. The testcontainers-go library abstracts the plumbing. You declare what you need, the library starts it, waits for it to be ready, hands you a connection string, and cleans up when the test finishes.

How testcontainers actually works

The library treats Docker containers as disposable resources scoped to a test function. Under the hood, it talks to the Docker daemon using the same API that docker run uses. You provide an image name, environment variables, and a readiness strategy. The library pulls the image if it is missing, creates a container, starts it, and maps a random host port to the container port. Random ports prevent collisions when multiple tests run in parallel.

Readiness is the hardest part. A database container starts quickly, but the engine inside needs time to initialize. If your test connects too early, it panics or hangs. The library solves this with wait strategies. You tell it to wait for a specific log line, an HTTP endpoint, a TCP port, or a custom health check. The test blocks until the condition is met or a timeout fires.

Cleanup happens through Go's defer statement. You call container.Terminate(ctx) immediately after starting the container. The call is deferred until the test function returns, whether it passes, fails, or panics. The container stops, the network is removed, and the disk space is reclaimed.

Minimal example

Here is the smallest working test that starts a PostgreSQL container, waits for it to be ready, and extracts the connection port.

package main

import (
	"context"
	"testing"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

func TestDatabase(t *testing.T) {
	// Context controls the lifetime and cancellation of the container
	ctx := context.Background()
	req := testcontainers.ContainerRequest{
		Image:        "postgres:15",
		ExposedPorts: []string{"5432/tcp"},
		Env: map[string]string{
			"POSTGRES_USER":     "test",
			"POSTGRES_PASSWORD": "test",
			"POSTGRES_DB":       "test",
		},
		// Blocks until the DB prints this exact log line
		WaitingFor: wait.ForLog("database system is ready to accept connections"),
	}
	// Starts the container and returns a handle for interaction
	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		t.Fatal(err)
	}
	// Guarantees cleanup even if the test panics or fails later
	defer container.Terminate(ctx)

	// Resolves the random host port mapped to container port 5432
	endpoint, err := container.MappedPort(ctx, "5432")
	if err != nil {
		t.Fatal(err)
	}
	// endpoint.Port() returns the actual host port for your DB driver
}

What happens under the hood

The test function begins by creating a context. Contexts in Go always flow downward through the call stack, and testcontainers-go respects that convention. The context carries deadlines, cancellation signals, and request-scoped values. If you wrap the test in a timeout or run it under go test -timeout, the context will cancel when the deadline passes, and the library will stop the container gracefully.

The ContainerRequest struct defines the container configuration. Image points to the Docker Hub registry by default. ExposedPorts tells the library which internal ports need host mapping. Env passes environment variables to the container's entrypoint. PostgreSQL uses these variables to create the initial user and database on first boot.

GenericContainer is the entry point. It sends a ContainerCreate request to the Docker daemon, then calls ContainerStart. The daemon allocates a network namespace, mounts the filesystem layers, and executes the entrypoint. The library then applies the wait strategy. wait.ForLog attaches to the container's stdout stream and scans for the target string. This is safer than sleeping for a fixed number of seconds. Sleeps make tests slow on fast machines and flaky on slow CI runners.

Once the wait strategy succeeds, the function returns a Container interface. The interface exposes methods for logs, port mapping, and execution. MappedPort queries the daemon for the host port assigned to the container port. Docker picks a random available port to avoid conflicts. The test captures that port and uses it to build a connection string.

The defer container.Terminate(ctx) line is critical. Go executes deferred calls in LIFO order when the surrounding function returns. This guarantees cleanup runs even if t.Fatal is called or a panic occurs. The library sends a stop signal, waits for the container to exit, and removes the filesystem layers.

Realistic test setup

Real tests need to connect to the database, run queries, and verify results. You typically wrap the container setup in a helper function to avoid repeating boilerplate. Go tests follow a convention where helper functions call t.Helper() so the test runner reports failures on the actual assertion line, not the helper line.

package main

import (
	"context"
	"database/sql"
	"fmt"
	"testing"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
	_ "github.com/lib/pq"
)

// setupDB spins up a PostgreSQL container and returns a connected sql.DB
func setupDB(t *testing.T) *sql.DB {
	t.Helper()
	ctx := context.Background()
	req := testcontainers.ContainerRequest{
		Image:        "postgres:15",
		ExposedPorts: []string{"5432/tcp"},
		Env: map[string]string{
			"POSTGRES_USER":     "test",
			"POSTGRES_PASSWORD": "test",
			"POSTGRES_DB":       "test",
		},
		WaitingFor: wait.ForLog("database system is ready to accept connections"),
	}
	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		t.Fatal(err)
	}
	// Cleanup runs when the test function returns
	t.Cleanup(func() {
		_ = container.Terminate(ctx)
	})

	port, err := container.MappedPort(ctx, "5432")
	if err != nil {
		t.Fatal(err)
	}
	// Build a standard PostgreSQL connection string
	dsn := fmt.Sprintf("host=localhost port=%d user=test password=test dbname=test sslmode=disable", port.Port())
	db, err := sql.Open("postgres", dsn)
	if err != nil {
		t.Fatal(err)
	}
	// Verify the connection actually works before returning
	if err := db.PingContext(ctx); err != nil {
		t.Fatal(err)
	}
	return db
}

func TestUserCreation(t *testing.T) {
	db := setupDB(t)
	// Close the DB connection pool when the test finishes
	defer db.Close()

	// Create a table for the test
	_, err := db.ExecContext(context.Background(), "CREATE TABLE users (id serial PRIMARY KEY, name text)")
	if err != nil {
		t.Fatal(err)
	}

	// Insert a row and verify it exists
	_, err = db.ExecContext(context.Background(), "INSERT INTO users (name) VALUES ($1)", "alice")
	if err != nil {
		t.Fatal(err)
	}
	var name string
	err = db.QueryRowContext(context.Background(), "SELECT name FROM users WHERE id = $1", 1).Scan(&name)
	if err != nil {
		t.Fatal(err)
	}
	if name != "alice" {
		t.Fatalf("expected alice, got %s", name)
	}
}

The helper uses t.Cleanup instead of defer. Both achieve the same result, but t.Cleanup is the modern test convention. It registers a function that runs after the test completes, regardless of pass or fail status. The library's Terminate method returns an error, but the cleanup function ignores it. Logging a cleanup error inside a test teardown usually clutters the output without changing the test result.

The test itself opens a connection pool with sql.Open. The driver name postgres comes from the lib/pq import. The blank identifier _ tells the compiler you are importing the package for its side effects, not to use its exported names directly. This is standard Go practice for database drivers. The test creates a table, inserts a row, queries it back, and asserts the value. Every database call uses Context methods. This respects the cancellation convention and prevents goroutine leaks if the test times out.

Common pitfalls and runtime failures

Docker must be running on the machine executing the tests. The library communicates with the Docker daemon via a Unix socket or TCP endpoint. If the daemon is stopped, GenericContainer returns an error like Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?. The test fails immediately. CI environments usually include Docker, but local development requires the user to start the service.

Wait strategies are the most common source of flaky tests. If you omit WaitingFor, the container starts and the test proceeds instantly. The database engine is still initializing. Your connection attempt fails with a dial error or a connection refused panic. If you use wait.ForListeningPort("5432/tcp"), the TCP port opens before the database accepts queries. You get a connection that immediately drops. Log-based waits are more reliable for databases because they wait for the application layer to report readiness.

Forgetting cleanup causes resource leaks. If you remove the defer or t.Cleanup call, the container keeps running after the test finishes. Run the test suite ten times and you have ten zombie containers consuming disk space and port mappings. The Docker daemon will eventually run out of resources or fail to map new ports. Always attach cleanup to the test lifecycle.

Context timeouts interact with container startup. If you pass a context with a short deadline, the library cancels the startup sequence when the deadline passes. You get a context deadline exceeded error. Database images can take ten to thirty seconds to pull and initialize on slow networks. Set reasonable timeouts or use context.Background() for test fixtures.

The compiler enforces strict type checking on connection strings. If you pass a string where an integer is expected, you get cannot use "5432" (untyped string constant) as int value in argument. Port mapping returns a testcontainers.Port struct, not a string. Call .Port() to get the integer, then format it into your DSN.

When to reach for containers versus alternatives

Use testcontainers when your test requires a real external service like a database, message broker, or cache, and you want reproducible isolation across machines. Use a mock or stub when you only need to verify that your code calls an interface correctly, and the actual behavior of the dependency is irrelevant to the test goal. Pick a shared local development database when you are writing quick scripts or debugging interactively, and you do not need parallel test execution. Rely on CI-managed service containers when your pipeline already provisions infrastructure, and you want to avoid the overhead of pulling images for every test run.

Containers are heavy. They add startup time to your test suite. Run them only for integration tests, not for fast unit tests. Keep the number of containers per test low. Reuse containers across multiple test functions when the setup cost outweighs the isolation benefit, but reset the state between tests.

Where to go next

Test containers are infrastructure, not magic. Define the wait strategy, respect the context, and always clean up.