golang-testcontainers
Go Integration Testing with Testcontainers
When to Use This Skill
- Writing integration tests that need real infrastructure (databases, caches, message queues)
- Testing data access layers against actual databases instead of mocks
- Verifying message queue or cache integrations
- Testing database migrations and schema changes
- Ensuring tests work against production-like environments in CI/CD
Core Principles
- Real Infrastructure Over Mocks - Use actual databases/services in containers, not mocks
- Test Isolation - Each test gets fresh containers or clean data via snapshots
- Automatic Cleanup -
testcontainers.CleanupContainer(t, ctr)handles lifecycle - Idiomatic Go - Table-driven tests,
t.Helper(),t.Cleanup(), subtests - Context Propagation - Pass
context.Contextto all container operations - Port Randomization - Containers use random ports to avoid conflicts
Reference Guide
Load detailed guidance based on context:
| Topic | Reference | Load When |
|---|---|---|
| Advanced Patterns | references/advanced-patterns.md |
Multi-container networks, Kafka, snapshots, TestMain, CI/CD |
Go Module Setup
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
go get github.com/testcontainers/testcontainers-go/modules/mysql
go get github.com/testcontainers/testcontainers-go/modules/redis
go get github.com/testcontainers/testcontainers-go/modules/rabbitmq
go get github.com/testcontainers/testcontainers-go/modules/kafka
Why Testcontainers Over Mocks?
// BAD: Mocking a database - doesn't test real SQL behavior
type mockDB struct{}
func (m *mockDB) GetUser(id string) (*User, error) {
return &User{ID: id, Name: "Alice"}, nil // No real query executed
}
// GOOD: Test against a real database with testcontainers
func TestGetUser(t *testing.T) {
ctx := context.Background()
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
postgres.BasicWaitStrategies(),
)
testcontainers.CleanupContainer(t, ctr)
require.NoError(t, err)
connStr, err := ctr.ConnectionString(ctx)
require.NoError(t, err)
db, err := sql.Open("pgx", connStr)
require.NoError(t, err)
defer db.Close()
// Test real SQL queries, constraints, and behavior
}
Pattern 1: PostgreSQL Integration Tests
package repository_test
import (
"context"
"database/sql"
"testing"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
_ "github.com/jackc/pgx/v5/stdlib"
)
func setupPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
postgres.BasicWaitStrategies(),
)
testcontainers.CleanupContainer(t, ctr)
require.NoError(t, err)
connStr, err := ctr.ConnectionString(ctx)
require.NoError(t, err)
db, err := sql.Open("pgx", connStr)
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
// Run migrations
_, err = db.ExecContext(ctx, `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)`)
require.NoError(t, err)
return db
}
func TestUserRepository(t *testing.T) {
db := setupPostgres(t)
repo := NewUserRepository(db)
t.Run("Create", func(t *testing.T) {
err := repo.Create(context.Background(), &User{Name: "Alice", Email: "alice@test.com"})
require.NoError(t, err)
})
t.Run("GetByEmail", func(t *testing.T) {
user, err := repo.GetByEmail(context.Background(), "alice@test.com")
require.NoError(t, err)
require.Equal(t, "Alice", user.Name)
})
}
Pattern 2: MySQL Integration Tests
func setupMySQL(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
ctr, err := mysql.Run(ctx, "mysql:8.0.36",
mysql.WithDatabase("testdb"),
mysql.WithUsername("test"),
mysql.WithPassword("test"),
)
testcontainers.CleanupContainer(t, ctr)
require.NoError(t, err)
connStr, err := ctr.ConnectionString(ctx)
require.NoError(t, err)
db, err := sql.Open("mysql", connStr)
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
return db
}
Pattern 3: Redis Integration Tests
package cache_test
import (
"context"
"testing"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
)
func setupRedis(t *testing.T) *redis.Client {
t.Helper()
ctx := context.Background()
ctr, err := tcredis.Run(ctx, "redis:7")
testcontainers.CleanupContainer(t, ctr)
require.NoError(t, err)
endpoint, err := ctr.Endpoint(ctx, "")
require.NoError(t, err)
client := redis.NewClient(&redis.Options{Addr: endpoint})
t.Cleanup(func() { client.Close() })
return client
}
func TestCacheService(t *testing.T) {
client := setupRedis(t)
cache := NewCacheService(client)
ctx := context.Background()
t.Run("SetAndGet", func(t *testing.T) {
err := cache.Set(ctx, "key1", "value1", 0)
require.NoError(t, err)
val, err := cache.Get(ctx, "key1")
require.NoError(t, err)
require.Equal(t, "value1", val)
})
t.Run("GetMiss", func(t *testing.T) {
_, err := cache.Get(ctx, "nonexistent")
require.ErrorIs(t, err, ErrCacheMiss)
})
}
Pattern 4: RabbitMQ Integration Tests
package messaging_test
import (
"context"
"testing"
amqp "github.com/rabbitmq/amqp091-go"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/rabbitmq"
)
func setupRabbitMQ(t *testing.T) *amqp.Connection {
t.Helper()
ctx := context.Background()
ctr, err := rabbitmq.Run(ctx, "rabbitmq:3-management-alpine",
rabbitmq.WithAdminUsername("guest"),
rabbitmq.WithAdminPassword("guest"),
)
testcontainers.CleanupContainer(t, ctr)
require.NoError(t, err)
endpoint, err := ctr.AmqpURL(ctx)
require.NoError(t, err)
conn, err := amqp.Dial(endpoint)
require.NoError(t, err)
t.Cleanup(func() { conn.Close() })
return conn
}
func TestPublishAndConsume(t *testing.T) {
conn := setupRabbitMQ(t)
ctx := context.Background()
ch, err := conn.Channel()
require.NoError(t, err)
defer ch.Close()
q, err := ch.QueueDeclare("test-queue", false, true, false, false, nil)
require.NoError(t, err)
// Publish
body := []byte("hello")
err = ch.PublishWithContext(ctx, "", q.Name, false, false, amqp.Publishing{
ContentType: "text/plain",
Body: body,
})
require.NoError(t, err)
// Consume
msgs, err := ch.Consume(q.Name, "", true, false, false, false, nil)
require.NoError(t, err)
msg := <-msgs
require.Equal(t, body, msg.Body)
}
Pattern 5: Generic Container
For services without a dedicated module:
func setupMinio(t *testing.T) string {
t.Helper()
ctx := context.Background()
ctr, err := testcontainers.Run(ctx, "minio/minio:latest",
testcontainers.WithExposedPorts("9000/tcp"),
testcontainers.WithEnv(map[string]string{
"MINIO_ROOT_USER": "minioadmin",
"MINIO_ROOT_PASSWORD": "minioadmin",
}),
testcontainers.WithCmd("server", "/data"),
testcontainers.WithWaitStrategy(
wait.ForListeningPort("9000/tcp"),
),
)
testcontainers.CleanupContainer(t, ctr)
require.NoError(t, err)
endpoint, err := ctr.Endpoint(ctx, "")
require.NoError(t, err)
return endpoint
}
Pattern 6: Table-Driven Integration Tests
Combine testcontainers with Go's table-driven test pattern:
func TestOrderRepository_Create(t *testing.T) {
db := setupPostgres(t)
repo := NewOrderRepository(db)
tests := []struct {
name string
order Order
wantErr bool
}{
{
name: "valid order",
order: Order{CustomerID: "CUST1", Total: 99.99},
},
{
name: "missing customer ID",
order: Order{Total: 50.00},
wantErr: true,
},
{
name: "negative total",
order: Order{CustomerID: "CUST2", Total: -10.00},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := repo.Create(context.Background(), &tt.order)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotZero(t, tt.order.ID)
})
}
}
Pattern 7: Shared Container with TestMain
Reuse a single container across all tests in a package for speed:
package repository_test
var testDB *sql.DB
func TestMain(m *testing.M) {
ctx := context.Background()
ctr, err := postgres.Run(ctx, "postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
postgres.BasicWaitStrategies(),
)
if err != nil {
log.Fatal(err)
}
connStr, err := ctr.ConnectionString(ctx)
if err != nil {
log.Fatal(err)
}
testDB, err = sql.Open("pgx", connStr)
if err != nil {
log.Fatal(err)
}
// Run migrations
runMigrations(testDB)
code := m.Run()
testDB.Close()
testcontainers.TerminateContainer(ctr)
os.Exit(code)
}
func TestWithSharedDB(t *testing.T) {
// Use testDB directly - container is shared across all tests
repo := NewUserRepository(testDB)
// ...
}
Best Practices
- Use
testcontainers.CleanupContainer(t, ctr)- Automatic cleanup tied to test lifecycle - Use
t.Helper()- Mark setup functions as helpers for clean stack traces - Use
t.Cleanup()- Register deferred cleanup for connections and clients - Prefer module APIs -
postgres.Run(),tcredis.Run()over generic containers - Random ports always - Never bind fixed ports; use
Endpoint()orConnectionString() - Share containers with
TestMain- One container per package, not per test - Table-driven tests - Combine with real infrastructure for comprehensive coverage
- Context propagation - Pass
context.Background()to container operations - Race detector - Always run integration tests with
go test -race - Build tags - Separate integration tests with
//go:build integration
Build Tag Separation
//go:build integration
package repository_test
// These tests only run with: go test -tags=integration ./...
Common Issues
| Issue | Solution |
|---|---|
| Container startup timeout | Increase Docker resource limits or use lightweight images (alpine) |
| Port conflicts | Always use random ports via Endpoint() - never fixed ports |
| Tests fail in CI | Ensure CI runner has Docker (ubuntu-latest on GitHub Actions) |
| Slow test suite | Share containers via TestMain instead of per-test containers |
| Flaky connection | Use module-provided wait strategies (postgres.BasicWaitStrategies()) |
| Leaked containers | Always call testcontainers.CleanupContainer(t, ctr) immediately after Run |
More from baotoq/agent-skills
golang-ddd
This skill should be used when implementing DDD tactical design patterns in Go, including Entities, Value Objects, Aggregates, Repositories, Domain Services, Domain Events, Factories, and Specifications.
21dotnet-ddd
Implement Domain-Driven Design tactical patterns in C#/.NET. Use when building Entities, Value Objects, Aggregates, Domain Events, Repositories, or structuring a DDD solution. Framework-agnostic — covers pure domain modeling with modern C#.
13design-ui-ux-pro-max
UI/UX design intelligence. 50 styles, 21 palettes, 50 font pairings, 20 charts, 9 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn/ui). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, mobile app, .html, .tsx, .vue, .svelte. Elements: button, modal, navbar, sidebar, card, table, form, chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, flat design. Topics: color palette, accessibility, animation, layout, typography, font pairing, spacing, hover, shadow, gradient. Integrations: shadcn/ui MCP for component search and examples.
12database-architect
Expert database architect specializing in data layer design from
12frontend-radix-ui-design-system
Build accessible design systems with Radix UI primitives. Headless component customization, theming strategies, and compound component patterns for production-grade UI libraries.
11api-design-principles
Master REST and GraphQL API design principles to build intuitive, scalable, and maintainable APIs that delight developers. Use when designing new APIs, reviewing API specifications, or establishing API design standards.
10