MockMvc로 충분하다고 생각했는데, WebFlux 프로젝트에서는 왜 동작하지 않을까요?

MockMvc는 서블릿 기반 Spring MVC에 특화된 테스트 도구입니다. WebFlux(리액티브)에서는 동작하지 않습니다. WebTestClient는 리액티브 기반으로 설계되었지만 MVC에서도 사용할 수 있어, 두 세계를 아우르는 통합 테스트 도구입니다.

WebTestClient란

WebTestClient는 Spring WebFlux의 테스트 클라이언트로, HTTP 요청을 보내고 응답을 검증하는 플루언트 API를 제공합니다.

세 가지 바인딩 모드:

모드서버 필요용도
bindToController()불필요특정 컨트롤러 격리 테스트
bindToApplicationContext()불필요Spring 컨텍스트 기반 테스트
bindToServer()필요실제 HTTP 요청 (E2E)

WebFlux 컨트롤러 테스트

컨트롤러에 직접 바인딩

JAVA
class ProductControllerTest {

    private WebTestClient webTestClient;

    @BeforeEach
    void setUp() {
        ProductService mockService = mock(ProductService.class);
        when(mockService.findById(1L))
                .thenReturn(Mono.just(new Product(1L, "스프링", 30000)));
        when(mockService.findAll())
                .thenReturn(Flux.just(
                        new Product(1L, "스프링", 30000),
                        new Product(2L, "리액터", 25000)
                ));

        webTestClient = WebTestClient
                .bindToController(new ProductController(mockService))
                .build();
    }

Mock 서비스를 바인딩한 후 exchange()로 요청을 보내고 응답을 검증합니다.

JAVA
    @Test
    void 상품_단건_조회() {
        webTestClient.get()
                .uri("/api/products/{id}", 1L)
                .accept(MediaType.APPLICATION_JSON)
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.id").isEqualTo(1)
                .jsonPath("$.name").isEqualTo("스프링")
                .jsonPath("$.price").isEqualTo(30000);
    }

목록 조회는 expectBodyList()로 리스트 크기와 포함 여부를 검증합니다.

JAVA
    @Test
    void 상품_목록_조회() {
        webTestClient.get()
                .uri("/api/products")
                .exchange()
                .expectStatus().isOk()
                .expectBodyList(Product.class)
                .hasSize(2)
                .contains(new Product(1L, "스프링", 30000));
    }
}

Spring 컨텍스트 기반 테스트

JAVA
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductIntegrationTest {

    @Autowired
    private WebTestClient webTestClient;  // 자동 주입

RANDOM_PORT 모드에서는 실제 서버가 띄워지므로 전체 요청 흐름을 통합 테스트할 수 있습니다.

JAVA
    @Test
    void 상품_등록_통합_테스트() {
        ProductCreateRequest request = new ProductCreateRequest("새 상품", 15000);

        webTestClient.post()
                .uri("/api/products")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(request)
                .exchange()
                .expectStatus().isCreated()
                .expectBody()
                .jsonPath("$.name").isEqualTo("새 상품")
                .jsonPath("$.id").isNotEmpty();
    }
}

Spring MVC에서 WebTestClient 사용

Spring Boot 2.4+에서는 MVC 프로젝트에서도 WebTestClient를 사용할 수 있습니다.

XML
<!-- 테스트 의존성에 webflux 추가 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <scope>test</scope>
</dependency>
JAVA
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MvcIntegrationTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void MVC_컨트롤러를_WebTestClient로_테스트() {
        webTestClient.get()
                .uri("/api/products/1")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.name").isNotEmpty();
    }
}

MockMvc vs WebTestClient 비교

JAVA
// MockMvc 방식
mockMvc.perform(get("/api/products/1")
                .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.name").value("스프링"));

// WebTestClient 방식
webTestClient.get()
        .uri("/api/products/1")
        .accept(MediaType.APPLICATION_JSON)
        .exchange()
        .expectStatus().isOk()
        .expectBody()
        .jsonPath("$.name").isEqualTo("스프링");
기준MockMvcWebTestClient
기반서블릿 API리액티브 API
WebFlux 지원불가가능
MVC 지원가능가능 (webflux 의존성 필요)
실제 HTTP 요청불가가능 (RANDOM_PORT)
비동기 테스트제한적네이티브 지원

StepVerifier와 조합

서비스 계층에서 Mono/Flux를 반환하는 리액티브 코드를 테스트할 때 StepVerifier를 사용합니다.

JAVA
@Test
void 리액티브_서비스_테스트() {
    // Mono 검증
    Mono<Product> result = productService.findById(1L);

    StepVerifier.create(result)
            .assertNext(product -> {
                assertThat(product.getName()).isEqualTo("스프링");
                assertThat(product.getPrice()).isEqualTo(30000);
            })
            .verifyComplete();
}

Flux의 요소 개수를 확인하거나 에러 발생을 검증하는 것도 StepVerifier로 간결하게 작성할 수 있습니다.

JAVA
@Test
void Flux_결과_검증() {
    Flux<Product> result = productService.findByCategory("IT");

    StepVerifier.create(result)
            .expectNextCount(3)     // 3개 요소 방출 확인
            .verifyComplete();      // 정상 완료 확인
}

@Test
void 에러_발생_검증() {
    Mono<Product> result = productService.findById(999L);

    StepVerifier.create(result)
            .expectError(NotFoundException.class)
            .verify();
}

WebTestClient + StepVerifier

WebTestClient의 응답을 Flux로 받아 StepVerifier로 검증할 수도 있습니다.

JAVA
@Test
void SSE_스트림_테스트() {
    Flux<Product> result = webTestClient.get()
            .uri("/api/products/stream")
            .accept(MediaType.TEXT_EVENT_STREAM)
            .exchange()
            .expectStatus().isOk()
            .returnResult(Product.class)
            .getResponseBody();

    StepVerifier.create(result)
            .expectNextMatches(p -> p.getName() != null)
            .expectNextCount(4)
            .thenCancel()  // 스트림 취소
            .verify();
}

응답 본문 검증 패턴

JSON 상세 검증

JAVA
webTestClient.get()
        .uri("/api/products/1")
        .exchange()
        .expectStatus().isOk()
        .expectHeader().contentType(MediaType.APPLICATION_JSON)
        .expectBody()
        .jsonPath("$.id").isEqualTo(1)
        .jsonPath("$.name").isNotEmpty()
        .jsonPath("$.price").isNumber()
        .jsonPath("$.tags").isArray()
        .jsonPath("$.tags.length()").isEqualTo(3);

타입으로 역직렬화 검증

JAVA
webTestClient.get()
        .uri("/api/products/1")
        .exchange()
        .expectStatus().isOk()
        .expectBody(Product.class)
        .value(product -> {
            assertThat(product.getName()).isEqualTo("스프링");
            assertThat(product.getPrice()).isGreaterThan(0);
        });

리스트 검증

JAVA
webTestClient.get()
        .uri("/api/products")
        .exchange()
        .expectStatus().isOk()
        .expectBodyList(Product.class)
        .hasSize(5)
        .value(products -> {
            assertThat(products).extracting(Product::getName)
                    .contains("스프링", "리액터");
        });

에러 응답 테스트

JAVA
@Test
void 존재하지_않는_상품_조회시_404() {
    webTestClient.get()
            .uri("/api/products/999")
            .exchange()
            .expectStatus().isNotFound()
            .expectBody()
            .jsonPath("$.message").isEqualTo("상품을 찾을 수 없습니다")
            .jsonPath("$.code").isEqualTo("PRODUCT_NOT_FOUND");
}

잘못된 요청에 대한 400 에러 응답도 동일한 패턴으로 검증합니다.

JAVA
@Test
void 잘못된_요청_형식() {
    webTestClient.post()
            .uri("/api/products")
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue("{\"name\": \"\", \"price\": -1}")
            .exchange()
            .expectStatus().isBadRequest()
            .expectBody()
            .jsonPath("$.errors").isArray();
}

실무 팁

  • ** 새 프로젝트 **라면 MockMvc 대신 WebTestClient를 기본으로 사용하는 것을 추천합니다 (MVC/WebFlux 모두 지원)
  • exchange() 이후 반드시 ** 상태 코드를 먼저 검증 **하세요 (디버깅 편의)
  • 실제 서버 테스트(RANDOM_PORT)에서는 @Autowired WebTestClient를 사용하고, Mock 테스트에서는 bindToController()를 사용하세요
  • SSE나 WebSocket 같은 ** 스트리밍 응답 **은 returnResult().getResponseBody()로 Flux를 받아 StepVerifier로 검증하세요

주의할 점

1. MVC 프로젝트에서 WebTestClient를 사용하려면 webflux 테스트 의존성이 필요하다

Spring MVC 프로젝트에서 WebTestClient를 주입받으면 NoSuchBeanDefinitionException이 발생합니다. spring-boot-starter-webflux를 테스트 스코프에 추가해야 하는데, 이 사실을 모르면 "MVC에서는 MockMvc만 써야 하나?"라고 오해하기 쉽습니다.

2. bindToController() 모드에서는 GlobalFilter나 ControllerAdvice가 적용되지 않는다

컨트롤러에 직접 바인딩하면 Spring 컨텍스트 없이 해당 컨트롤러만 테스트합니다. 전역 예외 처리기(@ControllerAdvice)나 Security 필터가 동작하지 않아, 에러 응답 형식이 실제와 다르게 나올 수 있습니다. 전체 흐름을 테스트하려면 RANDOM_PORT 모드를 사용하세요.

3. expectBody()에서 제네릭 타입은 ParameterizedTypeReference를 사용해야 한다

expectBody(List<Product>.class) 같은 제네릭 타입은 Java의 타입 소거 때문에 직접 사용할 수 없습니다. expectBodyList(Product.class)를 사용하거나, expectBody(new ParameterizedTypeReference<List<Product>>() {})로 명시해야 합니다. 이를 모르면 역직렬화 실패나 ClassCastException이 발생합니다.

정리

  • WebTestClient는 ** 리액티브 기반 테스트 클라이언트 **로 WebFlux와 MVC 모두를 지원합니다
  • bindToController(), bindToApplicationContext(), bindToServer() 세 가지 모드가 있습니다
  • StepVerifier 와 조합하면 Mono/Flux의 비동기 동작을 정밀하게 검증할 수 있습니다
  • MockMvc와 비교하면 실제 HTTP 요청 지원, 비동기 네이티브 지원이 장점입니다
댓글 로딩 중...