@WebMvcTest 및 MockMvc를 사용한 controller 테스트 작성
1. 개요
@SpringBootTest로 controller를 테스트하면 데이터베이스, 메시지 브로커 및 다른 모든 bean을 포함한 전체 애플리케이션이 로드됩니다. 이 방식은 느리고 오류를 격리하기 어렵습니다. Spring Boot는 웹 계층만 로드하는 @WebMvcTest를 제공합니다. MockMvc와 함께 사용하면 실제 서버를 시작하지 않고도 요청을 시뮬레이션할 수 있습니다.
이 글을 읽은 후에는 @WebMvcTest를 사용하여 REST controller를 테스트하는 방법을 알게 될 것입니다. 구체적으로 status, header 및 반환된 JSON을 확인하고, validation을 테스트하며, 예외 handler를 테스트하고, 흔한 오류를 피하는 방법을 다룹니다.
2. 의존성
controller를 위해 spring-boot-starter-web이 필요하고, Bean Validation을 위해 spring-boot-starter-validation이 필요하며, 테스트 부분을 위해 spring-boot-starter-test가 필요합니다:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
spring-boot-starter-test에는 이미 JUnit 5, AssertJ, Mockito, JsonPath 및 Spring Test가 포함되어 있습니다. 다른 것을 추가할 필요가 없습니다.
3. 테스트할 Controller
간단한 주문 관리 API를 사용합시다. 먼저 model과 request입니다:
public record Order(Long id, String customerName, BigDecimal total) {}
public record CreateOrderRequest(
@NotBlank String customerName,
@NotNull @Positive BigDecimal total) {}
Service 계층은 인터페이스입니다. 테스트에서 이를 mock할 것입니다:
public interface OrderService {
Order findById(Long id);
Order create(CreateOrderRequest request);
}
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("주문을 찾을 수 없습니다 " + id);
}
}
Controller에는 두 개의 endpoint가 있습니다:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.findById(id);
}
@PostMapping
public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
Order created = orderService.create(request);
return ResponseEntity
.created(URI.create("/api/orders/" + created.id()))
.body(created);
}
}
마지막으로 OrderNotFoundException을 HTTP 404로 변환하는 @RestControllerAdvice가 있습니다:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
4. @WebMvcTest를 사용한 첫 번째 테스트
4.1. 테스트 구조
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private OrderService orderService;
}
각 부분이 다음과 같이 작동합니다:
@WebMvcTest(OrderController.class)는 MVC와 관련된 bean만 포함하는 Spring context를 만듭니다. 여기에는OrderController,@ControllerAdvice,Converter,Filter,WebMvcConfigurer및 Jackson이 포함됩니다. 일반적인@Service,@Repository또는@Component는 로드되지 않습니다.MockMvc는 자동으로 구성되며, 주입하기만 하면 됩니다.ObjectMapper는 Spring Boot가 구성한 Jackson bean입니다. 실제 구성과 일치하도록 테스트를 도와주기 위해 이를 사용하여 요청을 직렬화합니다.@MockitoBean은OrderService에 대한 Mockito mock을 만들고 context에 등록합니다. 이 덕분에OrderController는 초기화할 dependency를 가집니다.
참고: Spring Boot 3.4부터
@MockBean은 deprecated로 표시되었습니다.org.springframework.test.context.bean.override.mockito패키지의@MockitoBean을 사용해야 합니다. Spring Boot 3.3 이하를 사용 중인 경우@MockBean을 사용하세요. 작동 방식은 동일합니다.
4.2. JSON을 반환하는 GET 테스트
@Test
void shouldReturnOrderWhenFound() throws Exception {
given(orderService.findById(1L))
.willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));
mockMvc.perform(get("/api/orders/{id}", 1L))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.customerName").value("Nguyen Van A"))
.andExpect(jsonPath("$.total").value(250000));
}
필요한 static import들:
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
각 단계의 설명:
given(...).willReturn(...)은 id1로 호출될 때 mock이 고정된Order를 반환하도록 정의합니다.perform(get(...))은DispatcherServlet를 통해GET /api/orders/1요청을 보냅니다. 요청은 네트워크를 통해 가지 않습니다.andDo(print())는 전체 요청과 응답을 콘솔에 인쇄합니다. 테스트가 실패할 때 이 단계가 매우 유용합니다.jsonPath("$.customerName")은 JSON 본문의 필드를 쿼리합니다.
print()의 출력은 다음과 같은 형태입니다:
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = {"id":1,"customerName":"Nguyen Van A","total":250000}
5. 요청 본문이 있는 POST 테스트
5.1. 성공하는 경우
@Test
void shouldCreateOrder() throws Exception {
var request = new CreateOrderRequest("Tran Thi B", new BigDecimal("99000"));
given(orderService.create(request))
.willReturn(new Order(10L, "Tran Thi B", new BigDecimal("99000")));
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/orders/10"))
.andExpect(jsonPath("$.id").value(10));
}
여기서 주목할 두 가지 점이 있습니다:
contentType(MediaType.APPLICATION_JSON)은 필수입니다. 누락되면 Spring이 415 Unsupported Media Type을 반환합니다.given(orderService.create(request))는record가 자동으로equals()를 생성하기 때문에 작동합니다. Controller가 deserialize한 요청은 테스트의request객체와 같을 것입니다. 일반 클래스를 사용하고equals()를 재정의하지 않은 경우any(CreateOrderRequest.class)을 대신 사용하세요.
5.2. Validation 테스트
@WebMvcTest는 validation 구성을 로드합니다. 따라서 @Valid는 실제로 실행할 때와 동일하게 작동합니다:
@Test
void shouldReturnBadRequestWhenCustomerNameIsBlank() throws Exception {
String invalidJson = """
{"customerName": "", "total": -5}
""";
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest());
verify(orderService, never()).create(any());
}
verify(orderService, never()).create(any()) 줄은 데이터가 유효하지 않을 때 service가 호출되지 않도록 합니다. 이것이 바로 당신이 보호하고 싶어하는 동작입니다: validation이 웹 계층에서 바로 요청을 차단합니다.
이 테스트는 ObjectMapper 대신 JSON 텍스트 블록을 사용합니다. 이 방식은 record Java로 표현할 수 없는 payload를 보낼 수 있도록 도와줍니다. 예를 들어 잘못된 데이터 타입의 JSON입니다.
6. 예외 handler 테스트
@WebMvcTest는 자동으로 @RestControllerAdvice를 로드하므로, 예외를 HTTP status로 매핑하는 것을 테스트할 수 있습니다:
@Test
void shouldReturnNotFoundWhenOrderDoesNotExist() throws Exception {
given(orderService.findById(99L)).willThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/orders/{id}", 99L))
.andExpect(status().isNotFound())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.detail").value("주문을 찾을 수 없습니다 99"));
}
응답 본문은 RFC 9457 (Problem Details) 표준을 따릅니다:
{"type":"about:blank","title":"Not Found","status":404,"detail":"주문을 찾을 수 없습니다 99","instance":"/api/orders/99"}
이것이 순수 unit test와 비교할 때 @WebMvcTest의 강점입니다. 순수 unit test는 단순히 orderController.getOrder(99L)를 직접 호출합니다. Unit test는 예외가 던져지는 것만 볼 수 있습니다. 실제 클라이언트가 어떤 status와 본문을 받는지는 확인할 수 없습니다.
7. MockMvcTester를 사용한 AssertJ 방식의 assertion
Spring Boot 3.4 (Spring Framework 6.2)부터, AssertJ가 classpath에 있으면 Spring Boot는 추가로 MockMvcTester를 자동 구성합니다. 이 bean을 사용하면 andExpect 체인 대신 AssertJ를 사용하여 assertion을 작성할 수 있습니다:
@WebMvcTest(OrderController.class)
class OrderControllerAssertJTest {
@Autowired
private MockMvcTester mvc;
@MockitoBean
private OrderService orderService;
@Test
void shouldReturnOrderWhenFound() {
given(orderService.findById(1L))
.willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));
assertThat(mvc.get().uri("/api/orders/{id}", 1L))
.hasStatusOk()
.hasContentType(MediaType.APPLICATION_JSON)
.bodyJson()
.extractingPath("$.customerName")
.isEqualTo("Nguyen Van A");
}
}
MockMvc와 비교하면 이 방식에는 세 가지 장점이 있습니다:
throws Exception이 필요하지 않습니다.MockMvcResultMatchers에서 많은 static import을 기억할 필요가 없습니다.- AssertJ의 오류 메시지는 보통 더 읽기 쉽습니다.
두 API는 기능 측면에서 동등합니다. 하나를 선택하고 프로젝트 전체에서 일관되게 사용하세요.
8. 로드되는 것 제한하기
8.1. Controller 지정하기
매개변수가 없는 @WebMvcTest를 작성하면 Spring이 애플리케이션의 모든 controller를 로드합니다. 이 경우 관련 없는 controller도 포함하여 모든 controller의 dependency를 mock해야 합니다. 따라서 항상 테스트할 controller를 지정하세요:
@WebMvcTest(OrderController.class)
8.2. 특정 bean을 추가로 로드하기
때로 controller가 사이드 이펙트가 없는 작은 bean (예: OrderMapper)을 사용합니다. 이 bean을 mock하는 것은 가치를 제공하지 않습니다. @Import를 사용하여 bean을 실제로 로드할 수 있습니다:
@WebMvcTest(OrderController.class)
@Import(OrderMapper.class)
class OrderControllerTest {
// ...
}
8.3. Spring Security가 있을 때
classpath에 spring-boot-starter-security가 있으면 @WebMvcTest는 security filter chain도 로드합니다. 위의 테스트는 401 또는 403을 반환할 것입니다. 다음 dependency를 추가해야 합니다:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
그 후 사용자를 시뮬레이션하고 데이터를 변경하는 요청에 CSRF 토큰을 포함합니다:
@Test
@WithMockUser(roles = "USER")
void shouldCreateOrderWhenAuthenticated() throws Exception {
// ... 섹션 5.1처럼 orderService를 stub
mockMvc.perform(post("/api/orders")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerName": "Le Van C", "total": 120000}
"""))
.andExpect(status().isCreated());
}
참고: 사용자가 정의한
SecurityFilterChain클래스 (@Configuration표시)는 모든 버전에서 자동으로 로드되지 않습니다. 자신의 권한 부여 규칙을 정확하게 테스트하려면@Import(SecurityConfig.class)을 추가하세요.
9. 흔한 오류
테스트 시작 시 NoSuchBeanDefinitionException. Controller가 웹 계층에 속하지 않는 bean에 의존하고 있으며 이를 mock하지 않았습니다. 해결: 해당 bean에 @MockitoBean을 추가하거나 @Import로 로드합니다.
@SpringBootApplication 클래스의 annotation이 테스트를 깨뜨림. 예를 들어 main 클래스에 직접 @EnableJpaRepositories 또는 @EnableScheduling을 작성했습니다. @WebMvcTest는 여전히 이 클래스를 읽으므로 repository를 생성하려고 하고 DataSource가 없어 오류가 발생합니다. 해결: 이러한 annotation을 별도의 @Configuration 클래스로 분리합니다.
contentType 잊음. 결과는 예상한 status 대신 415입니다.
MockMvc로 결과를 확인하되 logic이 service에 있음. @WebMvcTest는 웹 계층의 다음 부분만 확인해야 합니다:
- routing,
- serialization,
- validation,
- status code,
- exception mapping.
비즈니스 logic은 순수 unit test로 service에서 별도로 테스트하세요.
과도한 mock. 테스트에서 5, 6개의 method를 stub해야 한다면, controller가 너무 많은 일을 하고 있을 수 있습니다. 이것은 stub을 더 작성해야 한다는 신호가 아니라 refactor해야 한다는 신호입니다.
참고: 데이터베이스를 통한 end-to-end 흐름을 확인하고 싶거나 실제 서버의 동작을 테스트해야 할 때 (
port,HTTP/2, container filter)는@WebMvcTest를 사용하면 안 됩니다. 이 경우@SpringBootTest(webEnvironment = RANDOM_PORT)를TestRestTemplate또는WebTestClient와 함께 사용하세요. 애플리케이션이 WebFlux를 사용하면 slice는@WebMvcTest가 아니라@WebFluxTest입니다.
10. 결론
@WebMvcTest는 controller를 빠르고 집중적으로 테스트할 수 있도록 도와줍니다. 웹 계층만 로드하고, 뒤의 dependency는 @MockitoBean으로 대체됩니다. MockMvc 또는 MockMvcTester와 결합하면 클라이언트가 실제로 보는 동작을 확인할 수 있습니다: status code, header, JSON, validation 및 exception mapping.
기억해야 할 주요 규칙:
- 항상
@WebMvcTest에서 controller를 지정합니다. - controller가 필요한 것만 정확하게 mock합니다.
- 본문을 보낼 때
contentType을 잊지 마세요. - 테스트 slice가 불필요한 구성을 끌어오지 않도록
@SpringBootApplication클래스를 깨끗하게 유지합니다.