컨트롤러에 @GetMapping을 붙이고 파라미터를 record로 받았다. 실제로 쿼리 파라미터가 제대로 바인딩되는지, validation이 동작하는지는 직접 HTTP 요청을 날려봐야 안다. @WebMvcTest컨트롤러 레이어만 띄워서 이런 것들을 검증한다.

@WebMvcTest가 해주는 일

  • 지정한 컨트롤러와 관련 빈(ControllerAdvice, Filter 등)만 로드한다.
  • MockMvc를 자동으로 설정해준다.
  • Service, Repository는 로드하지 않는다. @MockBean으로 가짜를 넣어야 한다.

실제 서버를 띄우지 않고 HTTP 요청/응답을 시뮬레이션하기 때문에 빠르다.

sequenceDiagram autonumber participant Test as 테스트 코드 participant MVC as MockMvc participant Ctrl as MessageController participant Svc as MessageService
(@MockBean) rect rgb(232, 248, 232) Note over Test, MVC: 요청 구성 Test->>MVC: perform(get("/api/direct-messages")
.param("userId", "...")) end rect rgb(240, 248, 255) Note over MVC, Svc: Spring MVC 처리 MVC->>Ctrl: 파라미터 바인딩 + Validation Ctrl->>Svc: getByCursor(request) Svc-->>Ctrl: 미리 설정한 응답 Ctrl-->>MVC: ResponseEntity end rect rgb(232, 248, 232) Note over Test, MVC: 결과 검증 MVC-->>Test: andExpect(status, jsonPath) end

초록 영역에서 테스트가 요청을 만들고, 파란 영역에서 Spring MVC가 실제 바인딩과 validation을 수행한다. Service는 @MockBean이니까 미리 설정해둔 값을 그냥 돌려준다. 마지막 초록 영역에서 상태 코드와 응답 JSON을 검증한다.

기본 셋업

@WebMvcTest(MessageController.class)
class MessageControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private MessageService messageService;
}

@WebMvcTest(MessageController.class)로 대상 컨트롤러를 지정한다. 지정하지 않으면 모든 컨트롤러를 다 올리기 때문에 불필요한 @MockBean이 많아진다.

@MockBean@Mock의 차이

둘 다 가짜 객체를 만들지만, 어디에 등록되느냐가 다르다.

flowchart LR subgraph Mockito ["Mockito (@Mock)"] direction TB M["가짜 객체"] IJ["@InjectMocks로
직접 주입"] M --> IJ end Mockito ~~~ Spring subgraph Spring ["Spring (@MockBean)"] direction TB MB["가짜 빈"] CTX["Spring 컨텍스트에
등록"] MB --> CTX end style M fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style MB fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style IJ fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style CTX fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style Mockito fill:#fff8f0,stroke:#FF9800 style Spring fill:#f0f8f0,stroke:#4CAF50
  • @Mock : Mockito가 관리한다. @InjectMocks로 테스트 대상에 수동 주입한다. Spring과 무관.
  • @MockBean : Spring 컨텍스트에 빈으로 등록된다. 컨트롤러가 @Autowired로 주입받는 서비스를 대체한다.

@WebMvcTest에서 서비스를 @Mock으로 선언하면 Spring이 모르니까 컨트롤러에 주입되지 않아 NPE가 난다.

성공 케이스 테스트

@Test
void DM_조회에_성공한다() throws Exception {
    // given
    MessageGetResponse response = new MessageGetResponse(
            List.of(), null, null, false, 0,
            SortBy.createdAt, SortDirection.DESCENDING);
    given(messageService.getByCursor(any()))
            .willReturn(response);

    // when & then
    mockMvc.perform(get("/api/direct-messages")
                    .param("userId", UUID.randomUUID().toString())
                    .param("limit", "20"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.hasNext").value(false))
            .andExpect(jsonPath("$.totalCount").value(0));
}

given(...).willReturn(...)으로 서비스의 반환값을 미리 설정한다. 이 테스트의 관심사는 서비스 로직이 아니라 HTTP 레이어다. 파라미터가 제대로 바인딩되는지, 응답 JSON 구조가 맞는지를 검증한다.

jsonPath로 응답 검증

jsonPath는 JSON 응답에서 특정 필드를 꺼내 검증하는 도구다.

.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data.length()").value(3))
.andExpect(jsonPath("$.data[0].sender.name").value("sender"))
.andExpect(jsonPath("$.nextCursor").exists())
.andExpect(jsonPath("$.hasNext").value(true))
  • $ : 응답 JSON의 루트
  • $.data[0] : 배열의 첫 번째 요소
  • .exists() / .doesNotExist() : 필드 존재 여부
  • .isArray() / .isEmpty() : 타입 검증

Validation 테스트

validation은 @WebMvcTest에서 진짜 힘을 발휘한다. @NotNull, @Min 같은 어노테이션이 실제로 동작하는지 확인할 수 있다.

@Test
void userId가_없으면_400을_반환한다() throws Exception {
    mockMvc.perform(get("/api/direct-messages")
                    .param("limit", "20"))
            .andExpect(status().isBadRequest());
}

@Test
void limit이_0이면_400을_반환한다() throws Exception {
    mockMvc.perform(get("/api/direct-messages")
                    .param("userId", UUID.randomUUID().toString())
                    .param("limit", "0"))
            .andExpect(status().isBadRequest());
}

이런 테스트가 있으면 DTO의 validation 어노테이션을 실수로 삭제해도 바로 잡힌다.

에러 응답의 메시지 내용까지 검증하고 싶으면 jsonPath("$.message") 등으로 GlobalExceptionHandler가 반환하는 에러 형식을 확인할 수 있다.

Security 충돌 해결

프로젝트에 SecurityConfig가 있으면 @WebMvcTest에서 403 Forbidden이 뜰 수 있다. Security 필터가 자동으로 적용되기 때문이다.

방법 1 — 필터 끄기

테스트 목적이 컨트롤러 로직 검증이라면 Security 필터를 통째로 끌 수 있다.

@WebMvcTest(MessageController.class)
@AutoConfigureMockMvc(addFilters = false)
class MessageControllerTest {
    // Security 필터 없이 테스트
}

방법 2 — CSRF 토큰 추가

Security를 유지하면서 테스트하려면 요청에 CSRF 토큰을 넣는다.

mockMvc.perform(post("/api/direct-messages")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
        .andExpect(status().isOk());

GET 요청은 보통 CSRF가 면제되지만, POST/PUT/DELETE는 csrf()가 필요하다.

인증 객체가 필요한 경우

@AuthenticationPrincipal로 로그인 사용자를 받는 컨트롤러라면, @WithMockUser.with(user("test"))로 가짜 인증 객체를 넣어야 한다.

@ModelAttribute@RequestBody 테스트 차이

GET 요청에서 쿼리 파라미터를 객체로 바인딩하면 @ModelAttribute를 쓴다. POST에서 JSON body를 받으면 @RequestBody를 쓴다. 테스트 방법도 다르다.

flowchart TD A{요청 방식} A -->|"GET + 쿼리 파라미터"| B["@ModelAttribute"] A -->|"POST + JSON body"| C["@RequestBody"] B --> D[".param('key', 'value')"] C --> E[".content(json)
.contentType(APPLICATION_JSON)"] style A fill:#FFF3E0,stroke:#FF9800,stroke-width:2px style B fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style C fill:#E8F4F8,stroke:#2196F3,stroke-width:2px style D fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px style E fill:#E8F4F8,stroke:#2196F3,stroke-width:2px

@ModelAttribute.param()으로, @RequestBody.content() + contentType으로 보낸다.

// @ModelAttribute — 쿼리 파라미터
mockMvc.perform(get("/api/direct-messages")
        .param("userId", uuid.toString())
        .param("limit", "20"))

// @RequestBody — JSON body
mockMvc.perform(post("/api/direct-messages")
        .contentType(MediaType.APPLICATION_JSON)
        .content(objectMapper.writeValueAsString(request)))

자주 하는 실수

@WebMvcTest에서 서비스를 @Mock으로 선언

@Mock은 Mockito 내부에서만 동작한다. Spring 컨텍스트에 등록되지 않기 때문에 컨트롤러가 의존하는 서비스가 null이 된다. @WebMvcTest에서는 반드시 @MockBean을 써야 한다.

[!DANGER] 컨트롤러 미지정으로 전체 로드

@WebMvcTest에 컨트롤러를 지정하지 않으면 프로젝트의 모든 컨트롤러가 올라온다. 각 컨트롤러가 의존하는 서비스마다 @MockBean을 선언해야 해서 테스트가 비대해진다. 항상 @WebMvcTest(MessageController.class)처럼 대상을 명시한다.

[!DANGER] @ModelAttribute record에서 파라미터 바인딩 실패

record 타입은 Spring 6+에서 생성자 바인딩으로 동작한다. 컴파일 시 파라미터 이름이 보존되어야 하는데, -parameters 컴파일러 옵션이 빠져 있으면 바인딩이 안 된다. Gradle에서 compileJava { options.compilerArgs << '-parameters' }를 확인한다.