WebTestClient — WebFlux와 MVC 모두를 위한 통합 테스트
MockMvc로 충분하다고 생각했는데, WebFlux 프로젝트에서는 왜 동작하지 않을까요?
MockMvc는 서블릿 기반 Spring MVC에 특화된 테스트 도구입니다. WebFlux(리액티브)에서는 동작하지 않습니다. WebTestClient는 리액티브 기반으로 설계되었지만 MVC에서도 사용할 수 있어, 두 세계를 아우르는 통합 테스트 도구입니다.
WebTestClient란
WebTestClient는 Spring WebFlux의 테스트 클라이언트로, HTTP 요청을 보내고 응답을 검증하는 플루언트 API를 제공합니다.
세 가지 바인딩 모드:
| 모드 | 서버 필요 | 용도 |
|---|---|---|
bindToController() | 불필요 | 특정 컨트롤러 격리 테스트 |
bindToApplicationContext() | 불필요 | Spring 컨텍스트 기반 테스트 |
bindToServer() | 필요 | 실제 HTTP 요청 (E2E) |
WebFlux 컨트롤러 테스트
컨트롤러에 직접 바인딩
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()로 요청을 보내고 응답을 검증합니다.
@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()로 리스트 크기와 포함 여부를 검증합니다.
@Test
void 상품_목록_조회() {
webTestClient.get()
.uri("/api/products")
.exchange()
.expectStatus().isOk()
.expectBodyList(Product.class)
.hasSize(2)
.contains(new Product(1L, "스프링", 30000));
}
}
Spring 컨텍스트 기반 테스트
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductIntegrationTest {
@Autowired
private WebTestClient webTestClient; // 자동 주입
RANDOM_PORT 모드에서는 실제 서버가 띄워지므로 전체 요청 흐름을 통합 테스트할 수 있습니다.
@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를 사용할 수 있습니다.
<!-- 테스트 의존성에 webflux 추가 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<scope>test</scope>
</dependency>
@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 비교
// 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("스프링");
| 기준 | MockMvc | WebTestClient |
|---|---|---|
| 기반 | 서블릿 API | 리액티브 API |
| WebFlux 지원 | 불가 | 가능 |
| MVC 지원 | 가능 | 가능 (webflux 의존성 필요) |
| 실제 HTTP 요청 | 불가 | 가능 (RANDOM_PORT) |
| 비동기 테스트 | 제한적 | 네이티브 지원 |
StepVerifier와 조합
서비스 계층에서 Mono/Flux를 반환하는 리액티브 코드를 테스트할 때 StepVerifier를 사용합니다.
@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로 간결하게 작성할 수 있습니다.
@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로 검증할 수도 있습니다.
@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 상세 검증
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);
타입으로 역직렬화 검증
webTestClient.get()
.uri("/api/products/1")
.exchange()
.expectStatus().isOk()
.expectBody(Product.class)
.value(product -> {
assertThat(product.getName()).isEqualTo("스프링");
assertThat(product.getPrice()).isGreaterThan(0);
});
리스트 검증
webTestClient.get()
.uri("/api/products")
.exchange()
.expectStatus().isOk()
.expectBodyList(Product.class)
.hasSize(5)
.value(products -> {
assertThat(products).extracting(Product::getName)
.contains("스프링", "리액터");
});
에러 응답 테스트
@Test
void 존재하지_않는_상품_조회시_404() {
webTestClient.get()
.uri("/api/products/999")
.exchange()
.expectStatus().isNotFound()
.expectBody()
.jsonPath("$.message").isEqualTo("상품을 찾을 수 없습니다")
.jsonPath("$.code").isEqualTo("PRODUCT_NOT_FOUND");
}
잘못된 요청에 대한 400 에러 응답도 동일한 패턴으로 검증합니다.
@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 요청 지원, 비동기 네이티브 지원이 장점입니다