Test đơn vị có một điểm mù cố hữu: nó chỉ kiểm những input mà lập trình viên nghĩ tới. Bạn viết TestFoo với vài trường hợp bạn tưởng tượng ra — nhưng chính những input bạn không tưởng tượng (chuỗi rỗng, ký tự lạ, số âm, dữ liệu méo mó) mới là nơi bug ẩn nấp. Fuzzing giải đúng điểm mù đó: nó tự sinh hàng triệu input, kể cả những cái kỳ quái nhất, và chạy hàm của bạn với chúng để tìm panic hoặc kết quả sai.
Từ Go 1.18, fuzzing được tích hợp thẳng vào go test — không cần thư viện ngoài. Bạn viết một hàm Fuzz trong file _test.go, và go test -fuzz sẽ dùng coverage để dò thông minh, đột biến input, tìm ca làm sập, rồi tự rút gọn nó xuống tối giản. Bài này để fuzzing thật tìm ra một bug mà test thường bỏ lọt, và đo nó nhanh thế nào.
Hàm có bug ẩn
func TachKhuVuc(s string) (string, string) {
parts := strings.Split(s, ":")
return parts[0], parts[1] // BUG: parts[1] panic nếu không có ":"
}
Hàm tách "HN:5" thành ("HN", "5"). Trông ổn — nhưng nếu chuỗi không có dấu :, thì strings.Split trả về slice chỉ một phần tử, và parts[1] gây panic "index out of range".
Test thường: chỉ phủ case "đẹp"
func TestTachKhuVuc(t *testing.T) {
khu, so := TachKhuVuc("HN:5") // chỉ thử input hợp lệ
if khu != "HN" || so != "5" { t.Fatalf(...) }
}
Test này PASS — và đó chính là vấn đề. Lập trình viên viết test với input họ hình dung ("HN:5"), không bao giờ chạm tới input thiếu :. Bug lọt lưới hoàn toàn dù có test.
Fuzz test: máy tự sinh input
func FuzzTachKhuVuc(f *testing.F) {
f.Add("HN:5") // seed corpus: vài ví dụ mở đầu
f.Add("SG:10")
f.Fuzz(func(t *testing.T, s string) {
TachKhuVuc(s) // gọi với vô số input tự sinh; panic = lỗi
})
}
Khác biệt cốt lõi: f.Add cung cấp vài hạt giống (seed) mở đầu, còn f.Fuzz nhận một hàm được gọi với vô số input tự sinh. Engine fuzzing đột biến các seed, đo coverage sau mỗi lần chạy, giữ lại những input mở ra nhánh code mới — dò thông minh cho tới khi tìm được input làm hàm sập.

Hình 1: Fuzzing native trong Go — hàm TachKhuVuc có bug ẩn khi thiếu :; test thường chỉ thử "HN:5" nên PASS và bỏ lọt bug; fuzz test dùng f.Fuzz để engine tự sinh vô số input dò tìm ca làm sập.
Đo thật: fuzz tìm bug trong dưới 1 giây
Chạy test thường trước:
$ go test -run TestTachKhuVuc
PASS ok example.com/fz 0.001s
XANH — bug vẫn ẩn. Giờ chạy fuzzing:
$ go test -fuzz FuzzTachKhuVuc
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed,
now fuzzing with 10 workers
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzTachKhuVuc
panic: runtime error: index out of range [1] with length 1
example.com/fz.TachKhuVuc(...) /work/t116/code.go:9
Engine đo coverage nền của các seed, rồi bắt đầu fuzz với 10 worker song song, và tìm ra panic gần như tức thì (elapsed 0s). Nó chỉ đúng dòng gây lỗi — code.go:9, tức parts[1].
Điều tinh vi nhất là bước minimize — Go tự rút gọn input làm sập xuống tối giản nhất:
Failing input written to testdata/fuzz/FuzzTachKhuVuc/771e938e...
$ cat testdata/fuzz/FuzzTachKhuVuc/771e938e...
go test fuzz v1
string("0") // input tối giản: chuỗi "0" - KHÔNG có dấu ":"
Input làm sập được rút xuống chỉ còn "0" — một chuỗi một ký tự không có :. strings.Split("0", ":") trả ["0"] chỉ một phần tử, nên parts[1] vượt biên. Đây là reproducer tối giản, dễ hiểu ngay nguyên nhân. Và Go lưu nó vào testdata/fuzz/ — từ lần chạy go test sau, ca này tự động được chạy lại như một test hồi quy, chống bug tái phát.

Hình 2: Đo thật — test thường XANH (bỏ lọt bug), fuzz tìm ra panic index out of range [1] with length 1 tại code.go:9 gần như tức thì với 10 worker; input làm sập được minimize xuống chuỗi "0" và lưu vào testdata/fuzz/ làm test hồi quy.
Đánh đổi cần cân nhắc
Fuzzing chỉ bắt được lỗi mà nó nhận ra là lỗi. Mặc định nó bắt panic và các lỗi runtime (index out of range, nil dereference). Nhưng một hàm trả về kết quả sai mà không panic thì fuzzing không tự biết — trừ khi bạn thêm kiểm chứng (assertion) trong hàm fuzz. Mẫu mạnh nhất là property-based hoặc round-trip: ví dụ "encode rồi decode phải ra input gốc" — fuzz với đủ input, nếu round-trip sai ở đâu, t.Errorf báo. Chủ đề bài sau. Fuzzing trần chỉ tìm crash; kết hợp với assertion mới tìm được lỗi logic.
Fuzzing cần thời gian và không bao giờ "xong". Không như test đơn vị chạy một lần rồi dừng, fuzzing chạy cho tới khi bạn dừng (-fuzztime giới hạn) hoặc tìm được lỗi. Trong CI thường chạy fuzz một khoảng ngắn (vài giây đến vài phút) như một lớp kiểm tra bổ sung; tìm bug sâu cần chạy lâu hơn (hàng giờ). Bug ở đây tìm ra tức thì vì nó nông; bug sâu ẩn sau nhiều nhánh cần engine dò lâu hơn nhiều.
Corpus phình theo thời gian. Engine lưu mọi input "thú vị" (mở nhánh coverage mới) vào cache corpus ($GOCACHE/fuzz). Corpus này lớn dần và giúp fuzz lần sau bắt đầu từ trạng thái đã khám phá — tốt cho hiệu quả nhưng chiếm dung lượng. Còn các input làm sập (trong testdata/fuzz/) thì commit vào repo làm test hồi quy; đừng xóa chúng.
Ba ý mang về
- Fuzzing tìm bug ở những input bạn không nghĩ tới — điểm mù cố hữu của test đơn vị: đo thật, một hàm có test XANH vẫn ẩn bug panic khi thiếu
:, và fuzz tìm ra input làm sập gần như tức thì với 10 worker song song, chỉ đúng dòng lỗi. - Native từ Go 1.18, coverage-guided và tự minimize: viết
f.Fuzztrong_test.go, chạygo test -fuzz— engine đột biến seed theo coverage, rút input làm sập xuống tối giản (tìm được"0"), và lưu vàotestdata/fuzz/làm test hồi quy tự chạy lại. - Nêu rõ giới hạn: fuzzing trần chỉ bắt panic/lỗi runtime — muốn tìm lỗi logic phải thêm assertion (property/round-trip, bài sau); nó cần thời gian và không bao giờ "xong" nên CI thường chạy giới hạn; corpus phình dần theo thời gian.
Phần sau ta nâng fuzzing lên tầng mạnh hơn: property-based testing trong Go — thay vì kiểm từng ví dụ, ta khẳng định các tính chất luôn đúng (round-trip, bất biến) và để máy tìm phản ví dụ.