나의 개발일지

[트러블 슈팅] AssertJ 를 통해 CustomException 내에 추가로 정의한 필드 테스트 본문

Language/SpringBoot

[트러블 슈팅] 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);
    }