Unit test tốt: đặt tên, cấu trúc và độ phủ

1. Tổng quan

Unit test tốt không chỉ là test “chạy pass”. Nó phải dễ đọc, dễ bảo trì, và khi fail phải cho bạn biết ngay vấn đề ở đâu mà không cần debug cả buổi. Bài này đi qua ba trụ cột: đặt tên test sao cho tự giải thích, cấu trúc test theo AAA (Arrange-Act-Assert), và cách nhìn đúng về độ phủ (coverage) để không rơi vào bẫy “100% coverage nhưng vô dụng”.

Đọc xong, bạn sẽ có một checklist cụ thể để tự review test của mình trước khi tạo pull request.

2. Dependency cần thiết

Ví dụ trong bài dùng JUnit 5 và AssertJ. Nếu project Spring Boot, spring-boot-starter-test đã bao gồm cả hai.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. Đặt tên test: tên phải kể được câu chuyện

Tên test là tài liệu đầu tiên người khác đọc khi test fail. Một tên tốt trả lời được ba câu hỏi: đang test gì, trong điều kiện nào, và kỳ vọng gì.

3.1. Mẫu methodName_condition_expectedResult

class OrderServiceTest {

    @Test
    void calculateTotal_whenCartIsEmpty_returnsZero() {
        OrderService service = new OrderService();
        Order order = new Order(List.of());

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.ZERO);
    }

    @Test
    void calculateTotal_whenCartHasDiscountedItems_appliesDiscount() {
        OrderService service = new OrderService();
        Order order = new Order(List.of(
                new Item("SKU-1", BigDecimal.valueOf(100), 0.1)
        ));

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.valueOf(90.0));
    }
}

Giải thích:

  • Tên bắt đầu bằng method đang test (calculateTotal), rồi when... nêu điều kiện, cuối cùng là kết quả mong đợi.
  • Khi test fail trong report CI, bạn đọc tên là biết luôn lỗi nằm ở nhánh logic nào — không cần mở code.

Lưu ý: tránh tên như test1(), testCalculateTotal(). Chúng không nói gì về ngữ cảnh, buộc người đọc phải vào thân test mới hiểu được gì đang bị kiểm tra.

3.2. @DisplayName khi tên method không đủ diễn đạt

Với business rule phức tạp, tên method dài khó đọc. Dùng @DisplayName để viết câu tự nhiên hơn, giữ tên method ngắn cho code điều hướng.

@Test
@DisplayName("Khi khách hàng là VIP và đơn hàng trên 1 triệu, giảm thêm 5%")
void calculateTotal_vipCustomerLargeOrder_extraDiscount() {
    // ...
}

Output khi chạy với Maven Surefire hoặc IDE sẽ hiển thị câu @DisplayName thay cho tên method, dễ đọc trong report.

4. Cấu trúc AAA: Arrange – Act – Assert

Mỗi test nên có ba phần rõ ràng, theo đúng thứ tự, không trộn lẫn. Việc này giúp người đọc quét nhanh: dòng nào chuẩn bị dữ liệu, dòng nào gọi hành vi, dòng nào kiểm tra kết quả.

@Test
void withdraw_whenBalanceIsSufficient_deductsAmount() {
    // Arrange
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));

    // Act
    account.withdraw(BigDecimal.valueOf(200));

    // Assert
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(300));
}

Giải thích:

  • Arrange tạo đối tượng Account với số dư ban đầu.
  • Act gọi đúng một hành động đang test (withdraw).
  • Assert kiểm tra kết quả, dùng AssertJ cho câu assert dễ đọc như văn xuôi.

4.1. Một test, một hành vi

Test càng “làm nhiều việc” càng khó debug khi fail. Tránh viết test kiểu này:

@Test
void accountOperations() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));
    account.deposit(BigDecimal.valueOf(100));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(600));

    account.withdraw(BigDecimal.valueOf(200));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));

    account.withdraw(BigDecimal.valueOf(1000)); // lỗi: không đủ tiền
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
}

Nếu assert đầu tiên fail, hai hành vi sau không chạy — bạn mất thông tin. Tách thành ba test riêng: deposit_..., withdraw_sufficientBalance_..., withdraw_insufficientBalance_throwsException.

4.2. Test exception phải kiểm tra đúng loại và message

@Test
void withdraw_whenBalanceIsInsufficient_throwsIllegalStateException() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(100));

    assertThatThrownBy(() -> account.withdraw(BigDecimal.valueOf(500)))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("Insufficient balance");
}

Dòng assertThatThrownBy gọi hành động và bắt exception trong một bước. Không nên dùng @Test(expected = ...) kiểu cũ vì nó không cho phép kiểm tra tiếp message hay state sau exception.

5. Dùng @ParameterizedTest để giảm lặp code

Khi nhiều input khác nhau cùng kiểm tra một logic, dùng parameterized test thay vì copy-paste nhiều method.

@ParameterizedTest
@CsvSource({
    "0, 0",
    "100, 10",
    "1000, 100",
    "-50, 0"
})
void calculateShippingFee_variousOrderAmounts_returnsExpectedFee(
        BigDecimal orderAmount, BigDecimal expectedFee) {

    ShippingCalculator calculator = new ShippingCalculator();

    BigDecimal fee = calculator.calculateFee(orderAmount);

    assertThat(fee).isEqualTo(expectedFee);
}

Giải thích:

  • @CsvSource cung cấp bộ input/output, mỗi dòng là một test case độc lập.
  • Report sẽ hiển thị từng case riêng, fail case nào biết ngay case nào, không cần đoán.
calculateShippingFee_variousOrderAmounts_returnsExpectedFee(BigDecimal, BigDecimal)
  ✓ [1] 0, 0
  ✓ [2] 100, 10
  ✗ [3] 1000, 100
  ✓ [4] -50, 0

6. Mock đúng mức, không mock quá tay

Dùng mock cho dependency bên ngoài (database, HTTP client, service khác), không mock chính đối tượng đang test.

@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private PaymentService paymentService;

    @Test
    void processPayment_whenGatewayApproves_marksOrderAsPaid() {
        Order order = new Order("ORD-01", BigDecimal.valueOf(200));
        when(paymentGateway.charge(order.getAmount())).thenReturn(ApprovalResult.approved());

        PaymentResult result = paymentService.processPayment(order);

        assertThat(result.isSuccess()).isTrue();
        verify(paymentGateway).charge(BigDecimal.valueOf(200));
    }
}

Giải thích:

  • @Mock tạo đối tượng giả cho PaymentGateway — thứ gọi ra bên ngoài (có thể là API thật).
  • @InjectMocks inject mock đó vào PaymentService đang test.
  • verify xác nhận charge được gọi đúng tham số, không chỉ kiểm tra kết quả trả về.

Lưu ý: nếu bạn phải mock hơn 3-4 dependency cho một test, đó là dấu hiệu class đang làm quá nhiều việc. Cân nhắc tách nhỏ class trước khi tách nhỏ test.

7. Độ phủ (coverage): chỉ số tham khảo, không phải mục tiêu

Coverage cao không đồng nghĩa test tốt. Ví dụ test sau đạt 100% line coverage cho method divide nhưng không phát hiện được bug:

public int divide(int a, int b) {
    return a / b;
}
@Test
void divide_returnsQuotient() {
    Calculator calculator = new Calculator();

    int result = calculator.divide(10, 2);

    assertThat(result).isEqualTo(5);
}

Test này chạy qua toàn bộ dòng code (100% coverage) nhưng không test case b = 0 — nơi ArithmeticException sẽ bị throw ở production. Coverage chỉ đo được dòng nào đã chạy, không đo được đã kiểm tra đúng chưa.

7.1. Dùng coverage để tìm vùng mù, không để báo cáo đẹp

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
    </executions>
</plugin>

Chạy mvn test, JaCoCo sinh report HTML tại target/site/jacoco/index.html. Dùng report này để trả lời câu hỏi “nhánh nào chưa có test chạm vào”, rồi tự hỏi thêm “nhánh đó có logic quan trọng không” — không chạy theo con số % tuyệt đối.

Element           Missed Instructions  Cov.
Calculator        12 of 40             70%
  divide()        4 of 8               50%  ← chưa test case b=0

7.2. Test case biên (edge case) quan trọng hơn số %

Bổ sung test cho case bị bỏ sót ở trên:

@Test
void divide_whenDivisorIsZero_throwsArithmeticException() {
    Calculator calculator = new Calculator();

    assertThatThrownBy(() -> calculator.divide(10, 0))
            .isInstanceOf(ArithmeticException.class);
}

Một dòng test này có giá trị hơn nhiều so với việc viết thêm 5 test lặp lại case bình thường chỉ để đẩy % coverage lên cao hơn.

8. Checklist tự review test

Trước khi tạo pull request, tự kiểm tra:

  • Tên test có nói rõ điều kiện và kỳ vọng không, hay chỉ là test1, testX?
  • Mỗi test có đúng một hành vi, một lý do để fail?
  • Có test case biên: null, empty, 0, giá trị âm, giới hạn trên?
  • Có test case lỗi (exception) không, hay chỉ test happy path?
  • Coverage thấp ở đâu — là logic quan trọng bị bỏ sót, hay chỉ là getter/setter không cần test?

9. Kết luận

Unit test tốt là test mà khi fail, bạn biết ngay vấn đề nằm ở đâu mà không cần debug. Đặt tên theo mẫu method_condition_expected, giữ cấu trúc AAA rõ ràng với một hành vi mỗi test, và dùng coverage như công cụ tìm vùng mù thay vì mục tiêu cần đạt — đó là ba nguyên tắc đơn giản nhưng đủ để nâng chất lượng test suite của bạn lên một bậc. Phần khó nhất không phải là viết assert, mà là chịu dành thời gian nghĩ về case biên mà code của bạn có thể gặp ở production.

testingjunitjavabest-practicesspring-boot