개발이군고구마

[냄새라도 TDD/OOP] 2-2. 단위 테스트 (컨트롤러) 본문

SERVER/Architecture

[냄새라도 TDD/OOP] 2-2. 단위 테스트 (컨트롤러)

김구황 2025. 7. 4. 07:54
728x90

 

[OOP 설계 프로세스]
1. 설계 원칙 확립 < 구현 1단계
2-1. TDD 구현 단위(도메인) < 구현 2단계
2-2. TDD 구현 단위(컨트롤러-검증) < 구현 2단계
3. TDD 구현 통합

 


 

1. 컨트롤러 단위테스트 

🌐 환경설정 

 

1) Mvc단위테스트 설정하기 

@WebMvcTest(InfoController.class)
class InfoControllerTest {

	@Autowired
	private MockMvc mockMvc;

	private ObjectMapper objectMapper = new ObjectMapper(); // 요청값 json으로 변경(등록/수정) 
    
	@MockitoBean
	private InfoService infoService;
    
	@InjectMocks
	private InfoController infoController;

	// 수정 테스트 body에 들어갈 LocalDataTime 직렬화를 위해 JavaTimeModule을 등록하고, 날짜를 타임스탬프로 직렬화하지 않도록 설정
	@BeforeEach
	void setup() {
		objectMapper.registerModule(new JavaTimeModule());
		objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
	}
}

 

✅ Spring 제공하는 MockMvc 사용

 


🌐 원칙

- 요청값이 제대로 왔는지 테스트한다. 

 

2) 테스트 생각해보기 무엇이 필요할까

1) json 형태의 값이 @RequestBody 를 통해 Jackson 컨버팅 -> Java Class 와 제대로 매핑되나 
* Enum의 값에 해당함 
* Json 형태로 들어온 body 본문이 Jackson converter에 의해서 자바 클래스 필드 Enum 값으로 매핑되기 때문에 
2) 매핑 된 이후에 @Validation이 제대로 작동되나. 즉, 검증에 실패하면 그에 맞는 예외를 반환하는가. 
3) ApiResponse Template (Common / Exception) 에 맞는 응답을 뱉나

 

 

🔵 테스트 코드  

  • Jackson의 자바 클래스 매핑 확인
  • Enum 값으로 등록되지 않은 값을 넘기고 싶었기 때문에 json String 형태로 만들었음 (컴파일링 에러 방지)
@Test
void 정보_카테고리ENUM에_등록되지_않은_값이_들어왔을_때_DATA_NOT_READABLE_MESSAGE를_던진다() throws Exception {
    // given
    String json = String.format(
       "{\"title\":\"%s\",\"content\":\"%s\",\"infoCategory\":\"%s\",\"imageUrl\":\"%s\",\"isDisplay\":\"%s\"}",
       "테스트 제목",
       "테스트 내용 총 10자 이상 테스트",
       "NOT_EXISTING",
       "http://example.com/image.jpg",
       "Y");

    // when & then
    mockMvc.perform(
          post("/api/admin/info")
             .contentType(APPLICATION_JSON)
             .with(csrf())
             .content(json)
       )
       .andDo(print())
       .andExpect(status().isBadRequest())
       .andExpect(jsonPath("$.success").value(false))
       // Jackson 예외를 HttpMessageNotReadableException로 Wrapping
       .andExpect(jsonPath("$.message").value("읽을 수 없는 응답 데이터입니다."));
}

 

▶ Jackson 이 매핑시에 터트리는 에러는 @RequestBody의 에러에 감겨서 에러가 발생함 

 

 

🔴  실제 구현 코드

@AdminApi(reason = "관리자만 정보 공유 등록 가능")
@PostMapping("/api/admin/info")
public ApiTemplate<InfoDetailResponseByAdmin> saveInfo(@RequestBody @Valid InfoRequest saveRequest) {
    InfoDetailResponseByAdmin response = infoService.saveInfo(saveRequest);
    return ApiTemplate.ok(InfoResponseMessage.INFO_CREATED, response);
}

 

 

 


 

🔵 테스트 코드  

  • @Validation 체크 (경계값 검증)
  • 이 어노테이션을 통해 반환되는 예외 message는 errors: "..." 에 감쌈 
@ParameterizedTest
@CsvSource({
    "0, false",    // 길이 0 → 유효하지 않음
    "9, false",    // 길이  → 유효하지 않음
    "10, true",    // 길이 10 → 유효
    "1000, true",   // 길이 1000 → 유효
    "1001, false"   // 길이 1001 → 유효하지 않음
})
void 내용은_최소_10자_이상_최대_1000자_이하_여야하며_VALIDATION_예외를_던진다(int length, boolean isValid) throws Exception {
    // given
    InfoRequest request = buildInfoRequestWithContentLength(length);

    // when
    ResultActions actions = performInfoPost(request);

    // then
    if (isValid) {
       actions
          .andExpect(status().isOk());
    } else {
       actions
          .andExpect(status().isBadRequest())
          .andExpect(jsonPath("$.success").value(false))
          .andExpect(jsonPath("$.errors[0].fieldName").value("content"))
          .andExpect(jsonPath("$.errors[0].message").value("내용은 최소 10자 이상, 최대 1000자 이하까지 등록할 수 있습니다."));
    }
}

 

 

🔴  실제 구현 코드

@ExceptionHandler
public ResponseEntity<ExceptionResponse> handleMethodArgumentNotValidException(
    MethodArgumentNotValidException exception
) {
    List<ErrorSpot> errorSpots = extractErrorSpots(exception);
    ExceptionMessage exceptionMessage = extractExceptionMessage(exception);
    log.warn("[{}] : {}", exception.getClass(), errorSpots);

    return ResponseEntity.status(exceptionMessage.getHttpStatus())
       .body(DetailedExceptionResponse.fail(exceptionMessage, errorSpots));
}
private List<ErrorSpot> extractErrorSpots(MethodArgumentNotValidException exception) {
    return exception.getBindingResult()
        .getFieldErrors()
        .stream()
        .map(fieldError -> new ErrorSpot(fieldError.getField(), fieldError.getDefaultMessage()))
        .toList();
}


private ExceptionMessage extractExceptionMessage(MethodArgumentNotValidException exception) {
    boolean hasTypeMismatch = exception.getBindingResult()
       .getFieldErrors()
       .stream()
       .anyMatch(FieldError::isBindingFailure);

    if (hasTypeMismatch) {
       return ARGUMENT_TYPE_MISMATCH_MESSAGE;
    }
    return ARGUMENT_NOT_VALID_MESSAGE;
}

 

 

 

 


 

2. 컨트롤러 단위테스트 에서 중요했던 포인트 

1) ExceptionHandler가 제대로 정의되었나

- @RequestBody Jackson Converting 오류 : HttpMessageNotReadableException

- @Validation 오류 : MethodArgumentNotValidException

- @PathVarialbe 오류 : NoResourceFoundException

 

2) CommonResponse가 제대로 정의되었나

public record ApiTemplate<T>(
    boolean success,
    String message,
    T result
) {
	...
}

 

3) 조회, 삭제 는 content에 json을 넣을 필요가 없다. "요청만 테스트"

 

4) SpringBoot 에서는 제공되지만 테스트에선 안되는 각종 부가 기능들 임의 주입 

- LocalDateTime : 직렬화를 위해 objectMapper에 따로 모듈 등록

- csrf() : Spring Security가 안전하지 않은(state-changing) HTTP 메서드(POST, PUT, PATCH, DELETE)에 대해 기본적으로 CSRF로 보호하기 때문에 MockMvc 테스트 시에 csrf를 추가해야한다.