Integration Testing với Testcontainers — Test thật, không mock
Vấn đề với mock truyền thống
Mock tốt cho unit test — kiểm tra logic thuần. Nhưng khi test tầng repository, handler, worker với mock:
- Mock database không bắt được
NOT NULL, unique constraint, trigger, transaction rollback - Mock Redis không giống eviction policy, TTL behavior thật
- Mock queue không test được retry behaviour, delivery guarantee
- CI khác máy local — "nó chạy trên máy em mà!" thành câu cửa miệng
Giải pháp: chạy dependency thật trong container, xong tự huỷ.
Testcontainers là gì?
Testcontainers là thư viện spawn Docker container ngay trong test lifecycle. Hỗ trợ Go, Java, Python, .NET...
Cơ chế:
- Test khởi tạo → spawn container (Postgres, Redis, Kafka...)
- App kết nối vào container qua connection string tạm
- Test chạy với dependency thật — không mock
- Test xong → container tự destroy
Không cần Docker Compose, không cần service chạy sẵn. Mỗi package test có database riêng — chạy song song thoải mái.
Ví dụ với Go + Postgres
package postgres_test
import (
"context"
"database/sql"
"testing"
"github.com/docker/go-connections/nat"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
_ "github.com/lib/pq"
)
func setupTestDB(t *testing.T) (*sql.DB, func()) {
t.Helper()
ctx := context.Background()
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_USER": "test",
"POSTGRES_PASSWORD": "test",
"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.Fatalf("failed to start container: %v", err)
}
host, _ := container.Host(ctx)
port, _ := container.MappedPort(ctx, nat.Port("5432/tcp"))
dsn := "host=" + host +
" port=" + port.Port() +
" user=test password=test" +
" dbname=testdb sslmode=disable"
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("failed to connect: %v", err)
}
if err := db.PingContext(ctx); err != nil {
t.Fatalf("failed to ping: %v", err)
}
// Run migrations
_, err = db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
`)
if err != nil {
t.Fatalf("failed to migrate: %v", err)
}
return db, func() {
db.Close()
container.Terminate(ctx)
}
}
func TestInsertUser(t *testing.T) {
db, teardown := setupTestDB(t)
defer teardown()
_, err := db.ExecContext(context.Background(),
`INSERT INTO users (name, email) VALUES ($1, $2)`,
"Alice", "alice@example.com")
if err != nil {
t.Fatalf("insert failed: %v", err)
}
// Test unique constraint — mock không bắt được
_, err = db.ExecContext(context.Background(),
`INSERT INTO users (name, email) VALUES ($1, $2)`,
"Bob", "alice@example.com")
if err == nil {
t.Error("expected unique constraint error, got nil")
}
}
Với Testcontainers, test được cả NOT NULL constraint, UNIQUE constraint, transaction behavior — y hệt production. Không mock nào làm được.
Test nhiều dependency cùng lúc
func TestHandler(t *testing.T) {
db, teardownDB := setupTestDB(t)
defer teardownDB()
rd, teardownRedis := setupTestRedis(t)
defer teardownRedis()
handler := NewHandler(db, rd)
resp := handler.HandleRequest(...)
// assert kết quả thật với Postgres + Redis thật
}
Chi phí? Lần đầu tầm 3–5 giây (pull image), lần sau <1 giây — image đã cached.
Kinh nghiệm thực tế
1. Reuse container cho nhiều test
testcontainers.WithReuse(true)
Container sống qua nhiều test function — tiết kiệm thời gian khởi động.
2. Dùng wait strategy, không sleep
wait.ForLog("ready for connections") // chờ log
wait.ForHTTP("/health") // chờ HTTP 200
wait.ForListeningPort("5432/tcp") // chờ port mở
time.Sleep vừa chậm vừa fragile — wait strategy là cách đúng.
3. Module test riêng
Đặt integration test trong package riêng (postgres_test, redis_test), dùng build tag để tách với unit test:
//go:build integration
package postgres_test
Chạy: go test -tags=integration ./...
4. CI/CD cần Docker?
GitHub Actions, GitLab Runner, CircleCI đều có Docker sẵn. Nếu máy không có Docker — dùng Testcontainers Cloud (remote container).
Khi nào không nên?
- Business logic thuần — unit test + mock là đủ
- Project 1–2 bảng — SQLite in-memory tạm ổn (nhưng cẩn thận dialect khác)
- Test UI / integration test frontend — không cần database thật
Tổng kết
Testcontainers không làm integration test nhanh hơn — nhưng làm nó đáng tin hơn. Một test thật với Postgres còn giá trị hơn chục mock giả.
"Your tests should run against the same database you use in production." — đại ý từ Building Microservices, Sam Newman
Code mẫu đầy đủ: github.com/testcontainers/testcontainers-go