완벽정리! Junit5로 예외 테스트하는 방법
환경 구성
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeClasspath - Runtime classpath of source set 'test'.
+--- org.springframework.boot:spring-boot-starter-web -> 2.5.6
\--- org.springframework.boot:spring-boot-starter-test -> 2.5.6
+--- org.junit.jupiter:junit-jupiter:5.7.2
spring-boot-starter-web를 선택했다면 자동으로 spring-boot-starter-test 의존성이 추가되어있습니다. spring-boot-starter-test에 junit5가 기본적으로 있으니 바로 Junit5를 사용할 수 있습니다.
testImplementation 'org.junit.jupiter:junit-jupiter-api'
Junit5만 추가하고 싶다면 위의 의존성을 추가하면 됩니다.
public class DoSomething {
public static void func() {
throw new RuntimeException("some exception message...");
}
}
RuntimeException이 발생하는 func 메소드를 만들었습니다. 그러면 func 메서드가 예외를 잘 발생하는지 테스트 코드를 4가지 방법으로 만들어보겠습니다.
방법 1. assertThrows
import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
public void junit5에서_exception_테스트_1() {
Assertions.assertThrows(RuntimeException.class, () -> {
DoSomething.func();
});
}
Assertions.assertThrows의 두 번째 인자인 DoSomething.func()를 실행하여 첫 번째 인자인 예외 타입과 같은지(혹은 캐스팅이 가능한 상속 관계의 예외인지) 검사합니다.
방법 2. assertj의 assertThatThrownBy
testImplementation 'org.assertj:assertj-core:3.21.0'
assertj를 사용하기 위해서 그래들에 위와같이 추가하면 됩니다. (최신버전을 보려면? assertj mvn repository)
Junit5 예외처리를 이야기하면서 테스트 코드 가족성을 assertj를 소개하는것이 주제에 벗어나는것으로 보일 수 있습니다. 그러나 assertj는 테스트 코드 가독성을 높여주기위해 Junit5와 사용합니다. (참고. AssertJ 공식 문서)
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Test
public void junit5에서_exception_테스트_2() {
assertThatThrownBy(() -> DoSomething.func())
.isInstanceOf(RuntimeException.class);
}
assertThatThrownBy에 예외 테스트를 원하는 코드를 작성, isInstanceOf에 발생하는 예외 타입(혹은 부모 타입의 예외)를 작성하여 테스트합니다.
방법 3. 예외 메시지 테스트 - assertEquals (try ~ catch)
import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
public void junit5에서_exception_테스트_3() {
try {
DoSomething.func();
} catch (RuntimeException e) {
Assertions.assertEquals("some exception message...", e.getMessage());
}
}
try ~ catch 문을 사용하여 예외 메시지를 테스트할 수 있습니다. 예외 메시지는 변하기 쉬운 값이기에 테스트하면 깨지기 쉬운 테스트코드가 발생하므로 테스트를 안하는 것이 좋습니다. 다만 코드형 응답 메시지의 경우 테스트할 수 있습니다.
그러나 위의 코드는 전통적인 try ~ catch 문의 관념상 테스트코드가 무엇을 테스트하는지 명확하게 보이지 않을 수 있습니다. 그렇기에 방법 4를 제안합니다.
방법 4. 예외 메시지 테스트 - assertThrows 반환값 사용
@Test
public void junit5에서_exception_테스트_4() {
Throwable exception = assertThrows(RuntimeException.class, () -> {
DoSomething.func();
});
assertEquals("some exception message...", exception.getMessage());
}
방법 1에서 봤던 assertThrows는 발생하는 예외를 모든 예외 클래스의 선조 클래스인 Throwable 타입으로 반환합니다. Throwable으로 반환된 메서드에서 발생한 예외 객체의 메시지를 통하여 예외 메시지 테스트할 수 있습니다.
좀 더 깊은 이야기
assertThrows 예외 타입 체크 비밀
위에서 assertThrows는 인자로 넘어온 예외 타입 혹은 부모 타입의 예외를 상속한 예외인지 검사한다고 하였는데 어떤 의미인지 살펴보겠습니다.
public class SomeException extends RuntimeException {
public SomeException(String message) {
super(message);
}
}
RuntimeException을 상속받은 SomeException을 만들었습니다.
@Test
public void junit5에서_exception_테스트_1_2() {
Assertions.assertThrows(RuntimeException.class, () -> {
DoSomething.func2();
});
}
그리고 assertThrows를 사용하여 RuntimeException 타입인지 테스트를 합니다. 그러면 이 테스트는 통과할까요? 위에서 설명한 대로 부모 예외를 상속한 경우이기에 정답은 테스트를 통과합니다. 테스트를 통과하는 이유는 Assertions.assertThrows 내부 구현에 비밀이 있습니다.
private static <T extends Throwable> T assertThrows(
Class<T> expectedType, Executable executable, Object messageOrSupplier) {
try {
executable.execute();
}
catch (Throwable actualException) {
if (expectedType.isInstance(actualException)) {
return (T) actualException;
}
// 후략
executable인자로 넘어온 DoSomething.func2()를 실행합니다. SomeException이 발생하면 isInstance를 검사하게 되며 이 조건문은 참이되기에 return (T) actualException; 이 실행됩니다. 발생한 예외와 기대하는 예외 타입 검사를 isInstance로 검사하기에 기대하는 예외 타입을 상속받은 예외라면 테스트를 통과할 수 있습니다.
예외 테스트를 꼭 해야하는가?
public class TestCsv extends TestDb {
private void testOptions() {
// 생략
assertThrows(ErrorCode.FEATURE_NOT_SUPPORTED_1, csv)
.setOptions("escape=a error=b");
}
}
(h2는 assertThrows는 Junit5가 아닌 자체 AssertThrows를 사용합니다.)
위 코드는 스프링으로 웹 서비스를 만들면 필수인 h2의 CSV 테스트 코드입니다. 사실 실패 테스트를 하지 않고 유닛테스트를 구성할 경우 통합 테스트에서(속칭 서버 올리고 UI 클릭하며 진행하는 테스트) 시간을 많이 소요됩니다.
만일 회원가입이라고 한다면
- 메일을 보낼 수 없는 인증 메일을 입력한 경우
- 이메일 인증하지 않고 재가입 시도하는 경우
- 유효기간이 만료된 인증 토큰을 사용하는 경우
- 내부에서의 잘못된 암호화 키값으로 사용자의 비밀번호를 암호화하는 경우
- 등등..
이 모든 테스트를 통합 테스트에서 테스트하려면 정말 힘들것입니다. 예외에 대한 단위 테스트는 애플리케이션에 대해 빠르게 견고하게 만들어 줄 것입니다.
마치며
본 글에서 사용한 플레이그라운드 코드는 Github. Tistory-Covenant-Code에서 확인할 수 있습니다.