The test that passes locally and fails in CI
You write a test for your user registration endpoint. It hits a database, inserts a row, and checks the response. On your laptop, the test passes. You push the code. The CI runner fails. The error says the database connection was refused. Or the query syntax is invalid. Or the port is already in use.
The problem is environment drift. Your laptop has Postgres 15 installed. The CI runner has Postgres 12. Or the CI runner has no database at all. Or another test is hogging port 5432. The test depends on the machine, not just the code. You could mock the database, but mocks can lie. A mock returns whatever you tell it to return. It doesn't catch the case where your real query fails because of a type mismatch or a missing index.
Testcontainers solve this by treating infrastructure like a disposable resource. You spin up a real database inside a Docker container, run the test, and destroy the container. The test carries its own environment. It runs the same way on your laptop, in CI, and in a pull request check. The infrastructure is part of the test, not a prerequisite.
Infrastructure as a disposable resource
Think of a mock as a prop. A prop database is a cardboard cutout. It looks like a database from the right angle, but it doesn't have depth. It returns fake data instantly. It's great for checking if your code calls the right method, but it won't tell you if your SQL is broken.
A testcontainer is a stunt double. It's a real database, running in an isolated box, behaving exactly like production. You direct the stunt double for the scene, then send it home. The library testcontainers-go automates the choreography. It talks to the Docker daemon, pulls the image, starts the container, maps the ports, and waits until the service is actually ready to accept connections. When the test finishes, the library tears everything down.
The core idea is simple. Docker provides isolation. The test library provides the lifecycle management. You get a real service with zero setup on the host machine. You don't install Postgres. You don't manage ports. You just request a container and get a connection string back.
Minimal example
Here's the skeleton of a test that spins up a real Postgres instance, waits for it to be ready, and tears it down automatically.
package main
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestPostgresContainer(t *testing.T) {
// Context carries cancellation signals to the container lifecycle.
ctx := context.Background()
// ContainerRequest defines the Docker image and configuration.
req := testcontainers.ContainerRequest{
// Image specifies the Docker image to pull and run.
Image: "postgres:15",
// ExposedPorts tells Docker which ports to map to the host.
ExposedPorts: []string{"5432/tcp"},
// WaitingFor ensures the test waits until the DB logs it is ready.
WaitingFor: wait.ForLog("database system is ready to accept connections"),
}
// GenericContainer starts the container and returns a handle.
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
// Started: true means the container starts immediately.
Started: true,
})
if err != nil {
t.Fatal(err)
}
// t.Cleanup runs when the test finishes, even if it fails.
t.Cleanup(func() {
if err := container.Terminate(ctx); err != nil {
t.Logf("failed to terminate container: %v", err)
}
})
// Endpoint returns the host address and mapped port.
endpoint, err := container.Endpoint(ctx, "")
if err != nil {
t.Fatal(err)
}
t.Logf("Postgres is running at %s", endpoint)
}
The test does three things. It starts the container. It gets the connection endpoint. It schedules cleanup. The t.Cleanup call is the modern Go convention for test teardown. It replaces defer in test functions because it integrates with the test runner's lifecycle and runs in reverse order of registration. If the test panics, t.Cleanup still runs. The container doesn't leak.
How the lifecycle works
When the test calls GenericContainer, the library makes a series of calls to the Docker daemon. First, it checks if the image exists locally. If not, it pulls the image from the registry. Then it creates a container with the specified configuration. Docker assigns a random port on the host and maps it to the exposed port inside the container. This random mapping is intentional. It allows multiple tests to run in parallel without port conflicts.
The container starts. The library begins polling for the wait condition. In the example, it checks the container logs for the string database system is ready to accept connections. The library reads the logs in real time. Once the string appears, the wait strategy succeeds. The GenericContainer call returns. The test now has a Container object.
You call container.Endpoint to get the connection string. The library resolves the host IP and the mapped port. It returns a string like localhost:32768. You use this string to connect your code. The container runs in the background. Your test interacts with it like a normal service.
When the test function returns, the cleanup function runs. It calls container.Terminate. The library sends a stop signal to Docker. Docker stops the container and removes it. The resources are reclaimed. The next test starts fresh.
Realistic integration test
Here's a test that connects to the container, runs a query, and verifies the result. It uses the database/sql package and a real connection.
func TestUserInsertion(t *testing.T) {
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "postgres:15",
ExposedPorts: []string{"5432/tcp"},
// Env sets environment variables inside the container.
Env: map[string]string{
"POSTGRES_USER": "testuser",
"POSTGRES_PASSWORD": "testpass",
"POSTGRES_DB": "testdb",
},
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)
}
t.Cleanup(func() {
_ = container.Terminate(ctx)
})
endpoint, err := container.Endpoint(ctx, "")
if err != nil {
t.Fatal(err)
}
// DSN constructs the connection string from the endpoint.
dsn := fmt.Sprintf("host=%s user=testuser password=testpass dbname=testdb sslmode=disable", endpoint)
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = db.Close()
})
// Ping verifies the connection is alive before running queries.
if err := db.Ping(ctx); err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(ctx, "CREATE TABLE users (id serial PRIMARY KEY, name text)")
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(ctx, "INSERT INTO users (name) VALUES ($1)", "Alice")
if err != nil {
t.Fatal(err)
}
var name string
err = db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", 1).Scan(&name)
if err != nil {
t.Fatal(err)
}
if name != "Alice" {
t.Errorf("expected Alice, got %s", name)
}
}
The test sets environment variables to configure Postgres. It builds the DSN dynamically using the mapped endpoint. It creates a table, inserts a row, and reads it back. The db.Ping call is a safety check. It ensures the connection is established before the test logic runs. If the container is slow to start, Ping fails fast and reports a clear error. The cleanup functions close the database connection and terminate the container.
Pitfalls and error messages
Testcontainers make tests robust, but they introduce new failure modes. The most common issue is Docker access. The library talks to the Docker daemon via a socket. If Docker isn't running, the test fails immediately. The compiler or runtime reports Cannot connect to the Docker daemon at unix:///var/run/docker.sock. In CI, you need to ensure the Docker socket is available. Many CI systems support Docker-in-Docker or provide a socket mount.
Port conflicts can still happen if you hardcode ports. Never hardcode the host port in your test. Always use container.Endpoint or container.MappedPort. If you force a specific host port and two tests run in parallel, one will fail with Bind for 0.0.0.0:5432 failed: port is already allocated. The random port mapping prevents this. Let the library assign the port.
Wait strategies are critical. If you connect before the service is ready, you get a connection error. The error looks like dial tcp 127.0.0.1:32768: connect: connection refused. The wait package provides strategies for logs, HTTP health endpoints, and TCP ports. Choose the strategy that matches the service. For databases, log waiting is usually reliable. For HTTP services, health endpoints are better.
Resource limits can cause failures in constrained environments. Containers consume memory and CPU. If you run too many tests in parallel, the CI runner might run out of memory. The Docker daemon kills containers to protect the host. You might see container killed due to OOM. Reduce parallelism or increase resource limits in CI.
When to use testcontainers
Use testcontainers when you need to verify integration with external services like databases, message queues, or caches. Use a mock when you want to test business logic in isolation without the overhead of starting infrastructure. Use a shared test database when you are running a small suite of tests locally and want faster feedback, accepting the risk of state leakage. Use a stub when the external service is slow or expensive, and you only need to verify the call signature.
Testcontainers add startup time. A container takes seconds to pull and start. If you have hundreds of fast unit tests, adding containers slows the feedback loop. Reserve containers for integration tests. Keep unit tests fast and isolated. Run integration tests in a separate stage or with a longer timeout.
The library also provides modules for common services. Instead of GenericContainer, you can use testcontainers-go/modules/postgres. The module handles image selection, environment variables, and wait strategies for you. It simplifies the code and reduces boilerplate. Check if a module exists for your service before writing a generic request.
Where to go next
- How to Measure Test Coverage in Go
- How to Use gomock for Generating Mock Objects
- How to Use t.Setenv for Environment Variables in Tests
Mocks lie. Containers tell the truth. Infrastructure is code. Treat your test environment the same way.