@WebMvcTest와 RestTestClient
Controller 테스트의 질문은 "애플리케이션 전체가 뜨는가"가 아닙니다. 요청 URI와 헤더가 올바른 서비스 호출로 번역되는지, 반환값이 약속한 JSON과 상태 코드가 되는지 확인하는 것입니다. @WebMvcTest는 이 질문에 필요한 MVC 구성만 로드합니다.
웹 slice는 HTTP 번역 책임만 연다
slice에서 실제 서비스와 JPA 저장소까지 불러오면 Controller 번역 실패와 영속성 실패를 구분하기 어렵습니다. @MockitoBean으로 누적 프로젝트의 VersionedStudySessionService 경계를 대체하고, RestTestClient로 실제 HTTP 요청과 같은 형태의 검증을 수행합니다.
Mock에는 Controller가 전달해야 하는 정확한 인자를 설정합니다. query parameter나 기본 정렬이 달라지면 준비한 stub이 일치하지 않으므로, 응답 JSON뿐 아니라 요청 변환의 회귀도 드러납니다.
테스트는 서비스 내부의 업무 규칙을 다시 검사하지 않습니다. 서비스가 반환한 Page<StudySessionResponse>가 HTTP 200과 content 배열로 바뀌는 Controller 경계에 집중합니다.
누적 서비스 계약과 Controller를 연결한다
ch5-4에서 교체한 VersionedStudySessionService를 MockitoBean으로 대체합니다. API 버전 설정도 slice에 명시적으로 포함해 버전 헤더, 필터 query, Page JSON까지 현재 Controller 계약 그대로 검증합니다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>package com.andongmin.studylog.session;
import static org.mockito.Mockito.when;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.client.RestTestClient;
import com.andongmin.studylog.web.ApiVersionConfiguration;
@WebMvcTest(StudySessionController.class)
@AutoConfigureRestTestClient
@AutoConfigureMockMvc(addFilters = false)
@Import(ApiVersionConfiguration.class)
class StudySessionControllerTest {
@Autowired
RestTestClient client;
@MockitoBean
VersionedStudySessionService sessions;
@Test
@WithMockUser(authorities = "SCOPE_study.read")
void returnsVersionedPageAsJson() {
UUID id = UUID.fromString("00000000-0000-0000-0000-000000000001");
var response = new StudySessionResponse(id, "Spring", "MVC slice",
LocalDate.of(2026, 7, 13), 60, SessionStatus.PLANNED, 0);
var pageable = PageRequest.of(0, 20,
Sort.by(Sort.Order.desc("studyDate"), Sort.Order.asc("id")));
when(sessions.findAll("Spring", null, pageable))
.thenReturn(new PageImpl<>(List.of(response), pageable, 1));
client.get()
.uri("/api/study-sessions?subject=Spring&page=0&size=20")
.header("X-API-Version", "1.0")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.content[0].subject").isEqualTo("Spring")
.jsonPath("$.content[0].topic").isEqualTo("MVC slice")
.jsonPath("$.content[0].minutes").isEqualTo(60)
.jsonPath("$.totalElements").isEqualTo(1);
}
}컨텍스트가 시작되지 않으면 slice에 필요한 구성이나 Mock Bean부터 확인합니다. 컨텍스트는 뜨지만 응답이 다르면 URI·헤더·mock 인자·JSON 경로 순으로 범위를 좁힐 수 있습니다.
상태·헤더·body를 한 요청에서 단언한다
한 요청에서 상태와 JSON 구조를 함께 확인합니다. ETag나 Location처럼 계약에 포함된 응답 헤더가 있는 Controller라면 같은 exchange에서 헤더도 단언합니다.
./mvnw -q -Dtest=StudySessionControllerTest testMVC slice만 시작되고 `X-API-Version: 1.0` 요청의 query가 서비스 호출로 전달되며, mock의 `Page<StudySessionResponse>`가 HTTP 200의 `content` 배열로 번역되어야 합니다. `addFilters = false`는 Controller 변환만 보려는 선택이며 보안 규칙은 별도 Security 테스트에서 검증합니다.보안 필터 포함 여부를 테스트 의도에 맞춘다
인가 자체를 검증할 때는 Security 구성을 포함하고 순수 Controller 변환만 볼 때는 명시적 테스트 구성으로 경계를 좁힙니다.
MVC 변환을 격리했으니 다음 절은 반대쪽 어댑터인 JPA 매핑과 migration을 실제 PostgreSQL에서 확인합니다.