99 Ngày Spring — Ngày 59: Coverage & chất lượng
Chín ngày qua ta viết đủ loại test: unit, slice, tích hợp, Testcontainers, mock, security, dữ liệu. Hôm nay dừng lại hỏi câu khó hơn: bộ test đó tốt đến đâu, và đo bằng gì? Câu trả lời quen thuộc là coverage — nhưng coverage chỉ biết dòng nào đã chạy, không biết dòng nào được kiểm. Bài hôm nay: dựng JaCoCo đúng cách để chặn build chứ không chỉ vẽ biểu đồ, xem coverage không đo được gì và mutation testing bù vào chỗ đó thế nào, rồi xếp kim tự tháp test cho một service Spring và chọn bốn con số đáng theo dõi — cùng những con số nên bỏ.

JaCoCo — đo, báo cáo, và chặn build
JaCoCo là một Java agent cắm vào JVM chạy test, đếm từng lệnh bytecode đi qua. Cấu hình Maven có ba bước, và bước quan trọng nhất là bước nhiều dự án bỏ qua: check — làm đỏ build khi dưới ngưỡng. Không có nó, coverage là con số để ngắm trên dashboard. Ngưỡng nên đặt theo package nghiệp vụ, không theo cả dự án, và nên nhìn branch thay vì line: một câu if với hai điều kiện có bốn nhánh, mà một test đi qua một.
<!-- JaCoCo — đo coverage bằng Java agent: cắm vào JVM chạy test, đếm từng lệnh bytecode đã chạy qua. -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<!-- ① prepare-agent: gắn -javaagent vào Surefire → sinh target/jacoco.exec -->
<execution><goals><goal>prepare-agent</goal></goals></execution>
<!-- ② report: đọc jacoco.exec → target/site/jacoco/index.html (mở lên là thấy xanh đỏ vàng) -->
<execution>
<id>report</id><phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
<!-- ③ check: LÀM ĐỎ BUILD khi dưới ngưỡng — thiếu bước này, coverage chỉ là con số để ngắm -->
<execution>
<id>check</id><phase>verify</phase>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>PACKAGE</element> <!-- ⭐ ngưỡng theo PACKAGE nghiệp vụ, không theo cả dự án -->
<includes><include>vn.motdev.donhang.service.*</include></includes>
<limits>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.80</minimum></limit>
<limit><counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>0.70</minimum></limit>
</limits>
</rule>
</rules>
<excludes> <!-- ❗ loại thứ không đáng test khỏi mẫu số -->
<exclude>**/*Application.class</exclude>
<exclude>**/config/**</exclude>
<exclude>**/*Dto.class</exclude>
<exclude>**/generated/**</exclude>
</excludes>
</configuration>
</execution>
</executions>
</plugin>
<!-- BA CON SỐ trong report — và cái nào đáng nhìn:
INSTRUCTION lệnh bytecode đã chạy / tổng → mịn nhất, không phụ thuộc cách xuống dòng
LINE dòng có ít nhất một lệnh đã chạy → dễ đọc, dễ nói chuyện với team
BRANCH nhánh if / switch / && / || đã đi → ⭐ CÁI NÀY. 100% line vẫn có thể 25% branch:
if (don.tong() > 1_000_000 && khach.vip()) { giam(); }
một test "đơn 2 triệu của khách VIP" → LINE 100%, BRANCH 1/4 — ba nhánh chưa ai đi qua. -->
<!-- Gradle tương đương:
plugins { id 'jacoco' }
jacocoTestCoverageVerification { violationRules { rule { limit { minimum = 0.80 } } } }
check.dependsOn jacocoTestCoverageVerification -->prepare-agentgắn agent vào Surefire,reportsinh HTML,checklàm đỏ build — thiếu bước ba thì coverage không thay đổi hành vi của ai.Ngưỡng theo package (
service,domain) vớiexcludescho*Application,config, DTO, code sinh tự động — để mẫu số chỉ chứa thứ đáng test.Branch coverage là con số đáng nhìn:
if (a && b)có bốn nhánh, một test đi qua một nhánh vẫn cho line 100%.Gradle có
jacocoTestCoverageVerificationtương đương; nối vàocheckđểgradle buildtự chặn.
Coverage không đo được gì — và mutation testing
Một test gọi method mà không assert cho coverage 100% và không kiểm tra gì. Xóa thân method, test vẫn xanh. Đó là lỗ hổng cơ bản của coverage: nó đo code đã chạy, không đo test có nhận ra code sai. Mutation testing đo đúng cái đó: PITest cố tình sửa code — đổi > thành >=, && thành || — rồi chạy lại test; đột biến nào sống sót là chỗ test của bạn không kiểm. Kết quả thường gây sốc ở lần chạy đầu.
// COVERAGE KHÔNG ĐO ĐƯỢC GÌ — nó chỉ biết dòng nào ĐÃ CHẠY, không biết dòng đó có ĐƯỢC KIỂM không.
// Test này cho 100% coverage của tinhGiam() — và không kiểm tra gì cả:
@Test
void tinhGiam() {
dichVu.tinhGiam(new DonHang(2_000_000, true)); // ❌ không có assert → xanh mãi mãi
}
// Xóa toàn bộ thân tinhGiam() rồi "return 0;" → test vẫn xanh, coverage vẫn 100%.
// ⭐ MUTATION TESTING — PITest: sửa code của bạn một cách có chủ ý ("đột biến"), rồi chạy lại test.
// Test làm đột biến đỏ → "giết" được nó, test tốt. Đột biến SỐNG SÓT → test không kiểm chỗ đó.
int tinhGiam(DonHang d) {
if (d.tong() > 1_000_000 && d.khachVip()) return d.tong() / 10;
return 0;
}
// Đột biến 1: > → >= (Conditionals Boundary)
// Đột biến 2: && → || (Logical)
// Đột biến 3: /10 → *10 (Math)
// Đột biến 4: return 0 → return 1 (Return Values)
// Một test có assert nhưng chỉ một trường hợp "đơn 2 triệu, VIP → giảm 200.000":
// giết được 3 và 4 — nhưng 1 và 2 SỐNG SÓT: biên 1.000.000 và khách thường chưa bao giờ được kiểm.
// ✅ Bộ test giết hết bốn đột biến — mỗi dòng dữ liệu là một đột biến bị giết:
@ParameterizedTest
@CsvSource({ "2000000, true, 200000", // giảm
"1000000, true, 0", // ĐÚNG BIÊN: không giảm → giết đột biến 1
"2000000, false, 0", // khách thường → giết đột biến 2
"500000, true, 0" })
void tinhGiam(int tong, boolean vip, int mongDoi) {
assertThat(dichVu.tinhGiam(new DonHang(tong, vip))).isEqualTo(mongDoi);
}
// pom.xml — chạy tay hoặc nightly, vì chậm gấp 10–50 lần bộ test thường:
// <plugin><groupId>org.pitest</groupId><artifactId>pitest-maven</artifactId><version>1.17.0</version>
// <dependencies><dependency><groupId>org.pitest</groupId><artifactId>pitest-junit5-plugin</artifactId>
// <version>1.2.1</version></dependency></dependencies>
// <configuration><targetClasses><param>vn.motdev.donhang.service.*</param></targetClasses>
// <mutationThreshold>75</mutationThreshold></configuration></plugin>
// mvn org.pitest:pitest-maven:mutationCoverage → target/pit-reports/index.html
// ⭐ Không cần chạy PITest mỗi commit. Chạy trên package nghiệp vụ lõi, mỗi tuần, ĐỌC danh sách đột biến sống sót.
// Ba mùi của test "ăn coverage" mà không kiểm gì:
// ❌ không assert, hoặc chỉ assertNotNull ❌ verify(mock).goi() mà không kiểm kết quả trả về
// ❌ expected tính bằng CHÍNH công thức đang test — test và code sai giống nhau thì vẫn xanhTest không assert là kẻ ăn coverage lớn nhất — cùng với
assertNotNullsuông,verify(mock)không kiểm kết quả, và expected tính bằng chính công thức đang test.Đột biến sống sót chỉ thẳng vào test còn thiếu: biên
>/>=và nhánh&&là hai loại sống sót phổ biến nhất.@ParameterizedTestvới@CsvSourcelà cách rẻ nhất để giết đột biến biên: mỗi dòng dữ liệu là một đột biến bị giết.PITest chậm gấp 10–50 lần — chạy hằng tuần trên package lõi, không chạy mỗi commit;
mutationThresholdchỉ nên siết dần.
Kim tự tháp test, CI, và bốn con số đáng đo
Chín ngày test vừa rồi xếp thành ba tầng: nhiều unit test không context, vừa slice test, ít @SpringBootTest với Testcontainers. Tỉ lệ không cố định — service mỏng kiểu CRUD có thể nghiêng về tích hợp, đúng như "testing trophy" đề xuất — nhưng cách chạy thì nên cố định: Surefire cho tầng nhanh mỗi lần push, Failsafe cho tầng chậm trước khi merge. Và thay vì một con số coverage toàn dự án, hãy theo dõi coverage trên code mới, branch của package nghiệp vụ, thời gian chạy, và tỉ lệ flaky.
<!-- KIM TỰ THÁP TEST CHO MỘT SERVICE SPRING — và cách CI chạy từng tầng -->
<!-- Tầng 1 — NHIỀU: unit test, không có context Spring (Ngày 51). ~ mili-giây mỗi test
Tầng 2 — VỪA: slice test — @WebMvcTest (52), @DataJpaTest (53). ~ 1–3 giây nạp context, rồi cache
Tầng 3 — ÍT: @SpringBootTest + Testcontainers (54–55). ~ 10–30 giây, đúng vài luồng nghiệp vụ
Tỉ lệ thường thấy ở service khỏe: 70 / 20 / 10 theo SỐ test — nhưng 10 / 30 / 60 theo THỜI GIAN chạy.
⭐ "Testing trophy" (nhiều integration hơn unit) cũng hợp lý cho service MỎNG — CRUD + JPA, ít logic.
Không có tỉ lệ đúng tuyệt đối; chỉ có tỉ lệ đúng cho LƯỢNG LOGIC của bạn. -->
<!-- ① Surefire chạy unit + slice: *Test.java — mỗi lần push, không cần Docker -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludes><exclude>**/*IT.java</exclude></excludes>
</configuration>
</plugin>
<!-- ② Failsafe chạy integration: *IT.java — ở phase verify, cần Docker cho Testcontainers -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution><goals><goal>integration-test</goal><goal>verify</goal></goals></execution>
</executions>
</plugin>
<!-- mvn test → nhanh, không Docker: vòng lặp dev, chạy mỗi vài phút
mvn verify → cả hai tầng + jacoco:check + failsafe:verify: CI trước khi merge -->
<!-- ③ ĐO GÌ — bốn con số đáng theo dõi mỗi tuần:
• Coverage trên CODE MỚI (diff coverage): SonarQube "New Code" / Codecov "patch" ≥ 80%
→ chặn PR, không chặn cả repo. Code cũ tăng dần theo từng PR chạm vào nó.
• BRANCH coverage của package nghiệp vụ (service, domain) — không phải toàn dự án.
• Thời gian chạy bộ test: vượt 10 phút là bắt đầu có người bỏ qua nó.
• Tỉ lệ flaky: test đỏ rồi xanh khi chạy lại — mỗi cái là một lỗ hổng niềm tin.
ĐỪNG ĐO: 100% toàn dự án · getter / setter / record · @Configuration · Lombok · DTO
Goodhart: khi con số thành mục tiêu, nó thôi là thước đo — 90% coverage bằng test không assert
tệ hơn 60% bằng test có suy nghĩ, vì nó cho bạn sự tự tin mà bạn không có quyền có. -->Surefire chạy
*Test, Failsafe chạy*IT—mvn testlà vòng lặp dev không cần Docker,mvn verifylà cổng CI vớijacoco:check.Coverage trên code mới (diff coverage, SonarQube "New Code" hay Codecov "patch") chặn PR mà không bắt cả đội trả nợ code cũ trong một tuần.
Thời gian chạy và tỉ lệ flaky quyết định bộ test có được chạy hay bị bỏ qua — vượt mười phút hay đỏ-rồi-xanh là mất niềm tin.
Đừng đo 100% toàn dự án, getter,
@Configuration, Lombok, DTO — Goodhart: con số thành mục tiêu thì thôi là thước đo.
Bài tập nhỏ
Thêm
jacoco-maven-pluginvớicheckở ngưỡng LINE 0.80 cho packageservice— chạymvn verifyvà xem build đỏ hay xanh.Mở
target/site/jacoco/index.html, tìm class có line cao nhất nhưng branch thấp nhất — viết test cho nhánh còn thiếu.Viết một test gọi method mà không assert, chạy coverage, rồi xóa thân method — xác nhận test vẫn xanh.
Chạy
mvn org.pitest:pitest-maven:mutationCoveragetrên một package nhỏ; đọc ba đột biến sống sót đầu tiên và giết chúng.Đổi tên hai test tích hợp thành
*IT, cấu hình Failsafe, và đomvn testnhanh hơn bao nhiêu so vớimvn verify.
Kết lại
Bốn ý gói lại hôm nay: JaCoCo có ba bước và bước check làm đỏ build mới là bước biến coverage thành cam kết — đặt ngưỡng theo package nghiệp vụ, nhìn branch chứ không chỉ line; coverage không đo được assert, và PITest với các đột biến sống sót chỉ thẳng vào test còn thiếu; bộ test xếp thành kim tự tháp ba tầng chạy bằng Surefire và Failsafe, với tỉ lệ tùy theo lượng logic; và bốn con số đáng theo dõi là coverage trên code mới, branch của package lõi, thời gian chạy, tỉ lệ flaky — còn 100% toàn dự án là mục tiêu sai. Ngày 60 khép lại chặng testing bằng tổng kết chiến lược: chiến lược test cho một service thực tế và checklist review test. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


