@WebMvcTest는 Spring MVC의 웹 계층만 띄우는 슬라이스 테스트 어노테이션이다. MockMvc는 실제 서버를 켜지 않고 HTTP 요청을 흉내 내서 컨트롤러에 보내는 테스트 도구다. 둘을 함께 쓰면 '이 요청을 보내면 이 상태 코드와 이 응답 본문이 나온다'를 서버 없이 빠르게 검증할 수 있다. 테스트 단계 전체에서의 위치는 Spring 테스트 단계에 정리했다.
@WebMvcTest가 띄우는 빈
@WebMvcTest는 웹 계층에 해당하는 빈만 스캔한다. @Controller, @ControllerAdvice, Filter, HandlerInterceptor, WebMvcConfigurer 같은 것들이다. @Service, @Repository, 일반 @Component는 띄우지 않는다.
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
// OrderController와 웹 계층 빈만 띄움
@WebMvcTest(OrderController::class, properties = ["app.api-key=test-key"])
class OrderControllerTest {
@Autowired
lateinit var mockMvc: MockMvc
}properties는 이 테스트에서만 쓸 설정 값을 넣는다. 필터가 @Value("\${app.api-key}") 같은 설정을 요구하는데 값이 없으면 컨텍스트 로딩이 실패하므로, 테스트용 값을 여기서 넣는다.
Spring Boot 4에서는 @WebMvcTest의 패키지가 org.springframework.boot.webmvc.test.autoconfigure로 바뀌었다. Spring Boot 3까지는 org.springframework.boot.test.autoconfigure.web.servlet이었다.
필터 빈도 함께 뜨기 때문에, API 키를 검사하는 필터가 있으면 모든 테스트 요청에 키 헤더를 붙여야 한다. 헤더가 없으면 컨트롤러에 닿기 전에 필터가 401을 돌려준다. 반복을 줄이려면 요청을 만드는 헬퍼 함수를 둔다.
// 모든 요청에 키 헤더를 붙이는 헬퍼
private fun authed(request: MockHttpServletRequestBuilder) =
request.header("X-Api-Key", "test-key")서비스 자리에 가짜 빈 채우기
컨트롤러가 생성자로 서비스를 받으면, @WebMvcTest는 서비스 빈을 띄우지 않으므로 컨텍스트 로딩이 NoSuchBeanDefinitionException으로 실패한다. 이때 @MockitoBean으로 서비스 자리에 가짜 객체(mock)를 채운다.
import org.springframework.test.context.bean.override.mockito.MockitoBean
@MockitoBean
lateinit var orderService: OrderServicelateinit var로 선언하는 이유는 Spring이 테스트를 시작할 때 값을 넣어 주기 때문이다. @MockitoBean은 Spring Framework 6.2에서 추가됐고, Spring Boot의 @MockBean은 3.4부터 사용 중단(deprecated)으로 표시됐다.
컨트롤러 테스트에서 서비스를 가짜로 둬도 되는 이유는 이 테스트의 목적이 웹 응답이기 때문이다. 저장이 실제로 되는지는 @DataJpaTest에서 따로 검증한다.
given과 willReturn
가짜 객체는 아무 설정이 없으면 객체를 돌려줘야 하는 메서드에서 null을 돌려준다. Mockito의 given으로 '이 인자로 불리면 이걸 돌려줘라'를 미리 정해 둔다.
import org.mockito.BDDMockito.given
// 정확히 이 인자로 불릴 때만 주문을 돌려줌
given(orderService.cancel(42L, "user-1")).willReturn(Order(id = 42L, status = OrderStatus.CANCELED))
// 정확히 이 인자로 불릴 때만 예외를 던짐
given(orderService.cancel(99L, "user-1")).willThrow(OrderNotFoundException(99L))given은 무조건 값을 돌려주는 설정이 아니다. 인자가 equals로 같을 때만 그 시나리오를 쓴다. 인자 하나라도 다르면 설정이 없는 호출로 취급해 null이 돌아오고, 컨트롤러가 그 값을 쓰다가 예외가 난다.
Kotlin에서 any() 쓰기
인자에 상관없이 시나리오를 쓰고 싶을 때 Mockito의 any()를 쓴다. 그런데 Mockito.any()는 실제로 null을 반환하기 때문에, Kotlin의 non-null 파라미터에 넘기면 NullPointerException이 난다. Elvis 연산자(?:)로 아무 기본값을 넘겨 컴파일러와 런타임 검사를 통과시킨다.
// Mockito.any()는 null을 반환함; non-null 파라미터에는 기본값으로 대신 넘김
private fun anyOrderRequest(): OrderRequest =
Mockito.any(OrderRequest::class.java) ?: OrderRequest(items = emptyList())
given(orderService.place(anyOrderRequest())).willReturn(Order(id = 1L))mockito-kotlin 라이브러리를 쓰면 같은 일을 하는 any()가 들어 있다.
MockMvc로 요청 보내고 응답 확인하기
perform 괄호 안에서 요청을 만들고, 뒤에 andExpect를 이어 붙여 응답을 검증한다.
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
mockMvc.perform(
post("/orders/42/cancel")
.header("X-Api-Key", "test-key")
.header("X-User-Id", "user-1")
)
.andExpect(status().isOk) // 상태 코드 200
.andExpect(jsonPath("$.status").value("CANCELED")) // 응답 JSON의 status 값
.andExpect(jsonPath("$.canceledAt").isNotEmpty) // 값이 비어 있지 않음status()는 함수라 괄호를 붙여 호출해야 한다.status.isOk로 쓰면Function invocation 'status()' expected컴파일 오류가 난다.jsonPath의$는 응답 JSON 전체,.status는 그 안의 키다. 응답에 그 키가 없으면PathNotFoundException이 난다.- 파일 업로드는
multipart("/files").file(MockMultipartFile("file", "a.txt", "text/plain", bytes))로 만든다. 첫 인자"file"은 컨트롤러의@RequestParam("file")이름과 같아야 한다.
header는 쌓이고 contentType은 덮어쓴다
요청 빌더의 메서드는 같은 것을 두 번 불렀을 때 동작이 다르다.
post("/orders")
.contentType(MediaType.APPLICATION_JSON).contentType(MediaType.TEXT_PLAIN)
.content("first-body").content("second-body")
.header("X-User-Id", "user-1").header("X-User-Id", "user-2")| 메서드 | 결과 | 동작 |
|---|---|---|
.contentType() | text/plain | 덮어씀 |
.content() | second-body | 덮어씀 |
.header() | [user-1, user-2] | 값이 추가됨 |
HTTP 헤더는 같은 이름으로 여러 값을 가질 수 있지만, 본문 형식과 본문은 요청마다 하나뿐이기 때문이다.
같은 이름의 헤더 값이 두 개 들어가면 읽는 쪽에 따라 결과가 갈린다. 필터에서 쓰는 request.getHeader()는 첫 값만 돌려주고, 컨트롤러의 @RequestHeader String 파라미터는 값들을 쉼표로 합친 "user-1, user-2"를 받는다. 키 헤더를 이미 붙이는 헬퍼에 틀린 키를 한 번 더 붙여 401을 기대하는 테스트를 쓰면, 필터는 첫 값인 맞는 키를 읽고 통과시킨다. 그래서 헤더 조합을 바꿔야 하는 테스트는 헬퍼를 쓰지 않고 요청을 처음부터 만든다.
MockMvc가 흉내 내는 범위
MockMvc는 톰캣 같은 서블릿 컨테이너를 띄우지 않는다. 실행 로그에도 톰캣 시작 대신 TestDispatcherServlet 초기화만 찍힌다.
실제 요청은 네트워크를 거쳐 톰캣이 요청 객체를 만든 뒤에 필터로 넘어간다.
MockMvc 요청은 톰캣 단계가 없다. 테스트 코드가 요청 객체를 직접 만들어 필터에 넘긴다.
테스트 코드가 MockHttpServletRequest를 직접 만들어 넘기고, 응답도 MockHttpServletResponse 객체로 바로 받는다. 요청이 도착한 뒤의 처리(필터, 컨트롤러, 예외 처리기)는 실제와 똑같이 실행되고, 생략되는 것은 네트워크로 바이트가 도착해 해석되는 부분뿐이다.
그래서 실제 HTTP로는 불가능한 값도 통과할 수 있다. 예를 들어 헤더 값에 개행 문자를 넣으면 실제 HTTP에서는 요청이 성립하지 않지만, MockMvc는 값을 요청 객체에 그대로 넣기 때문에 필터까지 도달한다. 이런 경계값은 MockMvc 결과만으로 판단하지 않는다.
Node.js에서 같은 역할을 하는 도구는 supertest다.