예외 테스트를 왜 작성해야 하는지 경험한 내용을 공유하고자 한다.
📌 서론
내가 겪은 3가지 문제와 이것의 해결과정을 설명하고자 한다.
1. 안드로이드에서 데이터를 requestDto로 잘 보냈는데 스프링 서버에서는 null로만 받는 문제 발생
2. 1번 문제를 해결했더니 데이터 validation 문제 발생
3. 테스트 코드를 작성했는데 왜 이런 문제들이 발생한 걸까?
1. 첫 번째 문제: 데이터를 null로 받음
문제의 코드
- 아래의 컨트롤러 코드에 데이터를 보내면 모두 null을 받았다.
@RequestMapping("/recipe")
@RequiredArgsConstructor
@RestController
public class RecipeLikeController {
private final RecipeLikeUseCase recipeLikeUseCase;
private final RecipeLikeConverter recipeLikeConverter;
@PostMapping("/like")
public ResponseEntity<ResponseDto<Long>> recipeLike(
RecipeLikeRequestDto likeRequestDto
) {
// recipeId를 통해 좋아요 도메인 객체를 생성한다.
RecipeLike domain = recipeLikeConverter.dtoToDomain(likeRequestDto);
Long savedLikeId = recipeLikeUseCase.recipeLikeProcess(domain);
return ResponseEntity.ok(ResponseDto.success(savedLikeId));
}
}
요청 dto를 살펴보자
- 클라이언트로부터 데이터를 전달받을 dto 객체에는 validation이 적용되어 있다.
/**
* 좋아요 dto 객체
*/
@Data
@NoArgsConstructor
public class RecipeLikeRequestDto {
Long recipeLikeId;
@NotNull
Long recipeId;
@NotNull
Long memberId;
@Builder
private RecipeLikeRequestDto(Long recipeLikeId, Long recipeId, Long memberId) {
this.recipeLikeId = recipeLikeId;
this.recipeId = recipeId;
this.memberId = memberId;
}
public static RecipeLikeRequestDto of(Long recipeLikeId, Long recipeId, Long memberId) {
return new RecipeLikeRequestDto(recipeLikeId, recipeId, memberId);
}
}
안드로이드 요청은 다음과 같다.
- 안드로이드에서 retrofit을 통해 "/recipe/like"에 좋아요 요청을 보냈다.
/**
* 좋아요 retrofit 서비스
*/
interface RecipeLikeService {
// 좋아요 추가/삭제
@POST("/recipe/like")
suspend fun recipeLike(
@Body dto: RecipeLikeRequestDto
) :Response<ResponseDto<Long>>
}
디버깅으로 요청 내용을 살펴보자
- 아래와 같이 RecipeLikeRequestDto가 필수적으로 필요로 하는 recipeId, memberId를 보내는 것을 알 수 있었다.
스프링 디버깅 확인
- 음..? 스프링에도 디버깅을 해놓고 보고 있었는데 분명 데이터를 보냈는데 서버에서는 요청 데이터를 전부 null로 받고 있었다. 이 상황이 너무 당황스러워서 순간 뇌정지가 왔지만 침착하게 안드로이드의 logcat을 확인했다.
안드로이드 logcat확인
- logcat 확인 결과 요청할 때는 제대로 값을 보낸다. (첫 좋아요 요청은 recipeLikeId = null이 정상이다.) 다만 맨 하단을 보면 서버 측에서 내가 작성한 커스텀 에러코드(80005)를 반환받은 것을 확인할 수 있었다.
- STATUS는 500이었고 서버 로깅에는 다음과 같이 런타임 에러 기록되어 있었다. 이 내용에는 InvalidDataAccessApiException으로 given id는 null이어서는 안 된다는 것이었는데 이 오류는 Validation에서 발생한 오류가 아니라 repository에서 호출하는 QueryDSL 코드에서 like를 취소할 때 엔티티의 id가 필수인데 null값이 들어가 있으니 서버에서 500 오류가 발생한 것이었다.
RuntimeException occurred
InvalidDataAccessApiUsageException: The given id must not be null
분석하기
- 이 문제는 안드로이드에 있다고 생각하지는 않았다. 디버깅과 로그에는 분명히 데이터를 제대로 보낸 기록이 있었기 때문이다. 그래서 나는 이게 내가 작성한 스프링 코드 특히 validation이 동작하지 않는 것으로 보아 dto코드의 문제라고 생각하고 dto 코드를 확인했다. 그런데..? dto에는 @Data가 적혀있어서 Setter 메서드도 존재하기 때문에 데이터를 알아서 바인딩해 줄 터다. 뭐가 문제일까?? 명상을 하며 고민하던 도중 이유를 알아냈다.
문제 해결해 보기
- 문제 해결에 접근한 방법은 다음과 같았다. 생각해 보니 validation을 사용하려면 요청 dto 내부만 적어주는 것이 아니라 API의 매게 변수에도 @Valid를 적어주는 것이 필수였다. 이것을 까먹다니....!! 얼른 수정작업을 진행해서 아래와 같이 코드를 작성했다.
@PostMapping("/like")
public ResponseEntity<ResponseDto<Long>> recipeLike(
@Valid RecipeLikeRequestDto likeRequestDto
) {
// recipeId를 통해 좋아요 도메인 객체를 생성한다.
RecipeLike domain = recipeLikeConverter.dtoToDomain(likeRequestDto);
Long savedLikeId = recipeLikeUseCase.recipeLikeProcess(domain);
return ResponseEntity.ok(ResponseDto.success(savedLikeId));
}
2. 두 번째 문제: 데이터 바인딩 실패 (validation 오류)
이번에는 다른 문제가 발생했다.
- 문제가 바로 해결되었다면 참 좋았겠지만 테스트 결과 다른 오류가 발생했다. 이번에는 400 오류가 발생한 것이다.
또다시 안드로이드의 logcat을 확인했다.
- 나는 분명 memberId, recipeId를 요청 body에 담아서 보냈는데 logcat에서는 이 데이터가 전달되지 않아서 validation에 실패했다는 응답을 반환하고 있었다. (하단의 오류 메시지에 message부분에 적힌 값들이 validation에 실패한 필드명이다.)
스프링 서버의 로그 확인
- 이번에는 스프링에서 디버깅 걸어줘도 잡히지 않았다. 그래서 스프링의 로그를 확인했다. valid에 실패했다는 메시지가 남은 것으로 보아 이건 분명 요청 dto를 처리하는 단계에서 validation이 실패한 것이라고 판단했다.
로그 분석 결과
- 오류 내용을 요약하면 다음과 같다. (recipeId, memberId가 null이다.)
- 음...? 뭐지?? 나는 recipeId, memberId를 분명히 넣어서 보냈는데? 안드로이드 로그에도 남아있는데? 이전에는 validation이 안되어서 그런 줄 알았는데 애초에 null로 바인딩이 되고 있네?
2024-01-29T14:30:07.778+09:00 WARN .m.m.a.ExceptionHandlerExceptionResolver :
Resolved ipe.adapter.in.web.dto.request.RecipeLikeRequestDto) with 2 errors: [Field error in object 'recipeLikeRequestDto' on field 'recipeId': rejected value [null]; codes
[recipeLikeRequestDto.recipeId,recipeId]; arguments []; default message [recipeId]]; default message [널이어서는 안됩니다]]
[recipeLikeRequestDto.memberId,memberId]; arguments []; default message [memberId]]; default message [널이어서는 안됩니다]]
문제 해결해 보기
- 이번에는 dto, @valid의 문제도 아니다. 그럼 뭘까? 잠깐 모든 작업을 멈추고 커피를 한잔하고 와서 다시 코드를 봤다. 그랬더니 문제점이 바로 보이는 것이 아닌가?? @Valid옆에 가장 중요한 어노테이션인 @RequestBody가 빠져있었다.
- 아래와 같이 코드를 수정했다.
@PostMapping("/like")
public ResponseEntity<ResponseDto<Long>> recipeLike(
@Valid @RequestBody RecipeLikeRequestDto likeRequestDto
) {
// recipeId를 통해 좋아요 도메인 객체를 생성한다.
RecipeLike domain = recipeLikeConverter.dtoToDomain(likeRequestDto);
Long savedLikeId = recipeLikeUseCase.recipeLikeProcess(domain);
return ResponseEntity.ok(ResponseDto.success(savedLikeId));
}
결론
- 다시 요청을 보냈더니 아래와 같이 제대로 값을 받는 것을 확인할 수 있었다. 이렇게 모든 로직이 해결되었다.
- 근데 나는 분명 테스트 코드를 통과한 것만 사용 중인데 왜 이런 오류가 발생한 것일까?
3. 세 번째 문제: 테스트 코드
나의 생각
- 이번 사태가 왜 발생했을지 고민해 본 결과 가장 큰 문제는 내가 작성한 테스트 코드였던 것 같다. 분명 나는 모든 API를 테스트 코드에 통과한 것만 사용하고 있었다. 그런데 왜 테스트 코드에서 결과가 통과한 것인지 이해가 불가능했다.
@DisplayName("[통합] 좋아요 컨트롤러 테스트")
@AutoConfigureMockMvc
class RecipeLikeControllerTest extends TotalTestSupport {
@MockBean
private RecipeLikeConverter recipeLikeConverter;
@MockBean
private RecipeLikeUseCase recipeLikeUseCase;
@Autowired
private MockMvc mockMvc;
private ObjectMapper objectMapper = new ObjectMapper();
@Test
@DisplayName("[통합] 유저가 좋아요 요청을 시도하면 정상적으로 처리된다.")
void testRecipeLikeRequest() throws Exception {
// given
RecipeLikeRequestDto requestDto = RecipeLikeRequestDto.of(1L, 1L);
RecipeLike domain = RecipeLike.of(Recipe.of(1L), 1L);
when(recipeLikeConverter.dtoToDomain(any(RecipeLikeRequestDto.class))).thenReturn(domain);
when(recipeLikeUseCase.recipeLikeProcess(domain)).thenReturn(1L);
// when & then
mockMvc.perform(post("/recipe/like")
.contentType(MediaType.APPLICATION_JSON)
.content(asJsonString(requestDto)))
.andExpect(status().isOk());
}
private String asJsonString(final Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
코드 실행 결과는 성공이다.
- 이 테스트를 동작시킨 결과는 다음과 같다. 문제없이 통과하는 것을 알 수 있다.
테스트 성공 이유 파악하기
- 생각해 보면 이 당시 컨트롤러에는 @Valid도 @RequestBody도 없어서 테스트가 실패했어야 하는 거 아닌가?
테스트 코드에 print함수를 추가해서 결과를 확인했다.
// when & then
mockMvc.perform(post("/recipe/like")
.contentType(MediaType.APPLICATION_JSON)
.content(asJsonString(requestDto)))
.andDo(print())
.andExpect(status().isOk());
요청 MockHttpServletRequest의 결과는 다음과 같다.
- 이 로그를 보면 Body에 데이터를 잘 넣어주고 있다. 그 이유를 알겠는가?
- 당연히 내가 테스트 코드에서 .content(asJsonString(requestDto))로 dto를 직접 json 변환해서 넣어주고 있었으니 Body에 담긴 것이다.
응답 MockHttpServletResponse의 결과는 다음과 같다.
4. 테스트가 통과한 이유
@RequestBody와 테스트 코드 작성방식
- @RequestBody 애노테이션은 HTTP 요청의 본문(body)을 자바 객체로 매핑할 때 사용되는데, Spring MVC가 이 작업을 자동으로 처리해 준다. 하지만 MockMvc를 사용하는 테스트에서는 perform 메서드를 통해 직접 요청 본문을 JSON 문자열 형태로 지정하고, 이 문자열이 컨트롤러 메서드의 파라미터로 전달되도록 설정할 수 있다.
- 이 경우, Spring의 MockMvc 테스트 프레임워크가 요청 본문의 내용을 컨트롤러 메서드의 파라미터 객체로 변환하는 과정을 내부적으로 처리해 준다. 즉, 내가 테스트 코드에서 content(asJsonString(requestDto))) 이렇게 값을 변환해서 세팅해 주니 당연히 @RequestBody를 안 적었어도 문제없이 테스트가 통과했던 것이다.
// when & then
mockMvc.perform(post("/recipe/like")
.contentType(MediaType.APPLICATION_JSON)
.content(asJsonString(requestDto)))
.andDo(print())
.andExpect(status().isBadRequest());
5. 예외 테스트 추가하기
이걸 파악했다고 내 문제가 해결된 걸까? 아니다. 나는 가장 중요한 문제점을 알아냈다.
- 내가 컨트롤러에서 첫 번째 문제 (@Valid)를 적어주는 것을 해결하지 않았다면 테스트 요청 dto에 어떤 값을 넣어줬든 통과했을 것이다.(validation을 안 하고 mockBean으로 모든 동작이 모킹 되어 있었으니까 어떤 값이든 서비스 레이어도 성공했을 것이다.)
- 그럼 예외상황에 대한 테스트를 작성했어야 하는 것 아닌가? (이게 지금 사태가 발생하게 된 원인이었던 것이다.)
문제점을 파악했으니 예외 테스트를 작성해 보자
- 먼저 컨트롤러에 @Valid와 @RequestBody를 모두 추가하여 문제점을 없앴다.
@PostMapping("/like")
public ResponseEntity<ResponseDto<Long>> recipeLike(
@Valid @RequestBody RecipeLikeRequestDto likeRequestDto
) {
// recipeId를 통해 좋아요 도메인 객체를 생성한다.
RecipeLike domain = recipeLikeConverter.dtoToDomain(likeRequestDto);
Long savedLikeId = recipeLikeUseCase.recipeLikeProcess(domain);
return ResponseEntity.ok(ResponseDto.success(savedLikeId));
}
이후 아래와 같이 예외 테스트 코드를 작성했다.
- 요청 dto의 recipeId를 null로 설정해서 넣어준다. (일부로 예외상황을 발생시킨다.)
- 응답 STATUS 기댓값을 400으로 설정한다. (예외 테스트니 당연히 validation에서 예외가 발생해서 400이어야 한다.)
@Test
@DisplayName("[통합] 유저가 좋아요 요청을 시도하면 정상적으로 처리된다.")
void testRecipeLikeRequest() throws Exception {
// given (여기서 일부로 Dto에 null값을 넣어서 예외를 발생시킨다.)
RecipeLikeRequestDto requestDto = RecipeLikeRequestDto.of(null, 1L);
RecipeLike domain = RecipeLike.of(Recipe.of(null), 1L);
when(recipeLikeConverter.dtoToDomain(any(RecipeLikeRequestDto.class))).thenReturn(domain);
when(recipeLikeUseCase.recipeLikeProcess(domain)).thenReturn(1L);
// when & then
mockMvc.perform(post("/recipe/like")
.contentType(MediaType.APPLICATION_JSON)
.content(asJsonString(requestDto)))
.andDo(print())
.andExpect(status().isBadRequest()); //상태응답이 400이면 성공시킨다.
}
테스트를 실행했다.
- 새로운 테스트 또한 성공했으며 아주 만족스러운 결과를 얻을 수 있었다.
우측 테스트 로그를 확인해 보자
- 내가 요청에 담아줄 dto객체를 만들 때 일부로 recipeId를 null로 넣었는데 spring-validation이 제대로 동작해서 recipeId는 null이면 안된다고 예외가 발생했다. (대성공이다.)
마지막으로 테스트의 print로그를 다시 확인했다.
- 요청에서 recipeId에 null이 들어간 것을 확인했다.
- 응답에도 제대로 400이 발생했으며 custom예외코드의 message 필드에 validation에 실패한 필드명인 "recipeId"가 들어가 있는 것을 확인했다.
📌 결론
테스트를 작성할 때 왜 예외테스트를 자생해야 하는지 깨달았다. 아무리 귀찮아도 happy, bad 테스트는 모두 작성하는 습관을 가져야겠다. 안드로이드 개발과 동시에 작업을 진행하느라 빼먹었던 것 같은데 앞으로는 잊어먹지 않게 테스트 코드 작성 전 happy, bad를 모두 테스트 코드 틀만이라도 만들어 놓고 시작해야겠다.
'Spring 테스트 코드' 카테고리의 다른 글
[Spring] 단위테스트 @InjectMocks 사용방법 (7) | 2024.08.17 |
---|---|
Spring 통합테스트: Validation 문제 해결과 깊은 고민 (30) | 2024.01.10 |
[Spring] 테스트: @ParameterizedTest 사용방법 (29) | 2023.12.24 |
[Spring] 테스트 코드: @MockBean/@SpyBean 사용방법 (66) | 2023.12.24 |
[Spring] 테스트 코드: @SpyBean으로 @EventListener 검증하기 (40) | 2023.12.23 |