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ồiwhen...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:
Arrangetạo đối tượngAccountvới số dư ban đầu.Actgọi đúng một hành động đang test (withdraw).Assertkiể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:
@CsvSourcecung 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:
@Mocktạo đối tượng giả choPaymentGateway— thứ gọi ra bên ngoài (có thể là API thật).@InjectMocksinject mock đó vàoPaymentServiceđang test.verifyxác nhậnchargeđượ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.