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.

Ảnh chụp đoạn mã Go nền tối minh hoạ integration test với testcontainers trong Go dựng dịch vụ thật trong Docker, 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 func DungRedis ctx context Context string func error req bằng testcontainers ContainerRequest Image redis 7-alpine ExposedPorts 6379 tcp WaitingFor wait ForListeningPort 6379 tcp chờ sẵn sàng c err bằng testcontainers GenericContainer ctx ContainerRequest req Started true ep bằng c Endpoint ctx donDep bằng func c Terminate ctx tự dọn container khi xong return ep donDep err, dùng trong test chạy code thật chống dịch vụ thật func TestKhoDon t testing T ep donDep err bằng DungRedis ctx container Redis thật khởi động if err khác nil t Skip defer donDep dọn khi test xong kho bằng NewKhoDon ep repository trỏ vào Redis thật kho Luu ctx 1001 dang-giao HSET thật got bằng kho DocTrangThai ctx 1001 HGET thật, điểm mạnh cốt lõi thật dịch vụ thật Redis Postgres Kafka đúng phiên bản production cô lập mỗi test có container riêng sạch không dính state test khác tự dọn Terminate xóa container khi xong không rác lại máy CI chờ sẵn wait For đảm bảo container sẵn sàng trước khi test chạy

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.

Ảnh chụp bảng kết quả đo thật nền tối integration test với testcontainers trong Go chạy bằng go build cộng go test Go 1.23 arm64 testcontainers-go v0.33 go-lab không có Docker socket, 1 code testcontainers biên dịch sạch API thật go build testcontainers-go biên dịch sạch code dùng API thật ContainerRequest GenericContainer wait ForListeningPort Terminate, 2 chạy thật cần Docker socket môi trường này không có go test run TestRedisContainer tc_test.go dòng 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 trung thực go-lab không mở Docker socket nên không dựng được container thật testcontainers cần quyền truy cập Docker daemon, 3 integration test tương đương Redis thật in-process miniredis go test run TestKhoDon_Integration repo_test.go dòng 24 Lưu rồi đọc lại qua Redis thật don 1001 bằng dang-giao đúng PASS TestKhoDon_Integration ok example.com itest 0.003s repository chạy HSET HGET qua giao thức Redis thật không mock testcontainers làm y hệt phần này chỉ khác Redis là container Docker, cốt lõi ý tưởng dựng dịch vụ thật trong Docker ngay trong test không mock API ContainerRequest cộng wait For cộng Terminate biên dịch sạch tự dọn Terminate xóa container khi test xong không rác CI giới hạn cần Docker socket đo thật ở đây SKIP vì go-lab không có tương đương integration test thật chống Redis in-process PASS không mock đánh đổi chậm hơn unit test nhiều khởi động container cần Docker ở CI

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ề

  1. 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.
  2. 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ả.
  3. 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).