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.

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.