Project/Boilerplate

WebMvcTest + Spring REST doc 적용 ( 1 / 2 - MockMvc )

조용우 2025. 3. 28. 12:41

WebMvcTest + Spring REST doc 을 적용

 

적용 이유: 테스트와 동시에 문서화를 자동화 할 수 있기에 문서 작성의 편리함

 

Spring REST doc을 선택한 이유: Swagger와 달리, 프로덕트 코드에 테스트 관련 코드가 포함되지 않음. 이후 Swagger UI만 가져와서, Spring REST doc과 Swagger의 장점을 합칠 예정

 

RestAssured가 아니라 MockMvc를 선택한 이유:

RestAssured는 @SpringBootTest를 실행해야 하기 때문에 속도가 느림.

 


 

@WebMvcTest(GreetingController.class)는

Spring에서 웹 계층(Controller) 만 테스트하기 위한 전용 어노테이션입니다.
속도는 빠르면서도 HTTP 요청/응답 흐름을 실제처럼 테스트할 수 있다는 장점이 있어요.

 


✅ @WebMvcTest란?

@SpringBootTest가 전체 애플리케이션 컨텍스트를 로딩하는 반면,
@WebMvcTest는 웹 관련 컴포넌트만 로딩해서 테스트를 수행합니다.


🔍 @WebMvcTest(GreetingController.class) 의미 상세 분석

구성 요소설명
@WebMvcTest Spring MVC Controller 계층만 테스트하겠다는 선언
(JoinController.class) 테스트 대상 Controller를 명시 (다른 Controller는 로딩하지 않음)

⚠️ 지정하지 않으면 모든 @Controller, @RestController가 로딩되므로 성능 저하가 생길 수 있습니다.


✅ 로딩되는 컴포넌트

  • @Controller, @RestController (지정된 것만)
  • @ControllerAdvice (예외 처리 관련)
  • Spring MVC 관련 설정 (예: @RequestMapping, @GetMapping)
  • MockMvc 자동 설정

❌ 로딩되지 않는 컴포넌트

  • @Service, @Repository, @Component
  • Database, JPA 관련 빈
  • Security, Scheduling 등 부가적인 기능

→ 그래서 Controller가 사용하는 의존성은 @MockBean 으로 주입해줘야 해요!

@MockBean
private JoinService joinService;

💡 왜 쓰는가?

목적 설명
빠른 테스트 전체 애플리케이션 로딩 없이 Controller만 테스트
정확한 HTTP 테스트 MockMvc로 실제 요청 흐름처럼 테스트 가능
Spring REST Docs 연동 문서화 자동화 가능 (요청/응답 기반 캡처)

✅ 실제 적용 코드

@WebMvcTest(JoinController.class)
@DisplayName("유저 회원가입 Controller")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@AutoConfigureMockMvc(addFilters = false) // ✅ 이 부분을 넣어야 Spring Security Filter 작동 안함
class JoinControllerTest {

    @MockitoBean
    private JoinService joinService;

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;
    
    private Long id = 1L;
    private String email = "email@email.com";
    private String username = "email";
    private String password = "password";
    private String name = "name";

    @Nested
    class 회원가입_성공 {

        @Test
        void 회원가입_성공_정상_회원가입() throws Exception {
            // given
            JoinRequest request = new JoinRequest(email, password);
            JoinResponse response = JoinResponse.builder()
                .id(id)
                .username(username)
                .build();
            given(joinService.join(any(JoinRequest.class))).willReturn(response);

            // when & then
            mockMvc.perform(post("/api/join")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andDo(print()) // 👈 응답 JSON 전체 콘솔 출력
                .andExpect(jsonPath("$.id").value(id))
                .andExpect(jsonPath("$.username").value(username));
        }
    }

    @Nested
    class 회원가입_실패 {
        @Test
        void 회원가입_실패_중복된_이메일() throws Exception {
            // given
            JoinRequest request = new JoinRequest(email, password);

            given(joinService.join(any(JoinRequest.class)))
                .willThrow(new DuplicateUserException());

            // when & then
            mockMvc.perform(post("/api/join")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.message").value(UserError.DUPLICATE_USER.getMessage()))
                .andExpect(jsonPath("$.code").value(UserError.DUPLICATE_USER.name()))
                .andExpect(jsonPath("$.status").value(UserError.DUPLICATE_USER.getStatus().value()))
                .andExpect(jsonPath("$.timestamp").exists())
                .andExpect(jsonPath("$.details").isMap());
        }
    }
}

 


🔍 주의할 점

JoinRequest request = new JoinRequest("test@email.com", "password"); 
given(joinService.join(request)).willReturn(response);
 

이런식으로 작성하면 테스트가 실행되면서 mockMvc.perform(...)를 통해 들어온 JoinRequest는
new JoinRequest(...)로 만들어진 다른 인스턴스예요.

즉, Mockito는 내부적으로 아래처럼 비교합니다:

request.equals(실제_호출된_JoinRequest)

그런데 JoinRequest가 equals()를 오버라이딩 하지 않았다면?
👉 Object.equals()는 참조 동일성만 비교해서 false가 됩니다.

사용 방식 비교 기준 특징
any(...) 타입만 일치하면 됨 any()는 빠르고 편리하지만 정밀 매칭은 안 됨
정확한 객체 (ex. join(request)) equals() 메서드로 비교함 equals() 재정의 OR @EqualsAndHashCode 필요