Bài trước về mock (và các kỹ thuật test) có một điểm yếu chung: mock chỉ giả lập hành vi mà bạn nghĩ dịch vụ thật có. Bạn mock database trả về đúng cái bạn tưởng tượng — nhưng bug thật thường nằm chính ở khác biệt giữa mock và thực tế: cú pháp SQL sai với phương ngữ cụ thể, kiểu dữ liệu không khớp, hành vi transaction bất ngờ, ràng buộc unique, lỗi kết nối. Mock không bao giờ dạy bạn những điều đó.
testcontainers-go giải bằng cách dựng dịch vụ thật trong Docker ngay trong test: một container PostgreSQL, Redis, Kafka... đúng phiên bản production, khởi động lúc test bắt đầu và tự dọn khi xong. Bài này trình bày cơ chế, và — vì môi trường đo (go-lab) không có Docker socket — tôi báo cáo trung thực giới hạn đó, đồng thời chạy một integration test tương đương chống dịch vụ thật để minh họa kết quả.
Vì sao: mock nói dối, integration test nói thật
// Mock database/Redis chỉ giả LẬP hành vi bạn NGHĨ chúng có.
// Bug thật thường nằm ở khác biệt giữa mock và dịch vụ thật:
// cú pháp SQL, kiểu dữ liệu, hành vi transaction, lỗi mạng...
// testcontainers: dựng dịch vụ THẬT trong Docker ngay trong test.
Dựng container thật rồi dọn tự động
API cốt lõi của testcontainers-go: mô tả container bạn muốn (ContainerRequest), chờ nó sẵn sàng (wait.For...), rồi lấy endpoint để kết nối:
func DungRedis(ctx context.Context) (string, func(), error) {
req := testcontainers.ContainerRequest{
Image: "redis:7-alpine",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForListeningPort("6379/tcp"), // chờ SẴN SÀNG
}
c, err := testcontainers.GenericContainer(ctx, ...{
ContainerRequest: req, Started: true,
})
ep, _ := c.Endpoint(ctx, "")
donDep := func() { c.Terminate(ctx) } // tự dọn container khi xong
return ep, donDep, err
}
Ba điểm quan trọng: Image chỉ đúng phiên bản dịch vụ (như production), WaitingFor đảm bảo container sẵn sàng nhận kết nối trước khi test chạy (không đua tranh khởi động), và Terminate trong hàm dọn xóa container khi xong — không để lại rác trên máy CI.
Dùng trong test: chạy code thật chống dịch vụ thật
func TestKhoDon(t *testing.T) {
ep, donDep, err := DungRedis(ctx) // container Redis thật khởi động
if err != nil { t.Skip(...) }
defer donDep() // dọn khi test xong
kho := NewKhoDon(ep) // repository trỏ vào Redis thật
kho.Luu(ctx, "1001", "dang-giao") // HSET thật
got, _ := kho.DocTrangThai(ctx, "1001") // HGET thật
}
Repository chạy các lệnh Redis thật (HSET, HGET) qua giao thức thật, không mock. Nếu code bạn dùng sai lệnh, sai kiểu, hay giả định sai hành vi Redis, test này bắt được — mock thì không.

Hình 1: testcontainers-go — mô tả container bằng ContainerRequest, chờ sẵn sàng bằng wait.For..., lấy endpoint để repository kết nối, và Terminate tự dọn; test chạy code thật chống dịch vụ thật thay vì mock.
Đo thật: biên dịch sạch, và giới hạn Docker (trung thực)
Trước hết, code testcontainers biên dịch sạch — nó dùng đúng API thật:
$ go build ./...
testcontainers-go BIÊN DỊCH SẠCH (code dùng API thật)
Nhưng khi chạy test trong go-lab, tôi phải trung thực: môi trường này không mở Docker socket, nên testcontainers không dựng được container thật:
$ go test -run TestRedisContainer -v
tc_test.go:11: không có Docker socket: create container:
Cannot connect to the Docker daemon at
unix:///var/run/docker.sock. Is the docker daemon running?
--- SKIP: TestRedisContainer
Đây chính là yêu cầu cốt lõi của testcontainers: nó cần quyền truy cập Docker daemon. Trên máy dev có Docker hoặc CI có Docker-in-Docker, test này sẽ khởi động một Redis thật và chạy. Ở đây tôi không giả vờ nó chạy — tôi báo đúng cái xảy ra.
Để minh họa kết quả của một integration test (chạy code thật chống dịch vụ thật, không mock), tôi chạy một test tương đương dùng Redis thật in-process (miniredis — cài đặt giao thức Redis đầy đủ trong bộ nhớ):
$ go test -run TestKhoDon_Integration -v
repo_test.go:24: Lưu rồi đọc lại qua Redis THẬT:
don 1001 = "dang-giao" (đúng)
--- PASS: TestKhoDon_Integration
ok example.com/itest 0.003s
Repository lưu (HSET) rồi đọc lại (HGET) qua giao thức Redis thật và ra đúng "dang-giao" — không mock. testcontainers làm y hệt phần test này; khác biệt duy nhất là Redis của nó là một container Docker thật (đúng phiên bản, đúng mọi hành vi production), còn ở đây là Redis in-process.

Hình 2: Đo thật — code testcontainers biên dịch sạch; chạy test thật SKIP với đúng lỗi Docker socket (go-lab không có, báo trung thực); integration test tương đương chống Redis in-process PASS (lưu rồi đọc lại ra đúng "dang-giao", không mock).
Đánh đổi cần cân nhắc
Chậm hơn unit test rất nhiều. Khởi động một container Docker mất vài trăm mili-giây đến vài giây (kéo image lần đầu còn lâu hơn). Một bộ integration test đầy đủ có thể mất phút, so với unit test mili-giây. Nên thường tách: unit test chạy mọi lúc (nhanh), integration test chạy ở CI hoặc khi cần (build tag //go:build integration để lọc). Đừng biến mọi test thành integration test.
Cần Docker ở mọi nơi test chạy. Như đo ở trên, testcontainers bắt buộc có Docker daemon truy cập được. Máy dev phải cài Docker; CI phải cấu hình Docker-in-Docker hoặc mount socket. Môi trường không có Docker (như go-lab này, hay một số CI hạn chế) không chạy được — đó là lý do nhiều dự án dùng in-process fake (miniredis, sqlite) cho phần lớn test và testcontainers cho một tập nhỏ ca quan trọng nhất.
Cô lập tốt nhưng tốn tài nguyên. Mỗi test (hoặc mỗi package) có thể có container riêng sạch — cô lập tuyệt vời, không test nào dính state của test khác. Nhưng chạy nhiều container song song tốn RAM và CPU. Cân bằng: dùng một container chia sẻ cho cả package (TestMain) khi các test không đụng nhau, hoặc container riêng khi cần cô lập tuyệt đối. Terminate (thường trong defer hoặc t.Cleanup) là bắt buộc để không rò rỉ container.
Ba ý mang về
- testcontainers dựng dịch vụ thật trong Docker ngay trong test — bắt được bug mà mock bỏ lọt (cú pháp SQL, kiểu dữ liệu, hành vi transaction thật): đo thật, code dùng API thật (
ContainerRequest,wait.For...,Terminate) biên dịch sạch. - Báo cáo trung thực giới hạn: testcontainers cần Docker socket — go-lab không có nên test SKIP với đúng lỗi "Cannot connect to the Docker daemon"; một integration test tương đương chống Redis thật in-process PASS (lưu rồi đọc lại ra đúng, không mock) minh họa kết quả.
- Nêu rõ đánh đổi: chậm hơn unit test nhiều (khởi động container) nên tách bằng build tag và chạy ở CI, cần Docker ở mọi nơi test chạy (nên nhiều dự án dùng in-process fake cho phần lớn, testcontainers cho ca quan trọng), và luôn
Terminateđể không rò rỉ container.
Phần sau ta xét mặt còn lại của quang phổ test: mocking với gomock và mockgen trong Go — sinh mock tự động từ interface, khi nào mock đúng chỗ và khi nào nó che giấu bug (như testcontainers vừa cho thấy).