Độ phủ test (test coverage) là chỉ số ai cũng đo, và cũng là chỉ số dễ hiểu sai nhất. go test -cover cho bạn một con số — "75%" — nhưng con số đó vừa nói ít hơn bạn tưởng, vừa dễ khiến bạn tự tin sai. Bài này đi sâu hơn con số tổng: cách xem phủ theo từng hàm để biết đi đâu mà thêm test, và — quan trọng nhất — vì sao 100% phủ dòng không đồng nghĩa test tốt.
Go có công cụ coverage tích hợp mạnh, nhưng phần lớn người dùng dừng ở -cover. Bài này dùng đủ ba mức (-cover, -func, -html) trên code thật, và chứng minh bằng một bug sống sót qua 100% coverage rằng coverage đo dòng được chạy, không phải dòng được kiểm đúng.
Ba mức đo độ phủ
// go test -cover -> % câu lệnh được phủ, TỔNG cả package
// -coverprofile=cov.out -> ghi hồ sơ phủ chi tiết ra tệp
// go tool cover -func -> phủ theo TỪNG HÀM (thấy hàm nào yếu)
// go tool cover -html -> tô màu dòng nào chạy/chưa chạy (trực quan)
-cover cho con số tổng nhanh; -coverprofile ghi chi tiết để hai công cụ sau phân tích; -func liệt kê phủ theo hàm; -html mở trình duyệt tô xanh/đỏ từng dòng — trực quan nhất để thấy chính xác dòng nào chưa chạy.
-func chỉ ra hàm nào phủ yếu
Với hàm nhiều nhánh, dễ bỏ sót một nhánh khi viết test:
func PhanLoai(diem int) string {
if diem >= 1000 { return "vang" }
if diem >= 500 { return "bac" } // nhánh dễ quên test
return "thuong"
}
Chạy -func trên profile:
$ go tool cover -func=cov.out
PhanLoai 80.0% // thiếu nhánh "bac"
ChiaAnToan 66.7% // thiếu nhánh b==0
total 75.0%
Đây là khác biệt lớn so với -cover: thay vì chỉ biết "75%", bạn biết chính xác PhanLoai thiếu 20% và ChiaAnToan thiếu 33% — và đoán ngay là nhánh nào (bac, b==0). -func biến coverage từ một con số mơ hồ thành bản đồ hành động.

Hình 1: Ba mức đo phủ trong Go — -cover (tổng), -func (theo hàm, chỉ chỗ yếu), -html (tô màu dòng); và cạm bẫy 100% phủ dòng không bằng test tốt vì coverage đo dòng được chạy chứ không đo dòng được kiểm đúng.
Đo thật: tăng phủ có mục tiêu, và cạm bẫy 100%
Con số tổng từ -cover: coverage: 75.0% of statements — biết 75% nhưng không biết phần nào thiếu.
Phủ theo hàm từ -func: chỉ đúng PhanLoai 80% (thiếu bac), ChiaAnToan 66.7% (thiếu b==0). Thêm một test cho nhánh bac:
$ go tool cover -func=cov2.out
PhanLoai 100.0% // sau khi thêm test nhánh "bac"
total 87.5%
Phủ tăng từ 75% lên 87.5% có mục tiêu — -func dẫn đường tới đúng nhánh cần thêm, không mò mẫm.
Cạm bẫy 100% — phần quan trọng nhất. Xét hàm có bug và một test đạt 100% phủ dòng:
func TinhGiam(gia, phanTram int) int {
return gia - gia*phanTram/100 // BUG: phanTram>100 -> giá ÂM
}
func TestTinhGiam(t *testing.T) {
_ = TinhGiam(1000, 10) // dòng CHẠY -> phủ 100%, nhưng KHÔNG assert
}
$ go test -cover
coverage: 100.0% of statements // phủ hoàn hảo!
$ go test -run TestChungMinhBug -v
TinhGiam(1000,150) = -500 // giá ÂM - bug!, phủ 100% không bắt được
Test đạt 100% phủ dòng — nhưng bug vẫn sống. TinhGiam(1000, 150) trả về -500 (giá âm, vô lý) vì hàm không chặn phần trăm > 100. Test chạy dòng đó (nên phủ 100%) nhưng không assert kết quả, nên nó không bao giờ phát hiện giá trị sai. Đây là bài học cốt lõi: coverage đo dòng được chạy, không đo dòng được kiểm đúng. Một test rác gọi mọi hàm mà không kiểm gì cũng đạt 100%.

Hình 2: Đo thật — -cover cho 75% tổng, -func chỉ đúng hàm yếu (PhanLoai 80%, ChiaAnToan 66.7%), thêm test nhánh nâng lên 87.5% có mục tiêu; và một test 100% phủ dòng vẫn để lọt bug TinhGiam(1000,150) = -500 vì không assert.
Đánh đổi cần cân nhắc
Đừng biến % coverage thành mục tiêu tự thân. Khi đội đặt "phải đạt 90% coverage" làm KPI, phản ứng thường là viết test rác để nâng số — gọi hàm mà không assert, hoặc test những getter/setter tầm thường để bù cho logic phức tạp chưa test. Coverage cao do test tốt là tín hiệu tốt; coverage cao để đạt chỉ tiêu là ảo giác an toàn. Dùng coverage để tìm chỗ chưa test (điểm mạnh thật của nó), không phải để chứng minh code đã tốt.
Phủ nhánh khác phủ dòng — Go đo phủ câu lệnh. go test -cover mặc định đo phủ câu lệnh (statement), không phải phủ nhánh (branch) đầy đủ hay phủ điều kiện. Một dòng if a && b được tính là phủ khi nó chạy, dù bạn chưa test đủ mọi tổ hợp a/b. Với logic điều kiện phức tạp, phủ câu lệnh 100% vẫn có thể bỏ sót tổ hợp — cần thiết kế test theo bảng quyết định, không dựa vào con số phủ.
Coverage tích hợp (từ Go 1.20) cho phủ cả binary tích hợp. Ngoài phủ unit test, Go 1.20+ cho đo coverage của binary chạy thật (integration test, e2e) bằng go build -cover rồi thu thập từ GOCOVERDIR. Điều này cho bức tranh phủ toàn diện hơn — code chỉ chạy qua đường tích hợp cũng được tính — nhưng phức tạp hơn để thiết lập. Hữu ích khi unit test không phủ được các đường thật sự chạy trong production.
Ba ý mang về
go tool cover -funcbiến coverage từ con số mơ hồ thành bản đồ hành động: thay vì chỉ "75%", nó chỉ đúng hàm/nhánh nào yếu (PhanLoai 80%thiếu bac,ChiaAnToan 66.7%thiếu b==0) — đo thật, thêm test đúng nhánh nâng phủ lên 87.5% có mục tiêu;-htmltô màu trực quan nhất.- 100% phủ dòng KHÔNG bằng test tốt: đo thật, một test đạt 100% coverage vẫn để lọt bug
TinhGiam(1000,150) = -500(giá âm) vì nó chạy dòng nhưng không assert — coverage đo dòng được chạy, không đo dòng được kiểm đúng. - Dùng coverage đúng cách: để tìm chỗ chưa test chứ không để chứng minh code tốt (đừng biến % thành KPI dẫn tới test rác); nhớ Go đo phủ câu lệnh không phải phủ nhánh đầy đủ, và Go 1.20+ đo được cả coverage từ binary tích hợp.
Phần sau ta chuyển sang an ninh: govulncheck trong Go — quét lỗ hổng đã biết trong phụ thuộc, và điểm thông minh là nó chỉ báo lỗ hổng mà code bạn thực sự gọi tới, giảm nhiễu cảnh báo.