Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 디스크i/o
- 커뮤니티서버
- 이벤트핸들러this
- DDD
- MVC요청플로우
- index
- string
- JDBC
- 클린아키텍처
- 로그백
- JPA
- 문제선택근거
- 사면초가
- SpringLegacy
- MVC
- springsecurity
- 공통기술
- SEQUENCE
- 냄새라도
- Transaction
- OOP
- 이벤트핸들러등록
- Ajax
- TDD
- 공통규약
- 전전긍긍
- 설정세팅
- model
- 단위테스트
- AOP
Archives
- Today
- Total
개발이군고구마
[냄새라도 TDD/OOP] 2-2. 단위 테스트 (컨트롤러) 본문
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를 추가해야한다.
'SERVER > Architecture' 카테고리의 다른 글
| [TDD] 단위 테스트에서 만난 오류와 원리들 (0) | 2025.09.03 |
|---|---|
| [MSA] 공통으로 사용하는 컴포넌트의 API 설계 방법 (1) | 2025.07.07 |
| [냄새라도 TDD/OOP] 3. 통합테스트 (서비스) (0) | 2025.06.27 |
| [냄새라도 TDD/OOP] 2-1. 단위 테스트 (도메인단) (0) | 2025.06.27 |
| [냄새라도 TDD/OOP] 1. 구현 원칙 확립 (0) | 2025.06.27 |