@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(...)은 id 1로 호출될 때 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 클래스를 깨끗하게 유지합니다.
spring-boottestingmockmvcjunit5webmvctest