Integration Testing với Testcontainers — Test thật, không mock

Phong Hy

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ế:

  1. Test khởi tạo → spawn container (Postgres, Redis, Kafka...)
  2. App kết nối vào container qua connection string tạm
  3. Test chạy với dependency thật — không mock
  4. 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