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.

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).