Spring MockMvc 테스트 심화

MockMvc란?

MockMvc는 Spring MVC의 HTTP 요청-응답 사이클을 실제 서버 없이 테스트하는 프레임워크다. 내장 톰캣을 띄우지 않고 DispatcherServlet을 직접 호출하므로 밀리초 단위의 빠른 컨트롤러 테스트가 가능하다. Spring Boot 3.x에서는 @WebMvcTest 슬라이스 테스트와 조합해 사용한다.

기본 설정: @WebMvcTest

@WebMvcTest(UserController.class)  // 특정 컨트롤러만 로드
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean  // Spring Boot 3.4+ (@MockBean deprecated)
    private UserService userService;

    @Test
    void shouldReturnUser() throws Exception {
        given(userService.findById(1L))
            .willReturn(new UserDto(1L, "alice", "alice@example.com"));

        mockMvc.perform(get("/api/users/1")
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("alice"))
            .andExpect(jsonPath("$.email").value("alice@example.com"));
    }
}

@WebMvcTest는 MVC 관련 빈(컨트롤러, Filter, Interceptor, ControllerAdvice)만 로드하고, Service/Repository는 로드하지 않는다. Spring Test Slice 계층별 테스트에서 다른 슬라이스 어노테이션과 비교할 수 있다.

요청 빌더 심화

POST with JSON Body

@Test
void shouldCreateUser() throws Exception {
    var request = new CreateUserRequest("bob", "bob@example.com");

    mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(request)))
        .andExpect(status().isCreated())
        .andExpect(header().exists("Location"))
        .andExpect(jsonPath("$.id").isNumber());
}

쿼리 파라미터와 페이징

@Test
void shouldSearchWithPaging() throws Exception {
    mockMvc.perform(get("/api/users")
            .param("keyword", "alice")
            .param("page", "0")
            .param("size", "20")
            .param("sort", "name,asc"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.content").isArray())
        .andExpect(jsonPath("$.totalElements").isNumber());
}

파일 업로드 (Multipart)

@Test
void shouldUploadFile() throws Exception {
    MockMultipartFile file = new MockMultipartFile(
        "file", "report.csv", "text/csv", "id,namen1,alice".getBytes());

    mockMvc.perform(multipart("/api/files/upload")
            .file(file)
            .param("description", "Monthly report"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.filename").value("report.csv"));
}

응답 검증: jsonPath 심화

mockMvc.perform(get("/api/users"))
    // 배열 크기
    .andExpect(jsonPath("$.content", hasSize(3)))
    // 배열 요소 필드 검증
    .andExpect(jsonPath("$.content[0].name").value("alice"))
    // 존재 여부
    .andExpect(jsonPath("$.content[0].id").exists())
    .andExpect(jsonPath("$.content[0].password").doesNotExist())
    // 타입 검증
    .andExpect(jsonPath("$.content[0].age").isNumber())
    // 정규식 매칭
    .andExpect(jsonPath("$.content[0].email", matchesPattern(".*@.*\.com")))
    // 컬렉션 검증
    .andExpect(jsonPath("$.content[*].name", containsInAnyOrder("alice", "bob", "charlie")));

// 응답 본문 전체를 문자열로 검증
    .andExpect(content().json("""
        {"content": [{"name": "alice"}]}
        """, false));  // false = lenient (필드 순서 무시, 추가 필드 허용)

Security 통합 테스트

// spring-security-test 의존성 필요
@WebMvcTest(AdminController.class)
@Import(SecurityConfig.class)  // Security 설정 로드
class AdminControllerSecurityTest {

    @Autowired
    private MockMvc mockMvc;

    // 인증 없이 접근 → 401
    @Test
    void shouldReturn401WhenUnauthenticated() throws Exception {
        mockMvc.perform(get("/api/admin/stats"))
            .andExpect(status().isUnauthorized());
    }

    // @WithMockUser로 인증된 사용자 시뮬레이션
    @Test
    @WithMockUser(roles = "ADMIN")
    void shouldReturn200ForAdmin() throws Exception {
        mockMvc.perform(get("/api/admin/stats"))
            .andExpect(status().isOk());
    }

    // 권한 부족 → 403
    @Test
    @WithMockUser(roles = "USER")
    void shouldReturn403ForRegularUser() throws Exception {
        mockMvc.perform(get("/api/admin/stats"))
            .andExpect(status().isForbidden());
    }

    // JWT 토큰 시뮬레이션
    @Test
    void shouldAcceptJwtToken() throws Exception {
        mockMvc.perform(get("/api/admin/stats")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
            .andExpect(status().isOk());
    }

    // CSRF 토큰 포함
    @Test
    @WithMockUser
    void shouldRequireCsrf() throws Exception {
        mockMvc.perform(post("/api/users")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content("{}"))
            .andExpect(status().isCreated());
    }
}

Validation 에러 테스트

@Test
void shouldReturn400ForInvalidRequest() throws Exception {
    // @NotBlank name, @Email email 검증 실패
    var invalid = new CreateUserRequest("", "not-an-email");

    mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(invalid)))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errors", hasSize(2)))
        .andExpect(jsonPath("$.errors[*].field",
            containsInAnyOrder("name", "email")));
}

// @ControllerAdvice 에러 핸들러 통합 검증
@Test
void shouldReturnProblemDetailForNotFound() throws Exception {
    given(userService.findById(999L))
        .willThrow(new UserNotFoundException(999L));

    mockMvc.perform(get("/api/users/999"))
        .andExpect(status().isNotFound())
        .andExpect(jsonPath("$.title").value("Not Found"))
        .andExpect(jsonPath("$.detail").value("User 999 not found"))
        .andExpect(jsonPath("$.type").value("about:blank"));
}

커스텀 ResultMatcher 작성

// 반복되는 검증 패턴을 커스텀 Matcher로 추출
public class ApiResultMatchers {

    public static ResultMatcher isPageResponse() {
        return result -> {
            jsonPath("$.content").isArray().match(result);
            jsonPath("$.totalElements").isNumber().match(result);
            jsonPath("$.totalPages").isNumber().match(result);
            jsonPath("$.number").isNumber().match(result);
        };
    }

    public static ResultMatcher hasFieldError(String field) {
        return jsonPath("$.errors[*].field", hasItem(field));
    }
}

// 사용
mockMvc.perform(get("/api/users"))
    .andExpect(status().isOk())
    .andExpect(isPageResponse());

andDo(print())와 디버깅

// 요청/응답 전체 출력 (디버깅용)
mockMvc.perform(get("/api/users/1"))
    .andDo(print())  // 콘솔에 요청 헤더, 응답 본문 등 출력
    .andExpect(status().isOk());

// 응답 본문을 변수로 받기
MvcResult result = mockMvc.perform(get("/api/users/1"))
    .andReturn();

String body = result.getResponse().getContentAsString();
UserDto user = objectMapper.readValue(body, UserDto.class);
assertThat(user.name()).isEqualTo("alice");

MockMvc vs WebTestClient vs RestAssured

비교 항목 MockMvc WebTestClient RestAssured
서버 필요 ❌ 불필요 선택적 ✅ 필요
속도 가장 빠름 중간 느림 (HTTP 통신)
WebFlux 지원
적합 상황 MVC 단위 테스트 리액티브 + MVC E2E 통합 테스트

베스트 프랙티스

  • 컨트롤러당 하나의 테스트 클래스@WebMvcTest(XxxController.class)로 대상을 한정하면 컨텍스트 로딩 속도가 빨라진다.
  • Happy Path + Edge Case 모두 커버 — 정상 응답뿐 아니라 400, 401, 403, 404, 409 등 에러 시나리오도 테스트한다.
  • @MockitoBean 최소화 — 필요한 의존성만 Mock한다. 과도한 Mock은 테스트의 신뢰도를 낮춘다.
  • JSON 파일로 기대값 관리 — 복잡한 응답은 src/test/resources에 JSON 파일로 분리해 content().json(resource)로 비교한다.
  • Testcontainers와 분리 — MockMvc는 빠른 컨트롤러 테스트에, Testcontainers는 느린 통합 테스트에 사용한다.

정리

MockMvc는 Spring MVC 컨트롤러의 가장 효율적인 테스트 도구다. 서버를 띄우지 않아 빠르고, Security·Validation·Exception Handler를 포함한 전체 MVC 파이프라인을 검증한다. @WebMvcTest + @MockitoBean으로 격리하고, jsonPath로 응답을 정밀 검증하며, Security 테스트 지원(@WithMockUser, jwt())까지 활용하면 컨트롤러 계층의 신뢰성을 확보할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux