- 2편 — @WebMvcTest로 API 검증하기 ← 현재
컨트롤러에 @GetMapping을 붙이고 파라미터를 record로 받았다. 실제로 쿼리 파라미터가 제대로 바인딩되는지, validation이 동작하는지는 직접 HTTP 요청을 날려봐야 안다. @WebMvcTest는 컨트롤러 레이어만 띄워서 이런 것들을 검증한다.
@WebMvcTest가 해주는 일
- 지정한 컨트롤러와 관련 빈(ControllerAdvice, Filter 등)만 로드한다.
MockMvc를 자동으로 설정해준다.- Service, Repository는 로드하지 않는다.
@MockBean으로 가짜를 넣어야 한다.
실제 서버를 띄우지 않고 HTTP 요청/응답을 시뮬레이션하기 때문에 빠르다.
(@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의 차이
둘 다 가짜 객체를 만들지만, 어디에 등록되느냐가 다르다.
직접 주입"] 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를 쓴다. 테스트 방법도 다르다.
.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' }를 확인한다.