validation을 사용하여 dto 코드를 작성하고 통합테스트를 진행하던 도중 처음 겪는 문제에 직면했다.
📌 서론
통합테스트에서 "사용자가 잘못된 값을 입력해서 보내면 적절한 예외가 발생한다." 이런 bad 케이스 테스트를 작성하고 실행했다.
이때 예외가 발생해야 테스트가 성공하는데 내가 작성한 테스트에서는 예외가 발생하지 않았다. 그래서 나는 가장 먼저 사용자로부터 데이터를 받아오는 dto객체를 살펴봤다.
나는 dto 객체 내부에 Integer타입을 받는 변수를 선언해 주고 spring-validation의 기능을 적용하여 @Min(0)을 적어줬는데 이렇게 해주면 음수(-) 값이 들어왔을 때 검증에 의해 예외가 발생한다.
이렇게 열심히 세팅한 후 테스트를 진행했다. 그런데 보기 좋게 테스트에 실패했다. 예외가 발생해야 하는데 아무 문제 없이 동작한 것이다. "대체 왜 예외가 발생하지 않을까?" 지금부터 그 이유를 같이 살펴보자
1. 테스트에서 문제가 발생했다.
실수를 파악하기 위해 validation을 적용시킨 dto객체를 보자
- 아래의 dto는 가장 최상단에서 클라이언트의 요청을 받아서 데이터를 전달하는 클래스다.
@Data
@NoArgsConstructor
public class NutritionalInfoDto {
private Long id; // pk
@Min(0)
private Integer carbohydrates; // 탄수화물 함량
@Min(0)
private Integer protein; // 단백질 함량
@Min(0)
private Integer fat; // 지방 함량
@Min(0)
private Integer vitamins; // 비타민 함량
@Min(0)
private Integer minerals; // 미네랄 함량
// 생성자, 팩토리 메서드of는 생략...
}
어댑터 레이어 통합 테스트에서 잘못 작성한 코드
- 아래의 코드는 테스트에 실패한 코드다.
- NutritionalInfo라는 영양소 도메인 객체가 있는데 이걸 생성하는 createNutritionalInfoDomain() 메서드의 매게 변수로 잘못된 변숫값인 -50(음수)을 넣어주는 것을 볼 수 있다.
@Test
@DisplayName("[bad] 잘못된 데이터로 영양소를 업데이트하면, 적절한 예외가 발생한다")
void updateNutritionalInfoException() {
//given
RecipeEntity savedRecipeEntity = recipeRepository.findById(1L).orElseThrow();
NutritionalInfoEntity savedNutritionalInfoEntity = nutritionalInfoRepository.findById(1L).orElseThrow();
// 잘못된 영양소 정보 생성 (음수 값 사용)
NutritionalInfo savingNutritionInfo = createNutritionalInfoEntity(
savedNutritionalInfoEntity.getId(),
-50,
50,
50,
50,
50
);
Recipe recipe = createRecipeDomainWithId(
savedRecipeEntity.getId(),
savingNutritionInfo,
1L,
List.of()
);
//when-then
Assertions.assertThatThrownBy(() -> sut.updateNutritionalInfo(recipe))
.isInstanceOf(RuntimeException.class);
}
// 이 코드가 호출되어 도메인 객체를 생성해 준다.
public NutritionalInfo createNutritionalInfoDomain(long id, int carbohydrates, int protein, int fat, int vitamins, int minerals) {
// 매게변수로 받은 값을 사용해서 dto생성 (validation 조건 존재)
NutritionalInfoDto dto = NutritionalInfoDto.of(id, carbohydrates, protein, fat, vitamins, minerals);
// 생성한 dto를 다시 도메인 객체로 변환
return NutritionalInfo.of(
dto.getId(),
dto.getCarbohydrates(),
dto.getProtein(),
dto.getFat(),
dto.getVitamins(),
dto.getMinerals()
);
}
이 상태에서 테스트를 실행해 보자(-50(잘못된 인자)을 넣어줬기 때문에 예외가 발생해야 한다)
테스트 결과: "java.lang.AssertionError: Expecting code to raise a throwable."
- 내 예상대로면 "-50"이라는 음수가 들어간 필드가 존재하므로 @Min(0) 조건에 따라 예외가 발생하고 테스트는 통과했어야 했다. 근데 이상하게도 실제 코드를 동작시키면 아무런 예외가 발생하지 않았다.
- 아래 결과 이미지를 보면 예외가 발생해야만 성공하는 bad 케이스 테스트는 실패하고 말았다. (음수가 들어가서 valid관련 예외가 발생할 줄 알았는데 실제로는 예외가 발생하지 않고 코드가 성공적으로 동작했기 때문이다.)
2. 테스트가 실패한 이유
테스트가 실패한 이유는 다음과 같다.
- 음수가 입력되었음에도 예외가 발생하지 않는 이유는 @Min(0) 어노테이션으로 설정된 검증이 테스트 환경에서 자동으로 실행되지 않기 때문이다.
- 이 검증은 주로 Spring MVC 프레임워크에서 들어오는 요청을 처리할 때 사용되는데, 단위 테스트와 같이 Spring MVC 계층을 포함하지 않는 경우에는 자동으로 트리거 되지 않는다.
이런 간단한 이유 때문이라고? 그럼 어떻게 테스트해야 할까?
- 통합 테스트는 애플리케이션의 여러 컴포넌트가 함께 작동하는 것을 확인하는 데 초점을 맞춘다. 이는 단순히 유효성 검증 로직을 테스트하는 것을 넘어서, 실제 애플리케이션 컨텍스트에서의 상호작용을 검증하는 것이다.
- Spring MVC를 사용하는 웹 애플리케이션의 경우, MockMvc 또는 TestRestTemplate을 사용하여 실제 HTTP 요청을 시뮬레이션하고, 요청에 대한 유효성 검증이 적절히 수행되는지 테스트할 수 있다.
즉, 내가 MVC계층으로 받은 요청을 통해 테스트를 진행한 것이 아니기 때문에 Validator가 실행이 안된 것이다.
나는 이런 생각이 들었다.
내가 하려는 건 어댑터 컴포넌트의 통합테스트니 컨트롤러를 호출하지 않는다. 그러면 dto객체의 validator가 트리거 되지 않아 실행되지 않을 것이고 결국 조건 검증을 하지 않게 될 것이다. 그럼 잘못된 데이터에 대한 검증은 어댑터 레이어에서 하지 말고 dto 테스트 코드를 만들어서 처리하는 게 맞는 건가?
📌 전지전능하신 GPT께 질문드렸더니 이렇게 답해주셨다.
맞아. 일반적으로 컨트롤러 레벨에서 @Valid 어노테이션을 사용하여 DTO의 검증을 처리하고, 서비스 레이어나 리포지토리 레이어에서는 이러한 유효성 검증을 다시 수행하지 않는 것이 일반적인 패턴이야. 이는 계층화된 아키텍처에서 각 계층의 책임을 명확히 하고 중복을 피하기 위한 방법이지.
따라서, updateNutritionalInfo() 메서드 내에서 다시 데이터 유효성 검사를 하는 것보다는 컨트롤러에서 이미 검증된 데이터를 전달받고, 이를 처리하는 방식으로 구현하는 것이 더 효율적이고 명확해. 만약 예외 상황에 대한 처리가 필요하다면, 해당 예외를 적절히 처리하는 로직을 추가하는 것이 좋겠어.
3. 포기하지 말고 테스트를 진행해 보자
valid 검증 자체를 통합테스트 내부에서 진행한다면 어떻까?
- validation을 테스트 내부에서 동작시키기 위해 클래스에 전역 변수로 Validator 인스턴스를 선언하고 @BeforeEach를 통해 각 테스트 실행 전 validator를 세팅하도록 해준다.
private Validator validator;
@BeforeEach
public void setUp() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
- 이후 아래와 같이 테스트 코드를 작성했다.
@Test
@DisplayName("[bad] 잘못된 데이터로 영양소를 업데이트하면, 적절한 예외가 발생한다")
public void updateNutritionalInfoExceptio4n() {
// 1. 저장되어있던 레시피 엔티티 가져오기
RecipeEntity savedRecipeEntity = recipeRepository.findById(1L).orElseThrow();
// 2. 잘못된 영양소 정보를 가진 dto생성
NutritionalInfoDto dto = NutritionalInfoDto.of(1L, -50, 50, 50, 50, 50);
// 3. dto to domain
NutritionalInfo savingNutritionInfo = dtoToDomain(dto);
// 4. dto 검증 실시
Set<ConstraintViolation<NutritionalInfoDto>> violations = validator.validate(dto);
Recipe recipe = createRecipeDomainWithId(savedRecipeEntity.getId(), savingNutritionInfo, 1L, List.of());
// 5. 검증 실시
Assertions.assertThat(violations).isNotEmpty();
}
테스트 코드 디버깅 실시
- 작성한 코드를 디버깅해 봤더니 아래와 같이 violations 객체 내부에는 "0 이상이어야 합니다"라는 오류 메시지와 함께 예외가 발생한 것을 알 수 있었다.
- 디버깅을 마무리하면 이렇게 테스트에 성공한다.
이게 끝인 줄 알았는데.. 생각해 보니 이건 내가 해보려 했던 테스트의 목적과는 멀었다.
내가 하려고 했던 테스트를 다시 한번 살펴보자
[잘못된 데이터로 영양소를 "업데이트"하면, 적절한 "예외가 발생한다."]
즉, 내가 원하는 통합테스트의 목적에 부합하려면 실제로 업데이트를 진행하는 메서드인 sut.updateNutritionalInfo(recipe)를 호출해야만 한다는 것이다.
📌 결론
아무리 생각해 봐도 어떻게 해야 이 테스트가 통과할지 방법이 떠오르지 않는다.
실제로 사용자가 컨트롤러를 호출했을 때의 동작을 생각해 보면 사용자가 처음부터 잘못된 데이터를 넣어줬다면 애초에 dto의 validation에서 검증되어 예외 처리가 되었을 것이다. 즉, 어댑터까지 오지도 못했을 것이란 의미다.
근데 여기서 또 고민되는 것이 있다. 그럼 dto객체가 검증(validation)이 사라진 다른 객체로 변경된다면? 그렇게 되면 잘못된 데이터를 넣어도 어댑터에서 예외처리되지 않고 멀쩡히 통과할 것이다.
이것을 방지하기 위해서 어댑터 코드에도 예외처리를 하는 코드를 작성해줘야 하는 건가?
이런 예외처리 방식에 대해 정말 많은 고민이 된다. 특히 최근에 테스트 코드를 많이 작성하면서 더 많이 고민하게 되는데 누군가 도움을 주실 수 있다면 경험이나 지혜를 나눠주셨으면 한다.
'Spring 테스트 코드' 카테고리의 다른 글
[Spring] 단위테스트 @InjectMocks 사용방법 (7) | 2024.08.17 |
---|---|
[Spring] 예외 테스트의 중요성: 바인딩 오류 (5) | 2024.02.03 |
[Spring] 테스트: @ParameterizedTest 사용방법 (29) | 2023.12.24 |
[Spring] 테스트 코드: @MockBean/@SpyBean 사용방법 (66) | 2023.12.24 |
[Spring] 테스트 코드: @SpyBean으로 @EventListener 검증하기 (40) | 2023.12.23 |