Backend

99 Ngày Spring — Ngày 59: Coverage & chất lượng

SSite Admin
24 tháng 09, 2026 11 phút đọc 9 lượt xem
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ỏ.

Sketchnote Ngày 59: coverage và chất lượng — ba bước JaCoCo với check làm đỏ build, line khác branch coverage, coverage không đo được assert và mutation testing PITest, kim tự tháp test với Surefire và Failsafe, đo gì và đừng đo gì

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-agent gắn agent vào Surefire, report sinh HTML, check là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ới excludes cho *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ó jacocoTestCoverageVerification tương đương; nối vào check để gradle build tự 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 xanh
  • Test không assert là kẻ ăn coverage lớn nhất — cùng với assertNotNull suô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.

  • @ParameterizedTest với @CsvSource là 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; mutationThreshold chỉ 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 test là vòng lặp dev không cần Docker, mvn verify là cổng CI với jacoco: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-plugin với check ở ngưỡng LINE 0.80 cho package service — chạy mvn verify và 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:mutationCoverage trê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à đo mvn test nhanh hơn bao nhiêu so với mvn 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!

S

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.

Bài viết liên quan

99 Ngày Spring — Ngày 60: Tổng kết chiến lược test

Một chiến lược test cho service thật: mỗi lớp một câu hỏi và tỉ lệ 300 unit, 40 slice, 8 hành trình; fake có hành vi và WireMock ở biên giới; Surefire/Failsafe, Awaitility, Clock và chính sách test chập chờn — cùng checklist 12 câu để review test.

25 thg 9, 202611 phút0
99 Ngày Java — Ngày 60: Stream thực chiến

groupingBy hai cấp với TreeMap, flatMap xuống dòng hàng, top N bằng collectingAndThen, teeing cho min và max, toMap có merge giữ thứ tự — rồi khi nào đặt tên collector, dùng record thay tuple hay quay về vòng for, và một báo cáo từ CSV khép lại Giai đoạn 6.

25 thg 9, 202611 phút0
99 Ngày Java — Ngày 59: Parallel stream

Parallel stream là chia, làm, ghép trên ForkJoinPool chung của cả JVM: nguồn nào chia tốt, phép ghép nào đắt, mô hình N × Q để biết khi nào có lãi, vì sao I/O không bao giờ thuộc về parallel stream — và bẫy stateful lambda chỉ hỏng khi song song, cùng cách đo bằng JMH.

24 thg 9, 202610 phút4