Chiến lược test: test cái gì, ở tầng nào
Bắt đầu phần Testing nhé. Mình hay viết code xong mới viết test cho đủ coverage, nhưng sửa code một chút là test đỏ cả loạt. Vậy test thế nào mới đúng?
Câu hỏi rất đúng chỗ. Test đỏ khi bạn chỉ refactor (không đổi hành vi) là dấu hiệu test đang bám vào cách code viết, không phải code làm gì. Cả phần Testing mình dùng một ví dụ: OrderService.createOrder() của shop-app trên PostgreSQL.
1. Bài toán: OrderService.createOrder
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
private final DiscountPolicy discountPolicy; // logic thuần
private final PaymentClient paymentClient; // gọi HTTP sang payment-service
private final ApplicationEventPublisher events; // publish OrderCreated
@Transactional
public Order createOrder(CreateOrderCommand cmd) {
Product p = productRepository.findById(cmd.productId())
.orElseThrow(() -> new ProductNotFoundException(cmd.productId()));
if (p.getStock() < cmd.quantity()) {
throw new OutOfStockException(p.getId()); // 1. check stock
}
Money total = discountPolicy.apply(p.getPrice().times(cmd.quantity()),
cmd.customerTier()); // 2. tính giá
p.decreaseStock(cmd.quantity());
Order order = orderRepository.save(Order.create(cmd.customerId(), p, cmd.quantity(), total));
PaymentResult r = paymentClient.charge(order.getId(), total); // 3. payment
if (!r.success()) throw new PaymentFailedException(order.getId());
events.publishEvent(new OrderCreated(order.getId())); // 4. event
return order;
}
}
Một method nhưng chạm tới: logic thuần (giảm giá), database, HTTP ra ngoài, event. Mỗi phần cần một kiểu test khác nhau.
2. Vấn đề
- Chỉ unit test với mock mọi thứ: test xanh nhưng query JPA sai, mapping cột sai, transaction không rollback… đều lọt lên production.
- Chỉ test E2E (bật cả hệ thống, gọi API): chậm, hay flaky, đỏ thì không biết lỗi ở đâu.
- Test bám implementation:
verify(productRepository).findById(1L),verify(discountPolicy).apply(...)… ĐổifindByIdsangfindByIdForUpdatelà đỏ, dù hành vi không đổi. - Chạy theo coverage: 90% line coverage nhưng không có assertion nào kiểm tra tổng tiền đúng.
3. Giải pháp: phân tầng test theo rủi ro
Test pyramid Testing trophy (Kent C. Dodds)
/\ ___
/E2E\ ít, chậm, đắt | E2E |
/------\ |-------|
/ integr. \ | integ. | ← nhiều nhất
/------------\ |--------|
/ unit \ nhiều, nhanh | unit |
/------------------\ |static| (compiler, lint, Sonar)
- Pyramid: nhiều unit test, ít integration, rất ít E2E. Hợp với domain logic nhiều.
- Trophy: dồn trọng tâm vào integration test, vì phần lớn bug nằm ở chỗ ghép nối (SQL, JSON, config). Hợp với service CRUD mỏng.
- Thực tế backend Spring: unit test cho logic thuần + integration test với DB thật (Testcontainers, bài 5.5) cho repository và luồng chính + vài E2E/contract test.
Nguyên tắc chung: test hành vi quan sát được (input → output, state trong DB, event được publish, lời gọi ra hệ thống ngoài), không test các bước nội bộ.
4. Code từng bước
Bước 1: Dependency. spring-boot-starter-test đã có JUnit 5, AssertJ, Mockito, Spring Test.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Bước 2: Chia test theo tầng
| Thành phần | Rủi ro chính | Kiểu test | Bài |
|---|---|---|---|
DiscountPolicy | Sai công thức, biên (0%, tier VIP) | Unit thuần, parameterized | 5.2 |
OrderService (luồng nghiệp vụ) | Hết hàng, payment fail, event | Unit + mock ở biên (PaymentClient) | 5.3 |
OrderController | Status code, validation, JSON | @WebMvcTest | 4.2.13 |
OrderRepository | Query, mapping, constraint | @DataJpaTest + PostgreSQL thật | 5.4, 5.5 |
PaymentClient | URL, timeout, parse lỗi | @RestClientTest / WireMock | 5.4 |
| Cả luồng tạo order | Ghép nối, transaction | @SpringBootTest + Testcontainers | 5.5, 5.6 |
Bước 3: Viết test theo AAA / Given-When-Then
@Test
void createOrder_appliesVipDiscount_andDecreasesStock() {
// Arrange / Given
Product keyboard = new Product(1L, "Keyboard", Money.of(100), 10);
productRepository.save(keyboard); // fake in-memory repo
var cmd = new CreateOrderCommand(42L, 1L, 2, CustomerTier.VIP);
// Act / When
Order order = orderService.createOrder(cmd);
// Assert / Then
assertThat(order.getTotal()).isEqualTo(Money.of(180)); // 200 - 10%
assertThat(productRepository.findById(1L).get().getStock()).isEqualTo(8);
}
- Một test = một hành vi. Tên test nói ra hành vi:
method_condition_expectedResulthoặc dùng@DisplayName(bài 5.2). - Assert vào kết quả (tổng tiền, tồn kho), không assert "đã gọi
discountPolicy.apply".
Bước 4: Chạy
./mvnw test # unit test (surefire), vài giây
./mvnw verify # + integration test (failsafe, *IT.java)
./mvnw test -Dtest=OrderServiceTest # chạy một class
5. Cách hoạt động: test bám hành vi vs bám implementation
Refactor: findById ──► findByIdForUpdate (thêm lock)
Test bám implementation Test bám hành vi
verify(repo).findById(1L) ✗ ĐỎ assert total == 180 ✓ XANH
assert stock == 8 ✓ XANH
→ phải sửa test dù không có bug → test chỉ đỏ khi hành vi sai
6. So sánh
| Unit | Integration | E2E | |
|---|---|---|---|
| Tốc độ | ms | giây (start context, container) | phút |
| Phạm vi | 1 class, logic thuần | Spring + DB/HTTP thật | Nhiều service, UI/API |
| Bắt được | Logic, biên | SQL, mapping, config, transaction | Luồng nghiệp vụ, deploy |
| Nhược | Mock nhiều thì không thật | Chậm hơn, cần Docker | Flaky, khó debug |
7. Khi nào nên dùng / không nên
- Logic có nhiều nhánh (giảm giá, trạng thái order) → unit test kỹ, nhiều case.
- Code chủ yếu đẩy dữ liệu xuống DB → integration test có giá trị hơn unit test mock repository.
- Không unit test getter/setter, mapper sinh tự động, config Spring đơn giản.
- Không viết E2E cho mọi case biên: để E2E cho vài happy path quan trọng.
Team mình đặt rule coverage phải trên 80%. Vậy coverage cao là test tốt đúng không?
Chưa đúng. Coverage chỉ đo dòng nào đã chạy qua, không đo có kiểm tra kết quả hay không.
@Test
void createOrder() {
orderService.createOrder(cmd); // chạy hết method → coverage 100%
} // không assert gì → sai giá vẫn xanh
- Coverage thấp là tín hiệu tốt: chỗ đó chắc chắn chưa được test.
- Coverage cao không chứng minh test tốt. Muốn đo chất lượng assertion thì dùng mutation testing (PIT): nó sửa code (đổi
<thành<=), nếu test vẫn xanh thì test yếu. - Ép 80% toàn repo dễ sinh test rác cho DTO, config. Hợp lý hơn: đặt gate trên code mới (new code coverage) và ưu tiên branch coverage ở domain logic. Phần quality gate có ở bài 5.8.
"Test behavior, không test implementation" nghe hay, nhưng paymentClient.charge() không trả gì quan trọng thì mình phải verify nó được gọi chứ? Vậy có mâu thuẫn không?
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: lời gọi ra khỏi hệ thống chính là hành vi quan sát được.
- Outgoing command ra biên (charge tiền, gửi email, publish event): verify là đúng, vì đó là tác dụng mà người dùng/hệ thống khác thấy.
- Lời gọi nội bộ (service gọi
DiscountPolicy, repositoryfindById): không verify, hãy assert kết quả cuối. - Query (hàm chỉ đọc, trả dữ liệu): stub để có input, không cần verify nó được gọi.
verify(paymentClient).charge(orderId, Money.of(180)); // OK: hiệu ứng ra ngoài
verify(discountPolicy).apply(any(), any()); // Không nên: chi tiết nội bộ
Chi tiết khi nào mock, khi nào không ở bài 5.3.
Phỏng vấn hỏi chiến lược test thì trả lời sao?
Q1. Bạn tổ chức test cho một service Spring Boot thế nào?
🇻🇳 Tiếng Việt:
Em chia theo rủi ro. Logic nghiệp vụ thuần như tính giảm giá thì unit test nhanh, nhiều case biên. Repository và luồng chính thì integration test với PostgreSQL thật qua Testcontainers, vì bug hay nằm ở SQL và mapping. Controller dùng @WebMvcTest. Client gọi service khác dùng @RestClientTest hoặc WireMock. Cuối cùng vài E2E/contract test cho happy path quan trọng.
🇬🇧 English:
I split tests by risk. Pure business logic such as discount calculation gets fast unit tests with many edge cases. Repositories and the main flow get integration tests against a real PostgreSQL through Testcontainers, because bugs often hide in SQL and mapping. Controllers use @WebMvcTest, HTTP clients use @RestClientTest or WireMock, and a few E2E or contract tests cover the critical happy paths.
Q2. Test pyramid và testing trophy khác nhau thế nào?
🇻🇳 Tiếng Việt:
Pyramid đặt nhiều unit test nhất, ít integration, rất ít E2E. Trophy dồn trọng tâm vào integration test vì phần lớn lỗi nằm ở chỗ ghép nối, và thêm tầng static analysis ở dưới. Với service nhiều logic em nghiêng về pyramid, service CRUD mỏng thì nghiêng về trophy.
🇬🇧 English:
The pyramid has the most unit tests, fewer integration tests and very few E2E tests. The trophy puts the weight on integration tests, because most bugs live at the boundaries, and adds static analysis at the base. For logic-heavy services I lean towards the pyramid, for thin CRUD services towards the trophy.
Q3. Coverage 90% có nghĩa là test tốt không?
🇻🇳 Tiếng Việt:
Không. Coverage chỉ đo dòng code đã chạy, không đo assertion. Test không assert vẫn cho coverage cao. Em dùng coverage để tìm chỗ chưa test, đặt gate trên code mới, và nếu cần đánh giá chất lượng test thì dùng mutation testing như PIT.
🇬🇧 English:
No. Coverage only measures which lines were executed, not whether anything was asserted. A test without assertions can still give high coverage. I use coverage to find untested code, gate it on new code, and use mutation testing such as PIT when I want to measure test quality.
Cách nhớ ngắn gọn
- Test hành vi quan sát được, không test bước nội bộ.
- Logic thuần → unit; SQL/mapping/config → integration với DB thật.
- Verify lời gọi ra biên; assert kết quả cho phần nội bộ.
- Coverage thấp là xấu, coverage cao chưa chắc tốt.
- Mỗi test: một hành vi, AAA, tên nói được hành vi.
Tự kiểm tra
Refactor không đổi hành vi mà test đỏ, nói lên điều gì?
Test đang bám implementation (verify lời gọi nội bộ, cấu trúc code) thay vì hành vi.
Repository query phức tạp nên test bằng unit test mock hay integration test?
Integration test với DB thật (@DataJpaTest + Testcontainers): mock không kiểm tra được SQL.
Khi nào verify một mock là hợp lý?
Khi đó là lệnh ra biên hệ thống có tác dụng phụ: charge payment, gửi email, publish event.
Công cụ nào đo được test có assertion đủ mạnh không?
Mutation testing (PIT): đột biến code, test vẫn xanh thì yếu.
AAA là gì?
Arrange – Act – Assert, tương ứng Given – When – Then.