개발이군고구마

[TDD] 단위 테스트에서 만난 오류와 원리들 본문

SERVER/Architecture

[TDD] 단위 테스트에서 만난 오류와 원리들

김구황 2025. 9. 3. 23:37
728x90

1. Mock 에서 NPE

ㄴ 스프링 컨텍스트가 만든 Controller 빈(=MockMvc가 부르는 인스턴스)에는 component가 주입되지 않음 

 즉, @InjectMocks로 생성한 객체와 MockMvc가 사용하는 객체가 다른 인스턴스

@ExtendWith(MockitoExtension.class)
class ControllerTest {
    
    private MockMvc mockMvc;
    
    @Mock
    private Component component;
    
    @InjectMocks
    private Controller controller;
    
    @BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders.standaloneSetup(controller) // 스프링 컨텍스트 없이 직접 만든 컨트롤러 인스턴스를 MockMvc에 물립
                .setControllerAdvice() // 필요시 ControllerAdvice 추가
                .build();
    }
    
    @Test
    void saveTest() throws Exception {
        // given
        when(component.selectEvent("123")).thenReturn(456L);
        
        // when & then
        mockMvc.perform(post("/save")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"no\":\"123\"}"))
                .andExpect(status().isOk());
        
        verify(component).selectEvent("123");
    }
}

✅ Inject를 받은 controller를 MockMvc를 만들 때 생성해줌 

 

 

2. MockMvc content(dto)가 null로 들어감 

ㄴ standaloneSetup()으로 만든 MockMvc는 스프링 MVC의 커스텀 ArgumentResolver / 메시지 컨버터 / Validator를 자동 등록하지 않음

ㄴ DTO는 @RequestModel이라는 커스텀 어노테이션(대개 내부에서 HandlerMethodArgumentResolver로 바인딩)을 통해 생성·바인딩되는데, 이 Resolver가 테스트에 등록되지 않아 컨트롤러 파라미터가 null 로 들어온 것. (@RequestBody가 아니라서 기본 @ModelAttribute 경로도 타지 않음

 

🔮 MockMvc가 컨트롤러에 파라미터를 넘기는 과정과 원리 

 

1️⃣ MockMvc → DispatcherServlet 호출

ㄴ MockMvc는 실제 톰캣을 띄우지 않고 DispatcherServlet을 직접 호출

 

2️⃣ 핸들러 찾기

HandlerMapping(ex. RequestMappingHandlerMapping)이 요청 URL/HTTP 메서드로 컨트롤러 메서드 찾기 

ㄴ 예: @PostMapping("/saveEvtRequest") 메서드

 

3️⃣ 핸들러 실행 준비

HandlerAdapter(ex. RequestMappingHandlerAdapter)가 컨트롤러 메서드를 호출 가능한 형태로 래핑

 

4️⃣ 메서드 파라미터 해석

ㄴ HandlerMethodArgumentResolverComposite가 파라미터마다 다음 규칙으로 “누가 처리할지”를 결정하고 값을 만든다

  • @RequestBody가 붙어 있으면 → RequestResponseBodyMethodProcessor
    • 담당 리졸버: RequestResponseBodyMethodProcessor
    • 절차
      • 요청의 Content-Type 확인 (예: application/json).
      • 등록된 HttpMessageConverter 중 읽을 수 있는 것을 선택
        • JSON이면 보통 MappingJackson2HttpMessageConverter가 선택되고 내부에서 ObjectMapper가 동작.
      • 바디를 DTO로 역직렬화.
      • @Valid가 있으면 Validator로 검증 → 오류시 예외.
  • @ModelAttribute나 어노테이션 없는 POJO면 → ModelAttributeMethodProcessor/ServletModelAttributeMethodArgumentResolver
    • 담당 리졸버: ModelAttributeMethodProcessor 등
    • 절차
      • request.getParameterMap()(쿼리스트링, 폼 urlencoded)을 키-값으로 읽음.
      • WebDataBinder + ConversionService로 필드 매핑 & 타입 변환.
      • @Valid가 있으면 Validator 검증.
  • @PathVariable, @RequestParam, @RequestHeader 등은 각 전용 리졸버
  • 커스텀 어노테이션(예: @RequestModel)이면 → **네가 등록한 HandlerMethodArgumentResolver**가 처리해야 함

5️⃣ 검증(@Valid/@Validated)

ㄴ 값이 만들어진 뒤 WebDataBinder가 붙고, 파라미터에 @Valid/@Validated가 있으면 등록된 Validator(대개 LocalValidatorFactoryBean)로 검증

ㄴ 오류가 나면 MethodArgumentNotValidException 등을 터뜨리거나, BindingResult 파라미터가 있으면 그쪽에 담김

 

@BeforeEach
void setup() {
  mockMvc = MockMvcBuilders.standaloneSetup(godEvtDctV3Controller)
     .setCustomArgumentResolvers(new RequestModelArgumentResolver(...)) // ← 필수
     .setMessageConverters(new MappingJackson2HttpMessageConverter())   // JSON 바디용
     .setValidator(new LocalValidatorFactoryBean())                     // @Valid
     .build();
}

✅ 커스텀 리졸버 등록해주기