Mutation Testing — Coverage 90% mà bug vẫn lọt lưới
Có lần em review một PR, CI báo coverage 92% xanh lè, merge luôn cho kịp sprint. Ba tuần sau bug ở nhánh hoàn tiền nổ ra. Mở file test ra coi thì thấy test gọi hàm, không assert gì cả, chỉ để cho dòng code được thực thi. Từ đó em mới chịu hiểu: coverage trả lời câu "dòng này có chạy qua không", chứ không trả lời câu "test có thật sự kiểm chứng hành vi không". Đó là khoảng trống mà mutation testing san lấp.
Mutation testing hoạt động sao?
Ý tưởng đơn giản mà hiểm: máy tự đột biến chính code của mình, rồi chạy lại test suite xem có chết không.
- Đổi
>=thành>, đổi+thành- - Đổi
if err != nilthànhif err == nil - Xoá một dòng
return, thay hằng số500000bằng0 - Thay
truebằngfalse, thay hàm bằng zero value
Mỗi biến thể gọi là một mutant. Nếu test suite đỏ, mutant bị killed — test bắt được bug. Nếu test vẫn xanh, mutant survived — nghĩa là test của mình không hề kiểm chứng chỗ đó. Mutation score = killed / (tổng mutant − equivalent mutant). Equivalent mutant là loại đổi cú pháp nhưng hành vi y hệt (x * 1 → x / 1), không tài nào kill được, phải ignore tay.
Về bản chất đây là phép thử falsification: test tốt là test có khả năng chứng minh code sai. Coverage chỉ đếm dòng được thực thi, mutation testing mới đo khả năng đó.
Ví dụ: hàm tính phí ship
func ShippingFee(total int64, express bool) int64 {
if total >= 500_000 {
return 0
}
if express {
return 35_000
}
return 20_000
}
Test kiểu cho có, coverage 100% cho hàm này:
func TestShippingFee(t *testing.T) {
_ = ShippingFee(100_000, false)
}
Gremlins sẽ báo gần hết mutant sống sót, vì không có gì kiểm tra giá trị trả về. Test đủ sức kill thì phải bám biên và bám từng nhánh:
func TestShippingFee(t *testing.T) {
cases := []struct {
name string
total int64
express bool
want int64
}{
{"dưới ngưỡng, thường", 100_000, false, 20_000},
{"dưới ngưỡng, hoả tốc", 100_000, true, 35_000},
{"đúng ngưỡng 500k", 500_000, false, 0},
{"trên ngưỡng, hoả tốc vẫn free", 700_000, true, 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ShippingFee(c.total, c.express)
if got != c.want {
t.Fatalf("ShippingFee(%d, %v) = %d, muốn %d", c.total, c.express, got, c.want)
}
})
}
}
Case total = 500_000 mới là case ăn tiền: nó kill mutant >= → >. Thiếu case đúng biên là lỗ hổng kinh điển của table test.
Chạy thử với Gremlins
go install github.com/go-gremlins/gremlins/cmd/gremlins@latest
gremlins unleash --workers 4 --timeout-coefficient 5 ./internal/cart/...
Output dạng:
KILLED conditionals_boundary cart.go:8:7 (>= -> >)
KILLED arithmetic_base cart.go:11:9 (35000 + 0 -> 35000 - 0)
LIVED conditionals_negation cart.go:11:6 (express -> !express)
Mutation score: 82% (32 killed, 7 lived)
Mỗi dòng LIVED là một chỗ test đang hở. Đọc danh sách mutant sống còn nhanh hơn đọc test để tìm lỗ hổng assert.
Kinh nghiệm thực chiến
1. Đừng chạy toàn repo trong CI. Chi phí là N mutant × thời gian test suite. Chạy full dễ mất hàng chục phút. Chỉ chạy package bị thay đổi trong PR, hoặc gắn vào nightly job.
2. Timeout là bắt buộc. Mutant gây vòng lặp vô hạn hoặc deadlock sẽ treo CI. --timeout-coefficient 5 cho mỗi mutant tối đa 5 lần thời gian test gốc, quá thì coi như killed-by-timeout và cần coi tay.
3. Đặt ngưỡng 100% là mục tiêu sai. Có equivalent mutant không thể kill, có mutant ở logging/telemetry không đáng test. Ngưỡng thực tế 70-85% là lành mạnh.
4. Soi mutant sống ở error path trước. Phần lớn mutant sống sót nằm ở if err != nil { return nil } mà không ai test. Xoá dòng return nil đi test vẫn xanh — nghĩa là khi DB fail, app âm thầm trả dữ liệu rác rồi panic ở chỗ khác. Bug tiền bạc hay nằm ở đây.
5. Đừng biến mutation score thành KPI. Nó sẽ bị game y như coverage: người ta viết assert vô nghĩa cho đủ điểm. Dùng nó như công cụ điều tra, đọc mutant sống như đọc bug report.
Kết
Coverage trả lời "code có được chạy không". Mutation testing trả lời "test có thật sự kiểm chứng không" — cái thứ hai mới cứu bạn lúc 2 giờ sáng. Tool theo stack: gremlins (Go), Stryker (JS/TS), PIT (Java), mutmut (Python).