Chiến lược test: test cái gì, ở tầng nào

4 câu hỏi~20 phútTừ khóa: test pyramid, testing trophy, unit, integration, E2E, test behavior, coverage, AAA, Given-When-Then, naming

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 đề

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)

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ầnRủi ro chínhKiểu testBài
DiscountPolicySai công thức, biên (0%, tier VIP)Unit thuần, parameterized5.2
OrderService (luồng nghiệp vụ)Hết hàng, payment fail, eventUnit + mock ở biên (PaymentClient)5.3
OrderControllerStatus code, validation, JSON@WebMvcTest4.2.13
OrderRepositoryQuery, mapping, constraint@DataJpaTest + PostgreSQL thật5.4, 5.5
PaymentClientURL, timeout, parse lỗi@RestClientTest / WireMock5.4
Cả luồng tạo orderGhép nối, transaction@SpringBootTest + Testcontainers5.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);
}

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

UnitIntegrationE2E
Tốc độmsgiây (start context, container)phút
Phạm vi1 class, logic thuầnSpring + DB/HTTP thậtNhiều service, UI/API
Bắt đượcLogic, biênSQL, mapping, config, transactionLuồng nghiệp vụ, deploy
NhượcMock nhiều thì không thậtChậm hơn, cần DockerFlaky, khó debug

7. Khi nào nên dùng / không nên

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

"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.

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


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.