Có một loại hàm mà viết test kiểu want := "..." trở nên khổ sở: hàm sinh đầu ra lớn — một báo cáo văn bản 30 dòng, một khối HTML, một JSON phức tạp, mã nguồn sinh tự động, hay output của một lệnh CLI. Nhét cả đầu ra kỳ vọng thành một chuỗi trong file test khiến test rối, khó đọc, và cực khổ để cập nhật khi định dạng đổi.

Golden file testing (còn gọi snapshot testing) giải gọn: lưu đầu ra kỳ vọng vào một tệp riêng (tệp "vàng"), và test chỉ việc so đầu ra thật với nội dung tệp đó. Khi hành vi đổi có chủ đích, bạn chạy go test -update để ghi lại tệp thay vì sửa chuỗi bằng tay. Bài này dựng golden file testing thật trong Go, đo trọn vòng đời của nó — sinh, so, bắt khác biệt, và cập nhật — cùng những cạm bẫy.

Cờ -update để ghi lại golden file

Mấu chốt là một cờ dòng lệnh phân biệt hai chế độ:

var update = flag.Bool("update", false, "ghi lại golden file")
// go test -update  -> sinh/cập nhật tệp vàng
// go test          -> so đầu ra với tệp vàng (không ghi)

Test: -update thì ghi, không thì so

got := XuatHoaDon(h)                       // đầu ra thật
golden := filepath.Join("testdata", "hoadon.golden")

if *update {                              // chế độ ghi lại
	os.WriteFile(golden, []byte(got), 0644)
	return
}
want, err := os.ReadFile(golden)         // đọc tệp vàng
if err != nil {
	t.Fatalf("chưa có golden, chạy: go test -update")
}
if got != string(want) {                  // so từng byte
	t.Errorf("đầu ra KHÁC golden:\n--- GOT ---\n%s\n--- WANT ---\n%s", got, want)
}

Logic đơn giản: nếu -update bật thì ghi đầu ra hiện tại vào tệp và xong; nếu không thì đọc tệp và so. Dùng thư mục testdata/ — Go tự bỏ qua thư mục này khi build, nên đây là nơi chuẩn để đặt dữ liệu test.

Ảnh chụp đoạn mã Go nền tối minh hoạ golden file testing trong Go lưu đầu ra kỳ vọng vào tệp so tự động, vì sao đầu ra lớn khó viết assertion bằng tay hàm sinh đầu ra lớn phức tạp báo cáo HTML JSON mã sinh viết want bằng chuỗi 30 dòng trong test thì rối và khó bảo trì golden file lưu đầu ra kỳ vọng vào 1 tệp test so đầu ra với tệp đổi có chủ đích chạy update ghi lại tệp không sửa tay, cờ update để ghi lại golden file var update bằng flag Bool update false ghi lại golden file go test update sinh cập nhật tệp vàng go test so đầu ra với tệp vàng không ghi, test update thì ghi không thì so got bằng XuatHoaDon h đầu ra thật golden bằng filepath Join testdata hoadon.golden if update chế độ ghi lại os WriteFile golden byte got return want err bằng os ReadFile golden đọc tệp vàng if err khác nil t Fatalf chưa có golden chạy go test update if got khác string want so từng byte t Errorf đầu ra khác golden GOT got WANT want, quy trình dùng lần đầu go test update sinh tệp vàng commit vào repo bình thường go test so đầu ra với tệp khác là FAIL kèm diff đổi có chủ đích go test update ghi lại tệp xem diff khi review testdata Go tự bỏ qua thư mục này khi build nơi chuẩn để tệp

Hình 1: Golden file testing — cờ -update phân biệt chế độ ghi (sinh/cập nhật tệp vàng) và chế độ so (đọc tệp, so từng byte với đầu ra thật); dùng thư mục testdata/ mà Go tự bỏ qua khi build.

Đo thật: trọn vòng đời golden file

Bước 1 — lần đầu chưa có golden:

$ go test
--- FAIL: chưa có golden file, chạy: go test -update
$ go test -update
PASS  ok  example.com/golden

Lần đầu test FAIL với hướng dẫn rõ ràng; chạy -update sinh ra testdata/hoadon.golden (commit vào repo). Nội dung tệp vàng chính là đầu ra kỳ vọng:

HOA DON - Khach: Nguyen Van A
================================
Banh mi                 25000
Ca phe                  45000
Tra da                   5000
--------------------------------
TONG CONG               75000

Bước 2 — chạy lại bình thường: đầu ra khớp từng byte với tệp vàng → PASS.

Bước 3 — đổi code (ví dụ đổi tiêu đề "HOA DON" → "HOA DON BAN HANG"): golden test bắt khác biệt ngay, kèm diff chỉ rõ chỗ khác:

$ go test   # sau khi đổi tiêu đề trong code
--- FAIL: TestXuatHoaDon_Golden
    --- GOT ---
    HOA DON BAN HANG - Khach: Nguyen Van A
    --- WANT ---
    HOA DON - Khach: Nguyen Van A

So từng byte nên nó chỉ ra chính xác dòng khác. Đây là lưới an toàn: bất kỳ thay đổi nào tới đầu ra — dù vô tình — đều bị bắt.

Bước 4 — nếu đổi là có chủ đích: chạy -update để chấp nhận đầu ra mới:

$ go test -update
PASS
$ head -1 testdata/hoadon.golden
HOA DON BAN HANG - Khach: Nguyen Van A

Golden file cập nhật. Điểm hay: thay đổi này hiện ra dưới dạng diff của tệp golden khi review commit — người review thấy rõ đầu ra đã đổi thế nào và quyết định có đúng không. Đầu ra không bao giờ đổi ngầm.

Ảnh chụp bảng kết quả đo thật nền tối golden file testing trong Go chạy bằng go test Go 1.23 arm64 testdata hoadon.golden, 1 lần đầu chưa có golden và update sinh ra go test FAIL chưa có golden file chạy go test update go test update PASS ok example.com golden testdata hoadon.golden được sinh commit vào repo, golden file đã sinh đầu ra kỳ vọng HOA DON Khach Nguyen Van A Banh mi 25000 Ca phe 45000 Tra da 5000 TONG CONG 75000, 2 chạy lại so với golden PASS go test PASS ok example.com golden 0.001s đầu ra khớp từng byte với tệp vàng xanh, 3 đổi code golden test bắt khác biệt kèm diff go test sau khi đổi tiêu đề trong code FAIL TestXuatHoaDon_Golden GOT HOA DON BAN HANG Khach Nguyen Van A WANT HOA DON Khach Nguyen Van A so từng byte chỉ ra chính xác chỗ khác, 4 nếu đổi có chủ đích update chấp nhận go test update PASS head 1 testdata hoadon.golden HOA DON BAN HANG Khach Nguyen Van A golden cập nhật diff hiện ra khi review commit kiểm soát được, cốt lõi golden lưu đầu ra kỳ vọng vào tệp test so đầu ra với tệp update cờ ghi lại tệp khi hành vi đổi có chủ đích không sửa tay hợp cho đầu ra lớn báo cáo HTML JSON mã sinh output CLI đo thật sinh PASS so PASS đổi code FAIL kèm diff update nhận an toàn diff hiện khi review commit không lỡ đổi đầu ra ngầm đánh đổi dễ update mù không xem diff golden nhị phân khó đọc

Hình 2: Đo thật trọn vòng đời — -update sinh golden (PASS), chạy lại so PASS, đổi tiêu đề trong code thì test FAIL kèm diff GOT/WANT chỉ rõ chỗ khác, và -update chấp nhận thay đổi có chủ đích (golden cập nhật thành HOA DON BAN HANG).

Đánh đổi cần cân nhắc

Dễ -update một cách mù quáng — cạm bẫy lớn nhất. Sức mạnh của golden file (cập nhật dễ) cũng là điểm yếu: khi test FAIL, phản xạ lười là chạy -update ngay để test xanh, mà không đọc diff. Làm vậy là biến lưới an toàn thành vô dụng — bạn "chấp nhận" cả bug. Kỷ luật bắt buộc: luôn đọc diff golden trong code review trước khi merge, coi nó như một phần thay đổi thật sự. Golden file chỉ an toàn khi diff được xem xét nghiêm túc.

Đầu ra không tất định làm golden vỡ. Golden file so chính xác từng byte, nên đầu ra chứa yếu tố thay đổi mỗi lần chạy — thời gian hiện tại, số ngẫu nhiên, thứ tự map (Go lặp map ngẫu nhiên!), địa chỉ con trỏ — sẽ khiến test FAIL sai. Phải làm đầu ra tất định trước khi golden hóa: sắp thứ tự (như tôi sort.Strings các mặt hàng trong ví dụ), tiêm thời gian cố định, seed random cố định. Nếu không kiểm soát được, golden file không phù hợp.

Golden nhị phân hoặc quá lớn khó review. Golden file văn bản thì diff đọc được; nhưng golden là ảnh, PDF, hay khối nhị phân thì diff vô nghĩa với người — bạn không thấy cái gì đổi, chỉ thấy "khác". Với đầu ra như vậy, cân nhắc so ở mức có nghĩa hơn (kích thước, checksum, vài thuộc tính) thay vì byte thô, hoặc dùng công cụ diff chuyên cho định dạng đó. Golden file mạnh nhất cho đầu ra văn bản mà con người đọc được diff.

Ba ý mang về

  1. Golden file testing lưu đầu ra kỳ vọng vào tệp và so tự động — hợp cho đầu ra lớn (báo cáo, HTML, JSON, mã sinh, output CLI) mà viết assertion tay thì rối: đo thật trọn vòng đời, sinh bằng -update, so PASS, đổi code thì FAIL kèm diff GOT/WANT.
  2. Cờ -update là cơ chế cập nhật có kiểm soát: đổi có chủ đích thì ghi lại tệp thay vì sửa chuỗi tay, và thay đổi hiện ra dưới dạng diff golden khi review commit — đầu ra không bao giờ đổi ngầm; dùng testdata/ mà Go tự bỏ qua khi build.
  3. Nêu rõ cạm bẫy: dễ -update mù không đọc diff (biến lưới an toàn thành vô dụng — phải review diff nghiêm túc), đầu ra không tất định (thời gian, random, thứ tự map) làm golden vỡ nên phải làm tất định trước, và golden nhị phân/quá lớn khó review.

Bài tập làm thử

Bài 1 (đọc hiểu). Giải thích chính xác điều gì xảy ra khi chạy go test (không có cờ -update) trong hai tình huống: (a) tệp golden chưa tồn tại, (b) tệp golden tồn tại nhưng nội dung khác đầu ra thực tế.

Đáp án

(a) Khi tệp golden chưa tồn tại, os.ReadFile(golden) trả về lỗi, và test gọi t.Fatalf("chưa có golden, chạy: go test -update") — test FAIL với thông báo hướng dẫn rõ ràng cách khắc phục. (b) Khi tệp golden tồn tại nhưng nội dung khác đầu ra thực tế (got != string(want)), test gọi t.Errorf in ra cả hai để so sánh (--- GOT --- và --- WANT ---), giúp thấy chính xác chỗ khác biệt — test FAIL kèm diff.

Bài 2 (sửa lỗi). Đoạn mã golden test sau có lỗi khiến nó luôn PASS dù đầu ra sai, kể cả khi không chạy -update. Chỉ ra lỗi.

func TestXuatHoaDon_Golden(t *testing.T) {
	got := XuatHoaDon(h)
	golden := filepath.Join("testdata", "hoadon.golden")
	if *update {
		os.WriteFile(golden, []byte(got), 0644)
	}
	want, _ := os.ReadFile(golden)
	if got != string(want) {
		t.Errorf("khác golden")
	}
}
Đáp án

Lỗi: thiếu return sau khối if *update { ... }. Khi chạy -update, code ghi lại tệp golden nhưng không dừng lại — nó tiếp tục chạy xuống dòng đọc want (vừa ghi xong nên chắc chắn khớp với got) và so sánh, luôn PASS trong chế độ update (không sai ở đây, nhưng không phải vấn đề chính). Vấn đề thực sự nguy hiểm hơn: os.ReadFile(golden) bỏ qua lỗi (_) — nếu tệp golden chưa tồn tại và không chạy -update, want sẽ là nil/rỗng, và so sánh got != string(want) sẽ đúng là khác nên vẫn FAIL đúng, nhưng không có thông báo rõ ràng hướng dẫn chạy -update như bài mẫu. Sửa đúng theo mẫu bài: thêm return sau ghi tệp, và kiểm tra lỗi đọc tệp golden để báo t.Fatalf("chưa có golden, chạy: go test -update") khi tệp chưa tồn tại.

Bài 3 (vận dụng thực tế). Đội của bạn viết hàm sinh báo cáo tổng hợp doanh thu, trong đó có một dòng ghi "Xuất lúc: " + time.Now().Format(...). Nếu golden hóa đầu ra này trực tiếp, điều gì sẽ xảy ra khi chạy test vào ngày hôm sau? Theo bài, cách khắc phục là gì?

Đáp án

Test sẽ FAIL sai (false positive) mỗi lần chạy vào thời điểm khác, vì golden file so sánh chính xác từng byte, và dòng "Xuất lúc: ..." sẽ luôn khác giữa lần sinh golden và lần chạy test sau đó. Bài cảnh báo: "đầu ra chứa yếu tố thay đổi mỗi lần chạy — thời gian hiện tại, số ngẫu nhiên, thứ tự map... sẽ khiến test FAIL sai. Phải làm đầu ra tất định trước khi golden hóa" — cách khắc phục là tiêm (inject) một thời gian cố định vào hàm sinh báo cáo trong lúc test (ví dụ qua tham số hoặc một clock giả lập) thay vì gọi time.Now() trực tiếp, để đầu ra luôn tất định bất kể chạy lúc nào.

Bài 4 (bẫy/đánh đổi). Một lập trình viên thấy TestXuatHoaDon_Golden FAIL sau khi họ sửa code, và ngay lập tức chạy go test -update rồi commit mà không xem nội dung thay đổi. Theo bài, đây là cạm bẫy gì, và hậu quả có thể xảy ra?

Đáp án

Đây chính là "cạm bẫy lớn nhất" mà bài nêu: "dễ -update một cách mù quáng... khi test FAIL, phản xạ lười là chạy -update ngay để test xanh, mà không đọc diff. Làm vậy là biến lưới an toàn thành vô dụng — bạn 'chấp nhận' cả bug." Hậu quả: nếu thay đổi đầu ra thực chất là một bug ngoài ý muốn (ví dụ tính sai tổng tiền do một thay đổi code không liên quan), việc -update mù quáng sẽ khiến golden file "hợp thức hóa" luôn cả kết quả sai đó, và từ lần sau test sẽ coi đầu ra sai này là "đúng". Kỷ luật bắt buộc theo bài: "luôn đọc diff golden trong code review trước khi merge, coi nó như một phần thay đổi thật sự."

Bài 5 (đọc hiểu số liệu/quy trình). Từ log đo thật: bước 3 đổi tiêu đề code cho FAIL kèm GOT/WANT, bước 4 chạy -update rồi golden cập nhật thành tiêu đề mới. Giải thích tại sao "thay đổi này hiện ra dưới dạng diff của tệp golden khi review commit" lại là một cơ chế an toàn quan trọng, so với việc sửa trực tiếp một chuỗi want := "..." trong code test.

Đáp án

Khi golden file được cập nhật bằng -update, sự thay đổi nằm trong một tệp dữ liệu riêng (testdata/hoadon.golden) — khi commit, hệ thống quản lý phiên bản (git) sẽ hiện rõ diff của chính tệp đó, cho người review thấy chính xác nội dung đầu ra đã đổi từ gì sang gì (ví dụ "HOA DON" → "HOA DON BAN HANG"), y hệt như xem diff của bất kỳ thay đổi code nào khác. Nếu thay vào đó phải sửa tay một chuỗi want := "..." dài nhúng trong file test Go, diff sẽ khó đọc hơn nhiều (lẫn trong cú pháp Go, xuống dòng escape...), dễ khiến người review bỏ sót thay đổi thực sự. Bài kết luận: "Đầu ra không bao giờ đổi ngầm" — vì mọi thay đổi hành vi đều bắt buộc phải đi qua một diff tường minh, có thể xem xét trước khi merge.

Phần sau ta quay lại đo hiệu năng cho chính xác: benchstat trong Go — chạy benchmark nhiều lần, tính trung bình và độ lệch, và so hai phiên bản có ý nghĩa thống kê thay vì so một con số đơn lẻ dễ nhiễu.