Go Integration Testing with Testcontainers
Unit tests with mocks verify your logic in isolation. Integration tests verify that your code actually works with real dependencies — the database, the cache, the message queue. testcontainers-go spins up real Docker containers during test execution and tears them down when done. No mocks. No test doubles. No "it works on my machine" surprises from a shared dev database.
What Testcontainers Does
testcontainers-go is a Go library that uses the Docker API to start containers as part of your test setup. Each test (or test suite) gets a fresh container. When the test ends, the container is removed. The result is isolation without mocking: your code runs against a real PostgreSQL instance, a real Redis, or any other containerized service.
Requirements: Docker running on the test machine. Works locally and in CI (GitHub Actions, GitLab CI, etc. — most CI providers support Docker-in-Docker or have Docker available).
Installation
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
go get github.com/testcontainers/testcontainers-go/modules/redisStarting a PostgreSQL Container
package db_test
import (
"context"
"database/sql"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
_ "github.com/lib/pq"
)
func startPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
container, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:16-alpine"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2),
),
)
if err != nil {
t.Fatalf("failed to start postgres container: %v", err)
}
t.Cleanup(func() {
if err := container.Terminate(ctx); err != nil {
t.Errorf("failed to terminate container: %v", err)
}
})
connStr, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("failed to get connection string: %v", err)
}
db, err := sql.Open("postgres", connStr)
if err != nil {
t.Fatalf("failed to open DB: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.PingContext(ctx); err != nil {
t.Fatalf("failed to ping DB: %v", err)
}
return db
}This helper starts a Postgres container, registers cleanup with t.Cleanup, and returns a *sql.DB ready to use. Call it from any test.
Running Migrations Before Tests
Real tests need the schema. Apply migrations after the container is up:
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db := startPostgres(t)
migrations := []string{
`CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)`,
`CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
total INT NOT NULL
)`,
}
for _, m := range migrations {
if _, err := db.Exec(m); err != nil {
t.Fatalf("migration failed: %v\nSQL: %s", err, m)
}
}
return db
}For production migration tools (golang-migrate, goose, Atlas), run them programmatically:
import "github.com/golang-migrate/migrate/v4"
func runMigrations(t *testing.T, connStr string) {
t.Helper()
m, err := migrate.New("file://./migrations", connStr)
if err != nil {
t.Fatalf("failed to create migrate instance: %v", err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
t.Fatalf("migration failed: %v", err)
}
}A Real Repository Test
func TestUserRepository(t *testing.T) {
db := setupTestDB(t)
repo := NewUserRepository(db)
ctx := context.Background()
t.Run("create and retrieve user", func(t *testing.T) {
user := &User{Name: "Alice", Email: "alice@example.com"}
created, err := repo.Create(ctx, user)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if created.ID == 0 {
t.Error("expected non-zero ID after create")
}
found, err := repo.FindByID(ctx, created.ID)
if err != nil {
t.Fatalf("FindByID failed: %v", err)
}
if found.Email != user.Email {
t.Errorf("email mismatch: got %q; want %q", found.Email, user.Email)
}
})
t.Run("duplicate email returns error", func(t *testing.T) {
user := &User{Name: "Bob", Email: "bob@example.com"}
_, err := repo.Create(ctx, user)
if err != nil {
t.Fatalf("first create failed: %v", err)
}
_, err = repo.Create(ctx, &User{Name: "Bob2", Email: "bob@example.com"})
if err == nil {
t.Error("expected error for duplicate email, got nil")
}
})
}Starting a Redis Container
import (
"github.com/testcontainers/testcontainers-go/modules/redis"
goredis "github.com/redis/go-redis/v9"
)
func startRedis(t *testing.T) *goredis.Client {
t.Helper()
ctx := context.Background()
container, err := redis.RunContainer(ctx,
testcontainers.WithImage("redis:7-alpine"),
)
if err != nil {
t.Fatalf("failed to start redis: %v", err)
}
t.Cleanup(func() { container.Terminate(ctx) })
endpoint, err := container.Endpoint(ctx, "")
if err != nil {
t.Fatalf("failed to get redis endpoint: %v", err)
}
client := goredis.NewClient(&goredis.Options{Addr: endpoint})
t.Cleanup(func() { client.Close() })
if err := client.Ping(ctx).Err(); err != nil {
t.Fatalf("redis ping failed: %v", err)
}
return client
}Per-Test Isolation with Transactions
Spinning up a container per test is slow. A better pattern: start one container per package (via TestMain), but wrap each test in a transaction that rolls back:
var testDB *sql.DB
func TestMain(m *testing.M) {
ctx := context.Background()
container, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:16-alpine"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
),
)
if err != nil {
log.Fatalf("failed to start postgres: %v", err)
}
defer container.Terminate(ctx)
connStr, _ := container.ConnectionString(ctx, "sslmode=disable")
testDB, err = sql.Open("postgres", connStr)
if err != nil {
log.Fatalf("failed to open DB: %v", err)
}
applyMigrations(testDB)
os.Exit(m.Run())
}
func withTx(t *testing.T) *sql.Tx {
t.Helper()
tx, err := testDB.Begin()
if err != nil {
t.Fatalf("failed to begin tx: %v", err)
}
t.Cleanup(func() { tx.Rollback() })
return tx
}Each test gets a transaction that rolls back on cleanup. Tests see each other's data only within the same transaction — in practice, they're fully isolated.
Using a Generic Container
Not every service has a dedicated testcontainers module. Use the generic API:
func startMinio(t *testing.T) string {
t.Helper()
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "minio/minio:latest",
ExposedPorts: []string{"9000/tcp"},
Env: map[string]string{
"MINIO_ROOT_USER": "minioadmin",
"MINIO_ROOT_PASSWORD": "minioadmin",
},
Cmd: []string{"server", "/data"},
WaitingFor: wait.ForHTTP("/minio/health/live").WithPort("9000/tcp"),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatalf("failed to start minio: %v", err)
}
t.Cleanup(func() { container.Terminate(ctx) })
endpoint, _ := container.Endpoint(ctx, "http")
return endpoint
}WaitingFor is critical — it prevents your test from proceeding before the service is actually ready. Available strategies: wait.ForLog, wait.ForHTTP, wait.ForListeningPort, wait.ForExec.
CI Integration
GitHub Actions with Docker:
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run integration tests
run: go test -v -tags=integration ./...Docker is available on ubuntu-latest runners. testcontainers-go connects to the Docker socket automatically.
Use build tags to separate integration tests from unit tests:
//go:build integration
package db_test# Unit tests only (fast)
go test ./...
# Integration tests included
go test -tags=integration ./...Performance Tips
- Start containers in
TestMain, not per-test — container startup takes 1-5 seconds - Use transaction rollback for isolation instead of truncating tables
- Run integration tests in their own package so they don't slow down unit test feedback loops
- Enable container reuse during development with
testcontainers.WithReuseExisting()— containers aren't terminated between runs
testcontainers-go eliminates the class of bugs that only appear when your code hits a real database. The overhead is worth it for any code that does non-trivial data access.
HelpMeTest complements integration tests by continuously running scenarios against your live deployed environment, catching deployment and configuration issues that container-based tests can't detect.