Modern development is built on containers. We ship our CLIs, microservices, and distributed systems in them. But how do we properly test them? How do we validate that our Go application, which relies on services like Postgres, Redis, or Kafka, actually works before it hits production?
Mocking external services is a common approach, but it’s often a pale imitation of the real thing, leading to tests that pass in CI but fail in production. Manually starting services with docker-compose before running tests is tedious, error-prone, and doesn’t scale.
This is the problem that testcontainers-go solves.
In this guide, we’ll explore how to leverage testcontainers-go to programmatically manage real Docker containers from your Go tests. We’ll go over some basic principles and patterns, creating powerful, reproducible, and isolated integration test environments.
🧪 This is about building a robust testing infrastructure. We’re enabling your tests to run against real dependencies, ensuring your application is validated in an environment that mirrors production.
🤔 What Is Testcontainers and Why Should You Care?
Testcontainers is a library that provides a programmatic API to spin up and manage ephemeral, lightweight Docker containers. Think of it as docker-compose for your test suite, but controlled entirely from your Go code.
It solves the chronic pains of integration testing:
- – “It works on my machine!”: By defining dependencies as code, you guarantee that every developer and every CI run uses the exact same services (e.g., postgres:16-alpine, redis:7-alpine).
– Fragile Mocks: Mocks don’t always behave like the real service. They don’t have the same latency, error modes, or configuration quirks. Testcontainers uses the actual service, so you’re testing against reality.
– Manual Test Setup: No more `docker-compose up -d` before running tests and `docker-compose down` afterward. The lifecycle is automated.
– Resource Conflicts: Testcontainers intelligently maps to random available host ports, so you can run tests in parallel without port conflicts.
– Automatic Cleanup: It uses a sidecar container (called Ryuk) to ensure all containers, networks, and volumes created during the test run are automatically destroyed afterward, even if the tests crash.
In short, it allows you to write high-fidelity integration tests that are both reliable and easy to manage.
⚙️ Installation and Setup
Getting started is straightforward. First, install the library:
```bash
go get github.com/testcontainers/testcontainers-go
```
The only prerequisite is a running Docker environment. Testcontainers will automatically detect and use the Docker daemon on your local machine or in your CI environment.
🔬 The Testcontainers Lifecycle: What Happens Under the Hood?
When you ask testcontainers-go to run a container, a simple sequence of events unfolds:
1. Request: You define a ContainerRequest in your Go test, specifying the image, exposed ports, environment variables, and a “wait strategy.”
2. Docker Interaction: Testcontainers communicates with the Docker daemon to pull the image if it’s not present locally.
3. Creation & Startup: It creates a new container with your specified configuration, including mapping any exposed ports to random, free ports on the host.
4. Waiting Game: This is the crucial part. Testcontainers doesn’t just start the container; it waits for it to be ready. The wait strategy (e.g., wait.ForListeningPort, wait.ForLog) polls the container until a condition is met. This prevents flaky tests where you try to connect to a service before it has fully initialized.
5. Test Execution: Once the container is ready, control is returned to your test, which can now connect to the service using the dynamically assigned host and port.
6. Termination: Using a `defer container.Terminate(ctx)` statement, you ensure that once your test function completes, the container (and any associated resources) is automatically shut down and removed.
🧪 Example 1: Testing Your Own Dockerized CLI
Let’s start with a practical use case: testing a Go CLI tool that you’ve packaged into a Docker image. The CLI processes files in a directory mounted into the container.
🧱 Project Structure
mycli/
├── cmd/
│ └── main.go
├── Dockerfile
├── test/
│ └── integration_test.go
├── testdata/
│ └── sample.txt
└── go.mod
🐳 A Powerful Pattern: Building the Image On-the-Fly
Instead of manually building the image (`docker build -t mycli .`) before the test, we can ask Testcontainers to build it for us directly from the Dockerfile. This is a nice pattern for testing the application itself, as it ensures the image is always up-to-date with your source code.
Our test will now look like this:
```go
// test/integration_test.go
package test
import (
"context"
"io"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
tc "github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestCLIToolProcessesFile(t *testing.T) {
ctx := context.Background()
// Define the request, building the image from our Dockerfile
req := tc.ContainerRequest{
// FromDockerfile builds the image and gives it a random name.
FromDockerfile: tc.FromDockerfile{
Context: "../", // The path to the build context
Dockerfile: "Dockerfile",
},
Cmd: []string{"/data/sample.txt"},
BindMounts: map[string]string{ // Mount our test data
"./testdata": "/data",
},
// The container will run a short-lived command and exit.
// We wait for that exit event.
WaitingFor: wait.ForExit().WithDeadline(20 * time.Second),
}
container, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{
ContainerRequest: req,
Started: true, // Start the container immediately
})
require.NoError(t, err)
// Cleanup
defer func() {
if err := container.Terminate(ctx); err != nil {
t.Fatalf("failed to terminate container: %s", err)
}
}()
// Retrieve the container logs to verify the output.
logs, err := container.Logs(ctx)
require.NoError(t, err)
output, err := io.ReadAll(logs)
require.NoError(t, err)
// Assert that the output is what we expect.
expectedOutput := "Processing file: /data/sample.txt"
require.Contains(t, string(output), expectedOutput, "CLI output did not match")
}
```
This test now provides an end-to-end validation: it builds the real Docker image from source and runs it with a real file system bind mount, asserting on its actual output.
⚡️ Example 2: Testing Against Real Services with Modules
While GenericContainer is powerful, with Testcontainers we can also run Modules—pre-configured wrappers for popular services like Postgres, Redis, and Kafka. These modules simplify setup with sensible defaults and helper functions.
Let’s test a Go function that interacts with Redis.
First, add the Redis module and a client library:
```bash
go get github.com/testcontainers/testcontainers-go/modules/redis
go get github.com/redis/go-redis/v9
```
The test would look like this:
```go
package test
import (
"context"
"testing"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
redis_module "github.com/testcontainers/testcontainers-go/modules/redis"
)
func TestWithRedis(t *testing.T) {
ctx := context.Background()
// Use the Redis module to spin up a Redis container
redisContainer, err := redis_module.Run(ctx, "redis:7-alpine")
require.NoError(t, err)
defer func() {
require.NoError(t, redisContainer.Terminate(ctx))
}()
// Get the dynamic connection string
connStr, err := redisContainer.ConnectionString(ctx)
require.NoError(t, err)
// --- Your Application Logic ---
// Connect a real Redis client to the container
opts, err := redis.ParseURL(connStr)
require.NoError(t, err)
redisClient := redis.NewClient(opts)
// Test the connection
pong, err := redisClient.Ping(ctx).Result()
require.NoError(t, err)
require.Equal(t, "PONG", pong)
// Now, test your actual application logic
err = redisClient.Set(ctx, "mykey", "hello-testcontainers", 0).Err()
require.NoError(t, err)
val, err := redisClient.Get(ctx, "mykey").Result()
require.NoError(t, err)
require.Equal(t, "hello-testcontainers", val)
}
```
This demonstrates a complete, runnable test that validates logic against a real Redis instance, not a mock. The same pattern applies to Postgres, Kafka, or any other supported module.
🌐 Example 3: Connecting Multiple Containers with a Network
What if your application needs to talk to a database? You can create a dedicated Docker network for your tests and attach multiple containers to it. They can then communicate using their container names as hostnames.
Here’s how you’d set up a test for an application that needs to connect to a Postgres database.
```go
package test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
tc "github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/network"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestAppWithDatabase(t *testing.T) {
ctx := context.Background()
// 1. Create a network
network, err := network.New(ctx)
require.NoError(t, err)
defer func() {
require.NoError(t, network.Remove(ctx))
}()
// 2. Start a Postgres container on the network
pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("test-db"),
postgres.WithUsername("user"),
postgres.WithPassword("password"),
network.WithNetwork([]string{"postgres"}, network.Name), // The aliases of the container
)
require.NoError(t, err)
defer func() { require.NoError(t, pgContainer.Terminate(ctx)) }()
// Our app can connect to "postgres:5432" inside the network
dbURL := "postgres://user:password@postgres:5432/test-db?sslmode=disable"
// 3. Start our application container on the same network
appContainer, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{
ContainerRequest: tc.ContainerRequest{
FromDockerfile: tc.FromDockerfile{Context: "../", Dockerfile: "Dockerfile"},
Env: map[string]string{
"DATABASE_URL": dbURL, // Pass the DB connection string
},
ExposedPorts: []string{"8080/tcp"},
WaitingFor: wait.ForHTTP("/health").WithStatusCode(200),
Networks: []string{network.Name},
},
Started: true,
})
require.NoError(t, err)
defer func() { require.NoError(t, appContainer.Terminate(ctx)) }()
// Now you can make requests to your application container
// and assert that it correctly interacts with the Postgres container.
host, _ := appContainer.Host(ctx)
port, _ := appContainer.MappedPort(ctx, "8080")
fmt.Printf("Application running at: http://%s:%s\n", host, port.Port())
}
```
✅ Best Practices for Robust Tests
| Practice | Why It Matters |
|---|---|
Always defer container.Terminate(ctx) |
It guarantees resource cleanup, preventing orphaned containers from cluttering your system during the tests. |
| Use Modules When Possible | Modules (postgres, redis, etc.) provide a simpler, higher-level API with sensible defaults and helper methods. |
| Wait Strategies | Don’t rely on time.Sleep. Use specific strategies like wait.ForListeningPort, wait.ForLog, wait.ForHTTP, or wait.ForSQL to avoid race conditions and create resilient tests. |
| Build Images from Dockerfile | Use tc.FromDockerfile to test the exact artifact you’ll ship, eliminating drift between your source and the tested image. |
⚠️ Common Pitfalls to Avoid
- Test Hangs or Times Out: You probably forgot or misconfigured a `wait.For…` strategy. The container is running, but your test doesn’t know when it’s ready. Check the container logs for clues.
- “Connection Refused”: Either your wait strategy is insufficient (the port isn’t ready yet), or you’re using the wrong host/port.
- Tests Fail in CI but Pass Locally: This often points to a Docker-in-Docker (DinD) issue. Ensure your CI runner has access to a Docker socket or is running in a privileged mode that allows it to manage containers.
- Volume Mounts Fail: Use absolute paths or be very careful with relative paths. `filepath.Abs` can be your friend here. Ensure the source files and directories exist.
🤖 Running in CI (e.g., GitHub Actions)
Setting up testcontainers-go in CI is simple. You just need to ensure Docker is available. Here’s a typical GitHub Actions workflow:
jobs:integration-test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: actions/setup-go@v5with:go-version: '1.22'
# No special Docker setup needed on standard GitHub runners
- name: Run integration tests
run: go test -v ./...
🔚 Conclusion
testcontainers-go empowers you to build high-confidence test suites that validate your application against its real dependencies. By codifying your test infrastructure, you eliminate manual steps, prevent environment drift, and catch integration bugs long before they reach production.
No mocks. No pre-baked assumptions. Just real containers, real interactions, and reliable results.
🔗 Further Reading
- Official testcontainers-go Site: The definitive source for documentation.
- Modules Documentation: Browse available modules for popular services.
- Wait Strategies: A deep dive into making your tests more reliable.
- Official Examples: A repository of practical examples.