Testcontainers — integration test với database thật, không cần mock
Hồi mới đi làm, mình từng nghĩ "mock database là đủ rồi, cần gì phải chạy thật cho mệt". Ai ngờ lên production gặp đủ thứ chuyện — câu SQL viết sai syntax không ai phát hiện, transaction không rollback đúng, unique constraint báo lỗi mà test vẫn xanh.
Vấn đề là mock không bao giờ chạy giống database thật. Nó không có index, không có transaction isolation, không có constraint, không có locking behavior. Test thì xanh mà production thì đỏ — câu chuyện muôn thuở.
Testcontainers ra đời để giải quyết chuyện đó. Và nó thay đổi hoàn toàn cách team mình viết test.
Testcontainers là gì?
Testcontainers là thư viện cho phép bạn chạy Docker container tạm thời ngay trong test. Bạn viết code khai báo "tôi cần một PostgreSQL 16", Testcontainers sẽ lo phần còn lại:
- Pull image (nếu máy chưa có)
- Start container với port random — tránh conflict
- Cung cấp connection string cho test của bạn
- Tự động clean up sau khi test xong
Nó hỗ trợ đa ngôn ngữ: Java, Go, Python, Node.js, .NET, Rust. Module riêng cho PostgreSQL, Redis, Kafka, RabbitMQ, MySQL, và cả LocalStack (mock AWS).
Code example với Go
Đây là một integration test đơn giản dùng Testcontainers cho Go:
package postgres_test
import (
"context"
"database/sql"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
_ "github.com/lib/pq"
)
func TestUserRepository(t *testing.T) {
ctx := context.Background()
// Spin up PostgreSQL 16 Alpine
pg, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
Env: map[string]string{
"POSTGRES_DB": "testdb",
"POSTGRES_PASSWORD": "testpass",
"POSTGRES_USER": "testuser",
},
ExposedPorts: []string{"5432/tcp"},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
},
Started: true,
})
if err != nil {
t.Fatal(err)
}
defer pg.Terminate(ctx)
// Lấy connection string
host, _ := pg.Host(ctx)
port, _ := pg.MappedPort(ctx, "5432")
dsn := "host=" + host + " port=" + port.Port() +
" user=testuser password=testpass dbname=testdb sslmode=disable"
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Migration
_, err = db.Exec(`CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)`)
if err != nil {
t.Fatal(err)
}
// Insert & Query — test vô tư với database thật
_, err = db.Exec(`INSERT INTO users (name, email) VALUES ($1, $2)`,
"Phong Hy", "phong@hy.dev")
if err != nil {
t.Fatal(err)
}
var name string
err = db.QueryRow("SELECT name FROM users WHERE email = $1",
"phong@hy.dev").Scan(&name)
if err != nil {
t.Fatal(err)
}
if name != "Phong Hy" {
t.Errorf("expected Phong Hy, got %s", name)
}
}
Chỉ vậy thôi! Container tự động chạy, test xong tự dọn. Không cần mock, không cần setup external database thủ công. Chạy go test -v là thấy container nó tự động start lên.
Kinh nghiệm thực tế sau gần một năm
Team mình chuyển qua dùng Testcontainers được gần một năm rồi, rút ra mấy cái này:
1. Xài Alpine image cho nhẹ
postgres:16-alpine chỉ ~200MB so với bản full hơn 400MB. Redis alpine cũng chỉ 30MB. Pull lần đầu thì lâu nhưng lần sau Docker cache rồi, chạy rất nhanh.
2. Reuse container trong cùng package
Với Go, đừng tạo container mới cho mỗi test function. Dùng TestMain hoặc sync.Once để khởi tạo một container chung, share connection qua các test. Mỗi test có thể tự setup/dọn dẹp dữ liệu trong transaction riêng.
func TestMain(m *testing.M) {
pg, _ = setupPostgres()
code := m.Run()
pg.Terminate(context.Background())
os.Exit(code)
}
Khởi động container mất 2–3 giây — share được là tiết kiệm thời gian cực kỳ.
3. Set timeout hợp lý
Mặc định Testcontainers chờ 60 giây. Nếu máy CI lần đầu pull image hoặc Docker hub chậm, có thể bị timeout. Set thành 120–180 giây:
ContainerRequest{
// ...
StartupTimeout: 2 * time.Minute,
}
4. CI/CD cần Docker
Testcontainers cần Docker runtime. Trên GitHub Actions, GitLab CI, CircleCI đều hỗ trợ Docker-in-Docker hoặc docker-persist. Cơ bản là setup docker service trong CI pipeline là OK.
5. Biết giới hạn của integration test
Integration test với database thật rất mạnh, nhưng đừng lạm dụng. Dùng nó để test tương tác với database — SQL queries, migrations, transaction behavior. Business logic và edge cases vẫn nên cover bằng unit test thuần (nhanh hơn, deterministic hơn).
Thử nhé
Trước đây team mình từng deploy 3 lần trong một tuần vì bug không catch được bằng unit test — toàn mấy lỗi SQL syntax, unique constraint violation, transaction không rollback. Từ ngày áp dụng Testcontainers, chuyện đó gần như không còn nữa.
Integration test với database thật không còn là chuyện "lằng nhằng, ít người làm". Testcontainers làm nó đơn giản tới mức bạn không có lý do gì để không xài. Cài Docker, thêm thư viện, viết test — thế là xong.
Có bạn nào trong team cũng đang dùng Testcontainers chưa? Kinh nghiệm thế nào, chia sẻ dưới comment nhen 👇