나의 개발일지
[트러블 슈팅] AssertJ 를 통해 CustomException 내에 추가로 정의한 필드 테스트 본문
[트러블 슈팅] AssertJ 를 통해 CustomException 내에 추가로 정의한 필드 테스트
사각분무기 2024. 4. 19. 13:25프로젝트가 끝나고 다들 바쁜 시기입니다.
6 개월이라는 긴 수료기간에 지친 마음을 요양하는 시간을 가지는 분들도 있고, 취업 준비에 여념이 없는 분들도 있네요.
마음 같아선 좀 더 고도화를 진행해보고 싶지만 이러한 사정들로 어려워 보이기 때문에 개인적인 리펙토링만을 수행하고 있습니다.
리펙토링만 하는 것도 좋지만 재미가 없을 것 같더라구요.
테스트도 같이 진행해 보기로 했습니다.
통합 테스트와 단위 테스트 중 하나를 선택해야 하는 입장에 있어 팀원들과 진행할 수 없는 상황인 저에게는 다른 코드와의 의존성을 차단한 채로 순수하게 해당 기능만을 테스트해 볼 수 있는 단위 테스트를 선택했습니다.
저희는 Exception을 따로 정의하는 방식으로 사용하고 있습니다.
RuntimeException을 상속받는 Exception 클래스에 저희가 임의로 사용하고 싶은 필드인 code와 message라는 변수를 추가하는 방식입니다.
@Getter
public class BadRequestException extends RuntimeException {
private final int code;
private final String message;
public BadRequestException(final ExceptionCode exceptionCode) {
this.code = exceptionCode.getCode();
this.message = exceptionCode.getMessage();
}
}
public class PaymentException extends BadRequestException {
public PaymentException(final ExceptionCode exceptionCode) {
super(exceptionCode);
}
}
@RequiredArgsConstructor
@Getter
public enum ExceptionCode {
INTERNAL_SEVER_ERROR(9999, "서버 에러가 발생하였습니다. 관리자에게 문의해 주세요."),
PAYMENT_NOT_POSSIBLE(2008, "이미 취소가 완료되어 결제가 불가능합니다.");
private final int code;
private final String message;
}
위와 같은 코드로 이루어져 있습니다.
RuntimeException 을 상속받는 큰 틀의 Exception인 BadRequestException을 정의한 후 도메인마다 다른 클래스로 정의하여 사용함으로써 오류가 어디서 발생하는지를 명확히 했습니다.
실제로 사용하는 ExceptionCode의 종류는 이보다 더 다양합니다.
@Transactional // 충전 진행
public void processRecharge(final Recharge recharge, final Long discountAmount) {
if (recharge.isCancelled()) throw new PaymentException(PAYMENT_NOT_POSSIBLE);
cashLogService.addCashLogDone(recharge, discountAmount);
}
그리고 위는 제가 테스트하고자 하는 코드입니다.
isCancelled() 메서드에서 CancelDate 필드가 null 이 아닐 경우 true를 반환하고, 이 경우 오류를 던집니다.
@Test
@DisplayName("충전을 진행한다 - 실패 케이스(이미 취소된 충전 신청)")
void processRecharge_paymentNotPossible() {
// given
Recharge recharge = Recharge.builder().cancelDate(LocalDateTime.now()).build();
// when
Throwable thrown = catchThrowable(() -> rechargeService.processRecharge(recharge, null));
// then
Assertions.assertThat(thrown).isInstanceOf(PaymentException.class)
.hasMessage("이미 취소가 완료되어 결제가 불가능합니다.");
}
위는 테스트 코드입니다.
현재 저희가 사용하는 Exception은 message 와 code라는 변수를 가집니다.
위의 테스트 코드는 해당 변수 중 message를 통해 해당 Exception이 제가 원하는 Exception인지 확인합니다.
여기서 저는 message의 값은 너무 내용이 길고 오탈자에 의해 간단히 테스트가 실패할 수 있다는 생각이 들었습니다.
이에 비해 code의 경우 4자리의 int 값이기 때문에 오탈자가 날 확률이 적어 테스트에 용이합니다.
저는 AssertJ에서 제공하는 클래스들을 커스터마이징해보기로 했습니다.
먼저 message 값을 확인해주는 hasMessage() 메소드를 제공하는 클래스를 상속하는 클래스를 하나 만듭니다.
그 후 해당 클래스에 hasCode() 라는 코드를 새로 만들어주고 hasMessage() 메소드의 틀을 따라하면 될 것입니다.
public abstract class AbstractThrowableAssert<SELF extends AbstractThrowableAssert<SELF, ACTUAL>, ACTUAL extends Throwable>
extends AbstractObjectAssert<SELF, ACTUAL> {
@VisibleForTesting
Throwables throwables = Throwables.instance();
public SELF hasMessage(String message) {
throwables.assertHasMessage(info, actual, message);
return myself;
}
}
hasMessage() 메서드는 Throwables 객체를 필요로 하네요.
hasMessage() 메서드는 내부적으로 Throwables 의 assertHasMessage() 메서드를 통해 작동하는 것을 확인했습니다.
그렇다면 Throwables 를 상속하는 커스텀 클래스도 만든 후 assertHasCode() 메서드를 작성하면 될 것입니다.
public class Throwables {
private static final Throwables INSTANCE = new Throwables();
public static Throwables instance() {
return INSTANCE;
}
@VisibleForTesting
Failures failures = Failures.instance();
@VisibleForTesting
Throwables() {}
}
문제
Throwables 를 상속받는 CustomThrowables 를 만들고 생성자도 입력해주었습니다.
하지만 인텔리제이에서 컴파일 에러를 반환합니다.
실제로 애플리케이션을 실행시킬 경우엔 아래와 같은 에러를 보여줍니다.

Throwables 의 생성자에 접근할 수가 없다고 합니다.
원인 파악
이유는 Throwables 객체가 가지는 생성자의 접근 제어자가 default 이기 때문이었습니다.
평소 여러가지 접근 제어자가 있다는 사실은 인지하고 있었습니다.
private 의 경우 내부 호출 외에는 접근이 불가능하고
protected 는 내부 호출, 같은 패키지 내 호출, 상속 받은 클래스에서의 호출이 가능하며
default 는 그 중간쯤으로 내부 호출과 같은 패키지 내 호출만을 허용합니다.
default 접근 제어자의 경우 '이 기능이 과연 활용될만한 부분이 있을까' 라는 생각을 했었는데 이런 식으로 활용되는군요.
그간 접근 제어자를 통한 캡슐화라는 개념을 듣기만 했기에 실제로 그 개념의 영향을 받아보니 감회가 새롭습니다.
해결
클래스를 커스터마이징하는 방식의 접근 방법 대신 AssertJ 에서 제공하는 좋은 메서드를 찾았습니다.
catchThrowable() 메서드를 통해 얻은 Exception을 matches() 라는 메서드를 통해 원하는대로 비교할 수 있었습니다.
@Test
@DisplayName("충전을 진행한다 - 실패 케이스(이미 취소된 충전 신청)")
void processRecharge_paymentNotPossible() {
// given
Recharge recharge = Recharge.builder().cancelDate(LocalDateTime.now()).build();
// when
Throwable thrown = catchThrowable(() -> rechargeService.processRecharge(recharge, null));
// then
Assertions.assertThat(thrown).isInstanceOf(PaymentException.class)
.matches(ex -> ((BadRequestException) ex).getCode() == 2008);
}'Language > SpringBoot' 카테고리의 다른 글
| [트러블 슈팅] Projections 를 활용하여 Enum 변수를 정제한 DTO 생성하기 (0) | 2024.04.25 |
|---|---|
| [트러블 슈팅] Spring 환경에서 @WebMvcTest를 활용한 컨트롤러 테스트 진행하기 - 403 forbidden 오류 해결 (0) | 2024.04.05 |
| [SpringBoot] WebClient 비동기 흐름 내 Transaction 비활성화 트러블슈팅 - 2 ( 문제 발생 및 해결 ) (0) | 2024.02.22 |
| [SpringBoot] WebClient 비동기 흐름 내 Transaction 비활성화 트러블슈팅 - 1 ( 토스페이먼츠 WebClient 로 구현 ) (0) | 2024.02.22 |
| [SpringBoot] Page와 PageImpl (0) | 2024.02.21 |