Độ 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.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho hàm và kết quả go tool cover -func:
func PhanLoai(diem int) string {
if diem >= 1000 { return "vang" }
if diem >= 500 { return "bac" }
return "thuong"
}
PhanLoai 80.0%
Test hiện tại có thể đang thiếu kiểm tra nhánh nào? Vì sao chỉ nhìn con số tổng go test -cover (75%) không đủ để biết điều này?
Đáp án
Nhiều khả năng thiếu test cho nhánh "bac" (diem trong khoảng 500-999) — đúng như bài nêu, đây là nhánh dễ quên. Con số tổng từ -cover (75%) chỉ cho biết tỉ lệ phủ toàn package, không cho biết hàm/nhánh nào thiếu; phải dùng go tool cover -func để thấy chính xác từng hàm phủ bao nhiêu phần trăm, từ đó đoán ra nhánh còn thiếu.
Bài 2 (sửa lỗi / bẫy). Xét test sau:
func TinhGiam(gia, phanTram int) int {
return gia - gia*phanTram/100
}
func TestTinhGiam(t *testing.T) {
_ = TinhGiam(1000, 10)
}
Test này đạt 100% phủ dòng nhưng vẫn để lọt bug gì? Sửa lại test để bắt được bug đó.
Đáp án
Bug: hàm không chặn phanTram > 100, nên TinhGiam(1000, 150) trả về -500 (giá âm vô lý). Test gốc chạy dòng đó (nên phủ 100%) nhưng không assert kết quả nên không bao giờ phát hiện ra. Sửa:
func TestTinhGiam(t *testing.T) {
got := TinhGiam(1000, 10)
if got != 900 {
t.Errorf("TinhGiam(1000,10) = %d, muon 900", got)
}
// nên thêm: chặn hoặc kiểm tra phanTram > 100 không cho giá âm
if got := TinhGiam(1000, 150); got < 0 {
t.Errorf("TinhGiam(1000,150) = %d, gia khong duoc am", got)
}
}
Bài học: coverage đo dòng được chạy, không đo dòng được kiểm đúng — phải có assert mới bắt được bug.
Bài 3 (đọc hiểu). Sau khi thêm test cho nhánh bac, coverage tổng tăng từ 75% lên 87.5%. Việc tăng này có gì khác so với việc "viết thêm test bất kỳ để nâng số coverage lên 87.5%"?
Đáp án
Việc tăng ở đây là tăng có mục tiêu: -func chỉ đúng hàm/nhánh còn thiếu (PhanLoai thiếu nhánh bac), nên test thêm vào lấp đúng lỗ hổng logic thật. Ngược lại, nếu chỉ viết test rác gọi hàm mà không assert (như bug TinhGiam ở Bài 2) để nâng số coverage, con số tăng nhưng không phản ánh chất lượng kiểm thử thật — đó là ảo giác an toàn, đúng như bài cảnh báo về việc biến % coverage thành mục tiêu tự thân.
Bài 4 (vận dụng thực tế). Đội của bạn có một điều kiện phức tạp if a && b { ... } và coverage báo dòng này đã được phủ 100%. Điều đó có đảm bảo mọi tổ hợp giá trị của a và b đã được test chưa? Vì sao?
Đáp án
Không đảm bảo. Go đo phủ câu lệnh (statement), không phải phủ nhánh/điều kiện đầy đủ. Dòng if a && b được tính là phủ chỉ cần nó chạy ít nhất một lần (dù chỉ với một tổ hợp a/b), nên có thể chưa test đủ 4 tổ hợp (a đúng/sai kết hợp b đúng/sai). Với logic điều kiện phức tạp, cần thiết kế test theo bảng quyết định thay vì tin vào con số phủ.
Bài 5 (vận dụng thực tế / đánh đổi). Quản lý yêu cầu đội phải đạt "90% coverage" làm KPI bắt buộc. Nêu rủi ro của cách đặt mục tiêu này, và cách dùng coverage đúng theo bài viết.
Đáp án
Rủi ro: khi coverage trở thành KPI, phản ứng thường thấy là viết test rác để nâng số — gọi hàm mà không assert, hoặc test các getter/setter tầm thường để bù cho logic phức tạp chưa được test thật. Coverage cao do test tốt là tín hiệu tốt, nhưng coverage cao để đạt chỉ tiêu chỉ là ảo giác an toàn. Cách dùng đúng: dùng coverage (đặc biệt -func) để tìm chỗ chưa test — điểm mạnh thật của nó — chứ không dùng để chứng minh code đã tốt.
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.