StepVerifier: test code reactive cho chắc

4 câu hỏi~25 phútTừ khóa: StepVerifier, expectNext, verifyComplete, virtual time, withVirtualTime, thenAwait, WebTestClient, @WebFluxTest

Bài cuối nhé: code trả Mono/Flux thì viết unit test kiểu gì? assertEquals đâu có dùng được.

Đúng, assertEquals(mono, ...) vô nghĩa vì Mono chỉ là "công thức", chưa chạy. Bài này dùng StepVerifier (reactor-test) và WebTestClient.

1. Bài toán: test checkout của shop-app

Cần chứng minh: CheckoutService.view() gộp đúng 3 kết quả; payment lỗi thì fallback; retry 3 lần với backoff; controller trả JSON đúng.

2. Vấn đề

3. Giải pháp

4. Code từng bước

Bước 1: dependency

<dependency>
  <groupId>io.projectreactor</groupId>
  <artifactId>reactor-test</artifactId>
  <scope>test</scope>
</dependency>

Bước 2: test Mono/Flux cơ bản

@Test
void viewCombinesThreeServices() {
    when(userClient.get(42L)).thenReturn(Mono.just(user));
    when(orderClient.getCart(42L)).thenReturn(Mono.just(cart));
    when(paymentClient.methods(42L)).thenReturn(Mono.error(new RuntimeException("down")));

    StepVerifier.create(checkoutService.view(42L))
        .assertNext(v -> {
            assertThat(v.user()).isEqualTo(user);
            assertThat(v.payment()).isEqualTo(PaymentInfo.empty()); // fallback
        })
        .verifyComplete();
}

@Test
void statusStreamEmitsInOrder() {
    StepVerifier.create(Flux.just("PENDING", "PAID", "SHIPPED"))
        .expectNext("PENDING", "PAID")
        .expectNext("SHIPPED")
        .verifyComplete();
}

@Test
void userNotFoundPropagatesError() {
    when(userClient.get(1L)).thenReturn(Mono.error(new UserNotFoundException(1L)));
    StepVerifier.create(checkoutService.view(1L))
        .expectError(UserNotFoundException.class)
        .verify();
}

Bước 3: virtual time cho retry/backoff

@Test
void retriesThreeTimesWithBackoff() {
    StepVerifier.withVirtualTime(() ->                     // tạo publisher BÊN TRONG supplier
            paymentClient.charge(order)
                .retryWhen(Retry.backoff(3, Duration.ofSeconds(2))))
        .expectSubscription()
        .thenAwait(Duration.ofMinutes(1))                  // tua thời gian ảo
        .expectError()                                     // sau 3 lần retry vẫn lỗi
        .verify(Duration.ofSeconds(1));                    // chặn treo test thật
}

Bước 4: WebTestClient cho controller

@WebFluxTest(CheckoutController.class)
class CheckoutControllerTest {
    @Autowired WebTestClient client;
    @MockitoBean CheckoutService checkoutService;   // Boot 3.4+; bản cũ dùng @MockBean

    @Test
    void returnsCheckoutView() {
        when(checkoutService.view(42L)).thenReturn(Mono.just(view));
        client.get().uri("/checkout/42")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.user.id").isEqualTo(42);
    }
}

Bước 5: chạy

mvn test -Dtest=CheckoutServiceTest,CheckoutControllerTest

5. Cách hoạt động

StepVerifier.create(pub)
   | subscribe(pub)  (request theo kịch bản, mặc định unbounded)
   | onNext(a)  → khớp expectNext(a)?        ✔
   | onNext(b)  → khớp expectNext(b)?        ✔
   | onComplete → khớp verifyComplete()?     ✔
   | lệch bất kỳ bước nào → AssertionError kèm tín hiệu thực tế

withVirtualTime:
   VirtualTimeScheduler thay Schedulers.parallel()
   thenAwait(1 phút) → các delay/backoff hết hạn ngay → test chạy vài ms

6. So sánh

.block() + assertStepVerifierWebTestClient
TầngServiceService, operatorController / HTTP
Kiểm tra complete/error/thứ tựHạn chếĐầy đủTheo HTTP response
Thời gian ảoKhôngCóKhông
Dùng khiMono rất đơn giảnMặc định cho Mono/FluxTest endpoint, SSE

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

Sao virtual time phải bọc trong lambda, truyền thẳng Mono vào không được à?

Đúng chỗ hay sai nhất. Operator như delay, Retry.backoff lấy scheduler lúc Mono được tạo. withVirtualTime phải cài VirtualTimeScheduler trước, rồi mới gọi supplier để tạo Mono. Nếu bạn tạo Mono ở ngoài, nó đã gắn scheduler thật, và thenAwait không có tác dụng: test sẽ chờ thật hoặc treo.

Mono<String> m = Mono.delay(Duration.ofHours(1)).thenReturn("x");
StepVerifier.withVirtualTime(() -> m)                  // ❌ treo 1 tiếng
StepVerifier.withVirtualTime(() -> Mono.delay(Duration.ofHours(1)).thenReturn("x")) // ✅
    .expectSubscription()
    .expectNoEvent(Duration.ofMinutes(59))
    .thenAwait(Duration.ofMinutes(1))
    .expectNext("x")
    .verifyComplete();

Còn test Flux vô hạn như SSE trạng thái đơn hàng thì làm sao, nó đâu có complete?

Dùng thenCancel(): kiểm tra vài phần tử đầu rồi hủy subscription.

StepVerifier.create(orderClient.statusStream(7L))
    .expectNext(new OrderStatus("PENDING"))
    .expectNext(new OrderStatus("PAID"))
    .thenCancel()
    .verify();

Với endpoint SSE, WebTestClient có .returnResult(OrderStatus.class).getResponseBody() trả về Flux, rồi đưa tiếp vào StepVerifier.

Phỏng vấn hỏi test code reactive thì trả lời sao?

Q1. Test Mono/Flux thế nào?

🇻🇳 Tiếng Việt:

Em dùng StepVerifier trong reactor-test. Nó subscribe vào publisher và kiểm tra từng tín hiệu theo thứ tự: expectNext, assertNext, expectError, rồi verifyComplete hoặc verify. Với Flux vô hạn em dùng thenCancel. Hạn chế block() trong test vì không kiểm tra được complete, error hay thứ tự.

🇬🇧 English:

I use StepVerifier from reactor-test. It subscribes to the publisher and checks each signal in order with expectNext, assertNext and expectError, then verifyComplete or verify. For infinite Flux I use thenCancel. I avoid block in tests, because it cannot check completion, errors or ordering.

Q2. Test code có delay hoặc retry backoff mà không chờ thật?

🇻🇳 Tiếng Việt:

Dùng StepVerifier.withVirtualTime, tạo publisher bên trong supplier để nó dùng VirtualTimeScheduler, rồi thenAwait hoặc expectNoEvent để tua thời gian. Test một giờ backoff chạy trong vài mili giây. Không áp dụng được cho code blocking thật như Thread.sleep.

🇬🇧 English:

I use StepVerifier.withVirtualTime and create the publisher inside the supplier so it picks up the VirtualTimeScheduler, then thenAwait or expectNoEvent to advance the clock. An hour of backoff runs in milliseconds. It does not work for real blocking code such as Thread.sleep.

Q3. Test controller WebFlux thế nào?

🇻🇳 Tiếng Việt:

Dùng @WebFluxTest với WebTestClient, mock service, gọi endpoint và assert status, header, JSON. Với integration test thì @SpringBootTest với WebTestClient trỏ vào server thật, có thể dùng Testcontainers cho PostgreSQL R2DBC.

🇬🇧 English:

I use @WebFluxTest with WebTestClient, mock the service, call the endpoint and assert the status, headers and JSON. For integration tests I use @SpringBootTest with WebTestClient bound to the running server, often with Testcontainers for PostgreSQL over R2DBC.

Cách nhớ ngắn gọn


Ôn tập chương Spring Reactive

BàiChủ đềÝ chính cần nhớ
4.5.1Vì sao cần reactiveThread-per-request tốn thread khi chờ I/O; async bằng thread pool chỉ dời chỗ chờ.
4.5.2Event loop & non-blocking I/OVài thread + NIO selector phục vụ nhiều connection; không bao giờ block event loop.
4.5.3Mono & Flux0..1 và 0..N phần tử; lazy, không subscribe thì không chạy.
4.5.4Operator hay dùngmap đồng bộ, flatMap bất đồng bộ, zip gộp song song, filter, switchIfEmpty.
4.5.5Lỗi, timeout, retryonErrorResume/Return, timeout mọi lời gọi ngoài, retry có backoff.
4.5.6BackpressureSubscriber request(n); buffer, drop, latest, limitRate.
4.5.7SchedulerssubscribeOn = nguồn, publishOn = phía sau; blocking → boundedElastic; BlockHound.
4.5.8WebFlux & WebClientHandler trả Mono/Flux; WebClient có timeout; Mono.zip gọi song song; SSE.
4.5.9R2DBCJDBC blocking; R2DBC non-blocking, không ORM; transaction theo Reactor Context.
4.5.10MVC, WebFlux hay virtual threadsCRUD + JPA → MVC + VT; gateway, streaming, backpressure → WebFlux.
4.5.11StepVerifier (bài này)StepVerifier, virtual time, WebTestClient.

Tự kiểm tra

Quên gọi .verify() / .verifyComplete() thì sao?

StepVerifier không subscribe, không kiểm tra gì, test luôn "pass" giả.

Vì sao tạo publisher bên trong supplier của withVirtualTime?

Để operator thời gian lấy VirtualTimeScheduler lúc được tạo.

Test Flux không bao giờ complete thế nào?

Kiểm tra vài phần tử đầu rồi thenCancel().verify().

expectNoEvent(Duration) dùng để làm gì?

Khẳng định trong khoảng thời gian ảo đó không có tín hiệu nào (ví dụ chưa hết delay).

Test controller WebFlux không cần chạy server thật dùng gì?

@WebFluxTest + WebTestClient.