Độ 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.

Ảnh chụp đoạn mã Go nền tối minh hoạ coverage nâng cao trong Go đo phủ theo hàm và vì sao 100 phần trăm không đủ, ba mức đo độ phủ go test cover phần trăm 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, hàm nhiều nhánh dễ bỏ sót func PhanLoai diem int string if diem lớn hơn bằng 1000 return vang if diem lớn hơn bằng 500 return bac nhánh dễ quên test return thuong, func chỉ ra hàm nào phủ yếu go test coverprofile cov.out go tool cover func cov.out PhanLoai 80.0 phần trăm thiếu nhánh bac ChiaAnToan 66.7 phần trăm thiếu nhánh b bằng 0 total 75.0 phần trăm func cho biết đi đâu để tăng phủ không chỉ 1 con số tổng, cạm bẫy 100 phần trăm phủ dòng không bằng test tốt func TinhGiam gia phanTram int int return gia trừ gia nhân phanTram chia 100 BUG phanTram lớn hơn 100 giá âm func TestTinhGiam t testing T gạch dưới bằng TinhGiam 1000 10 dòng chạy phủ 100 phần trăm nhưng không assert bug lọt lưới coverage đo dòng được chạy không đo dòng được kiểm đú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%.

Ảnh chụp bảng kết quả đo thật nền tối coverage nâng cao trong Go chạy bằng go test cover cộng go tool cover func Go 1.23 arm64, 1 go test cover một con số tổng go test cover coverage 75.0 phần trăm of statements biết 75 phần trăm nhưng không biết phần nào chưa phủ, 2 go tool cover func phủ theo từng hàm PhanLoai 80.0 phần trăm thiếu nhánh bac diem 500 tới 999 ChiaAnToan 66.7 phần trăm thiếu nhánh b bằng 0 total 75.0 phần trăm chỉ ra chính xác hàm nhánh nào cần thêm test, 3 bổ sung test cho nhánh thiếu phủ tăng go tool cover func cov2.out PhanLoai 100.0 phần trăm sau khi thêm test nhánh bac total 87.5 phần trăm func dẫn đường thêm đúng test phủ tăng có mục tiêu, 4 cạm bẫy 100 phần trăm phủ dòng nhưng bug vẫn sống go test cover coverage 100.0 phần trăm of statements phủ hoàn hảo go test run TestChungMinhBug v TinhGiam 1000 150 bằng âm 500 giá âm bug phủ 100 phần trăm không bắt được test gọi hàm dòng chạy 100 phần trăm nhưng không assert kết quả coverage đo dòng chạy không đo dòng được kiểm đúng, cốt lõi cover phần trăm câu lệnh phủ tổng package một con số func phủ theo từng hàm chỉ chỗ yếu để thêm test html tô màu dòng chạy chưa chạy trực quan nhất đo thật 75 phần trăm thêm test nhánh 87.5 phần trăm func chỉ đúng chỗ cạm bẫy 100 phần trăm phủ dòng không bằng test tốt bug âm 500 vẫn lọt đánh đổi coverage đo dòng chạy không đo kiểm đúng đừng thờ phần trăm mù

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ề

  1. go tool cover -func biế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; -html tô màu trực quan nhất.
  2. 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.
  3. 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.