Go Integration Testing with Testcontainers

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/redis

Starting 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.

Read more

Start now free