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.
Bài tập làm thử
Bài 1 (đọc hiểu). Trong đoạn mã dựng container Redis sau, giải thích vai trò riêng biệt của WaitingFor và Terminate — nếu thiếu WaitingFor, vấn đề gì có thể xảy ra?
req := testcontainers.ContainerRequest{
Image: "redis:7-alpine",
ExposedPorts: []string{"6379/tcp"},
WaitingFor: wait.ForListeningPort("6379/tcp"),
}
Đáp án
WaitingFor đảm bảo container thực sự sẵn sàng nhận kết nối (cổng 6379 đã lắng nghe) trước khi hàm trả về, tránh tình huống "đua tranh khởi động" (race) khi test cố kết nối vào một Redis còn đang khởi động dở. Terminate (gọi trong hàm dọn, thường qua defer) xóa container khi test xong, tránh để lại rác trên máy CI. Nếu thiếu WaitingFor, test có thể cố kết nối tới container ngay sau khi nó được tạo nhưng trước khi Redis bên trong đã khởi động xong dịch vụ, dẫn tới lỗi kết nối ngẫu nhiên/không ổn định (flaky test) tùy vào tốc độ khởi động của container ở từng lần chạy.
Bài 2 (sửa lỗi/phân biệt khái niệm). Một đồng nghiệp viết test sau và tự tin rằng nó đã kiểm tra đầy đủ hành vi thật của repository với Redis:
func TestKhoDon(t *testing.T) {
m := NewMockRedis() // mock tự viết, giả lập HSET/HGET
kho := NewKhoDon(m)
kho.Luu(ctx, "1001", "dang-giao")
got, _ := kho.DocTrangThai(ctx, "1001")
// so sánh got
}
Theo bài, vì sao cách này không đạt được điều mà testcontainers làm được, dù test này vẫn PASS?
Đáp án
Test này dùng mock tự viết, chỉ "giả lập hành vi mà bạn nghĩ dịch vụ thật có" — nó không chạy các lệnh HSET/HGET thật qua giao thức Redis thật. Bài nói rõ: "bug thật thường nằm chính ở khác biệt giữa mock và thực tế" — nếu MockRedis giả định sai về cách Redis xử lý kiểu dữ liệu, lỗi kết nối, hay hành vi cụ thể của lệnh, test này vẫn PASS trong khi code thật sẽ hỏng khi chạy với Redis production. testcontainers giải quyết đúng vấn đề này bằng cách dựng một container Redis thật (đúng phiên bản production) để test chạy chống, bắt được các khác biệt mà mock không thể phát hiện.
Bài 3 (vận dụng thực tế). Dự án của bạn có 200 unit test chạy trong 2 giây, và bạn muốn thêm 15 integration test dùng testcontainers (mỗi test khởi động một container PostgreSQL riêng, mất khoảng 3 giây/test). Theo đánh đổi nêu trong bài, bạn nên tổ chức các test này thế nào để không làm chậm vòng lặp phát triển hằng ngày?
Đáp án
Nên tách bằng build tag (//go:build integration) để lọc: unit test chạy mọi lúc trong vòng lặp phát triển (nhanh, không cần Docker), còn integration test dùng testcontainers chỉ chạy ở CI hoặc khi cần kiểm tra kỹ (go test -tags=integration). Bài nói rõ: "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... Đừng biến mọi test thành integration test." Ngoài ra có thể cân nhắc dùng một container PostgreSQL chia sẻ cho cả package qua TestMain nếu các test không đụng chạm state của nhau, để giảm số lần khởi động container từ 15 xuống 1.
Bài 4 (bẫy/đánh đổi). Bài báo cáo trung thực rằng test TestRedisContainer bị SKIP trong môi trường go-lab vì thiếu Docker socket, thay vì "giả vờ nó chạy". Theo bài, đây là yêu cầu cốt lõi nào của testcontainers, và hệ quả gì cho việc triển khai CI?
Đáp án
Yêu cầu cốt lõi: testcontainers bắt buộc phải có quyền truy cập Docker daemon để dựng container. Hệ quả cho CI: "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, hoặc một số CI hạn chế) sẽ không chạy được test testcontainers. Đây là lý do bài khuyên "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" — không nên giả định mọi môi trường CI đều có Docker sẵn sàng mà không kiểm tra trước.
Bài 5 (đọc hiểu kết quả đo). Bài chạy một test tương đương dùng miniredis (Redis thật in-process) thay vì testcontainers thật, và nói "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". Vậy miniredis có phải là "mock" theo định nghĩa ở bài trước (go-119) không? Giải thích.
Đáp án
Không, miniredis không phải là mock theo nghĩa "cài đặt giả khai trước hành vi mong đợi" — bài mô tả nó là "cài đặt giao thức Redis đầy đủ trong bộ nhớ", nghĩa là nó thực thi đúng logic và giao thức Redis thật (không phải giả lập hành vi lập trình viên tưởng tượng ra), chỉ khác là chạy in-process thay vì trong container Docker riêng biệt. Vì vậy repository vẫn gọi HSET/HGET thật qua giao thức thật và nhận phản hồi thật từ một cài đặt Redis thực sự — đây vẫn là một dạng integration test thật, chỉ khác testcontainers ở chỗ dịch vụ chạy trong cùng tiến trình thay vì container cô lập.
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).