JUnit은 자바 프로그래밍 언어용 유닛 테스트 프레임워크이다. JUnit은 TDD를 구현하는 면에서 중요하며 SUnit과 함께 시작된 XUnit이라는 이름의 유닛 테스트 프레임워크 계열의 하나이다.
JUnit은 단위 테스트를 작성하는 자바 개발자의 93%가 사용하는 테스트 프레임워크이다.
TestNG, Spock 등의 테스트 프레임워크도 있지만, JUnit를 주로 많이 사용하기 때문에 이에 대해 학습하고자 한다.
이전 버전인 JUnit4 는 하나의 모듈로 되어 다른 라이브러리를 참조하는 형식이었지만, JUnit5로 넘어오면서 JUnit5 자체에 세 개의 다른 하위 프로젝트의 여러 다른 모듈로 구성되어 있다.
JUnit Platform은 JVM에서 테스트 프레임워크를 실행하기 위한 기초를 제공하는 역할을 한다. 그리고 테스트 엔진 API를 통해 테스트 프레임워크를 개발할 수 있다. Junit Platform은 CLI를 통해 플랫폼을 시작할 수 있는 Console Launcher와, 하나 이상의 테스트 엔진을 사용하여 사용자 지정 Test Suite를 실행할 수 있는 JUnit Platform Suite Engine을 제공한다. JUnit Platform은 IDE(IntelliJ IDEA, Eclipse, NetBeans, Visual Studio Code)와 빌드 도구(Gradle, Maven, and Ant)에서 지원한다.
JUnit Jupiter는 JUnit 5에서 테스트를 작성하고 확장을 하기 위한 새로운 프로그래밍 모델과 확장 모델의 조합이다. Jupiter sub-project는 Jupiter를 기반으로 한 테스트를 실행하기 위한 테스트 엔진을 제공해준다.
JUnit Vintage는 하위 호환성을 위해 JUnit3과 JUnt4를 기반으로 작선된 테스트를 실행하기 위해 테스트 엔진을 제공해준다.
JUnit5을 사용하기 위해서는 런타임 시점에 java 8 버전 이상이 필요하며, 이전 버전의 JDK로 컴파일 된 코드를 테스트할 수 있긴 하다.
테스트를 수행하는 메서드를 지정한다.
테스트 클래스 안에 모든 테스트 메스트 메서드들이 실행되기 전에 단 한번 수행되는 메서드를 지정한다.
테스트 클래스 안에 모든 테스트 메스트 메서드들이 실행되고 난 후에 단 한번 수행되는 메서드를 지정한다.
테스트 메서드들이 실행되기 전에 항상 수행되는 메서드를 지정한다.
테스트 메서드들이 실행된 후에 항상 수행되는 메서드를 지정한다.
@Test 어노테이션이 붙은 테스트 메서드를 비활성 해야하는 경우 사용한다. 테스트가 진행되지 않는다.
클래스 레벨에 작성하는 어노테이션이다. 해당 클래스의 테스트 이름을 전략에 따라 변경할 수 있다.
@DisplayNameGeneration(DisplayNameGenerator.Standard.class)
class Test {
@Test
void create_name_test_arg(String arg) {
assertThat(1).isEqualTo(1);
}
@Test
void create_name_test_noarg() {
assertThat(1).isEqualTo(1);
}
}
위의 메서드를 예로 들어 어떻게 표현되는지 확인해보자.
JUnit5의 기본 전략이다. 테스트 메서드명 자체를 이름으로 지정한다.
파라미터가 없는 경우 괄호를 제거한다.
언더 스코어(_)를 제거한다.
테스트 클래스의 이름과 함께 표시한다.
클래스, 메서드 레벨에 붙여 사용자가 테스트 명을 지정할 수 있다.
클래스 레벨에 붙이면 해당 테스트 클래스의 테스트 이름이 변경되며, 메서드 레벨에 붙이면 해당 메서드의 테스트 이름이 변경된다.
@DisplayName("test class")
class StudyTest {
@Test
@DisplayName("test method")
void create_test() {
assertThat(1).isEqualTo(1);
}
}
기본적으로 JUnit5는 Jupiter API를 사용하지만, Jupiter가 제공하는 Assertion은 조금은 가독성이 떨어진다. 따라서 가독성이 더 좋은 AssertJ 라이브러리를 사용한다. 아래의 예시를 보자.
@DisplayName("test class")
class StudyTest {
@Test
@DisplayName("jupiter test")
void jupiter_test() {
int num = 1;
org.junit.jupiter.api.Assertions.assertEquals(num, 2, "1은 1과 같다.");
}
@Test
@DisplayName("assertj test")
void assertj_test() {
int num = 1;
org.assertj.core.api.Assertions.assertThat(num).as("%d은 1과 같다.", num).isEqualTo(2);
}
}
Jupiter의 경우에는 파라미터의 순서를 통해 검증하기 때문에 가독성이 떨어진다. 사실 검증하려는 값과, 기대하는 값의 순서가 바뀌어도 상관이 없긴 하지만 AssertJ의 경우에는 "어떤 값은 어떤 기대하는 값이 나와야 해"라는 것이 명확하게 보인다.
테스트에 실패한 경우, 메시지를 출력하도록 할 수 있는데 Jupiter는 3번째 파라미터로 그냥 받는다. AssertJ는 as() 를 통해서 보다 더 명시적으로 지정할 수 있다. 참고로 이 둘 모두 Supplier 함수형 인터페이스를 람다식으로 구현하여 메시지를 지정할 수 있다. (되도록 이 방법을 권장한다고 한다.)
AssertJ 문법에 대해서 모두 다루긴 어렵고, 공식 문서를 참조하도록 하자.
Assertion은 말 그대로 검증하는 것이고, Assumptions는 테스트를 실행할지 조건을 명시할 때 사용한다. 아래의 예시를 보자.
public class Assumption {
@Test
@DisplayName("assumption test")
void assumption_test() {
int num1 = 1;
int num2 = 2;
int expected = 2;
Assumptions.assumeThat(num1).isEqualTo(expected);
Assertions.assertThat(num2).isEqualTo(expected);
}
@Test
@DisplayName("assertion test")
void assertion_test() {
int num1 = 1;
int num2 = 2;
int expected = 2;
Assertions.assertThat(num1).isEqualTo(expected);
Assertions.assertThat(num2).isEqualTo(expected);
}
}
두 테스트 케이스 모두 일부러 첫 번째를 실패하도록 작성했다. 다만 차이점은 첫 번째 테스트 메서드는 Assumptions을 사용했다는 점이다. 결과를 확인해보자.
Assertions의 경우에는 테스트가 실패했다고 나오지만, Assumptions의 경우는 마치 @Disable 어노테이션을 붙인 것처럼 테스트를 진행하지 않은 것으로 나온다.
즉, Assumptions는 테스트를 수행하기 위한 조건을 명시하기 위해 사용한다. 조건에 부합하지 않는다면 이후의 테스트를 진행하지 않는 것이다. 예를 들어, Window 10 OS에서만 진행해야 하는 테스트가 있다고 가정해보자.
@Test
@DisplayName("assumption test ex")
void assumption_test_ex() {
String osName = System.getProperty("os.name");
Assumptions.assumeThat(osName).isEqualTo("window 10");
int num = 1;
int expected = 2;
Assertions.assertThat(num).isEqualTo(expected);
}
그렇다면 위와 같이 해당 테스트가 진행되어야 하는 조건을 Assumptions를 통해 명시를 해서, 다른 OS인 경우에는 진행하지 않도록 할 수 있다.
추가로 Assumptions를 사용하지 않고, 아래와 같이 JUnit5 에서 제공해주는 어노테이션을 기반으로 테스트 실행 조건을 명시할 수도 있다.
@Test
@EnabledOnOs(OS.MAC)
@EnabledOnJre(JRE.JAVA_11)
@EnabledIfEnvironmentVariable(named = "test.env", matches = "test")
@DisplayName("assumption test annotation")
void assumption_test_annotation() {
int num = 1;
int expected = 2;
Assertions.assertThat(num).isEqualTo(expected);
}
@Tag는 테스트 케이스에 특정한 태그를 부여하는 데 사용하는 어노테이션이다. 하나의 클래스 혹은 메서드에 여러 개를 지정할 수 있다.
아래의 예시를 보자.
public class Tagging {
@Test
@Tag("fast")
public void fast() {
System.out.println("fast test");
}
@Test
@Tag("slow")
public void slow() {
System.out.println("slow test");
}
}
위와 같이 @Tag 어노테이션을 이용하여 해당 테스트 케이스가 어떤 특징을 가지고 있는지 표현할 수 있다.
만약 테스트를 진행하는 데 오래 걸리는 테스트와, 빠르게 진행되는 테스트가 있다고 해보자. 이러한 테스트의 특징, 속성을 지정하기 위해 @Tag 어노테이션을 사용할 수 있다.
그리고 오래 걸리는 테스트는 로컬에서 진행하기 어려워서, 배포 당시에 진행을 해야한다고 가정했을 때 태그를 필터링 하여 수행할 테스트와 제외할 테스트를 지정할 수도 있다.
tasks.named('test') {
useJUnitPlatform {
includeTags 'fast'
excludeTags 'slow'
}
}
build.gradle 파일에 위와 같이 test 태스크에서 실행할 태그를 지정해주면 필터링이 가능하다.
실제로 위의 예시를 테스트 해보면, fast 태그가 달린 테스트만 실행된 것을 확인할 수 있다.
하지만 위의 방법은 gradle test 명령에만 의존해서 테스트를 구성할 수밖에 없어진다. 따라서 다른 task를 만들어서 필터링 할 수도 있다.
tasks.named('test') {
useJUnitPlatform {
includeTags 'fast'
}
}
task slowTest(type: Test) {
useJUnitPlatform {
includeTags 'slow'
}
}
위와 같이 slow 태그를 가진 테스트를 진행하기 위해 task를 하나 추가로 정의했다. 이러면 앞으로 gradlew slowTest 명령어를 통해 slow 태그가 달린 테스트를 진행할 수가 있게 된다.
JUnit이 제공하는 어노테이션은 메타 어노테이션으로 사용할 수 있다. 따라서 해당 어노테이션을 통해 커스텀 어노테이션을 만들어서 직접 태그를 정의할 수 있게 된다.
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Test
@Tag("fast")
public @interface FastTest {
}
우선 위와 같이 어노테이션을 정의해준다.
@FastTest
public void fastAnnotation() {
System.out.println("fast annotation");
}
그리고 정의한 어노테이션을 명시해주면 된다. 실제로 fast 테스트만 수행을 해보면 정상적으로 수행된다.
기존에는 @Tag에 태그 명을 직접 문자로 일일이 작성해야 해서 추후 태그 명이 변경될 경우 모든 어노테이션의 값을 변경해 주어야 한다. 또 컴파일 오류가 당연히 아니기 때문에 오탈자에 취약할 수밖에 없다.
하지만 사용자 정의 어노테이션으로 관리하면 어노테이션만 잘 정의했다면 오류가 날 일이 없고 관리도 쉽다.
테스트를 반복적으로 수행해야 할 경우 사용할 수 있는 어노테이션이다.
@DisplayName("repeated test")
@RepeatedTest(value = 10, name = "{displayName}, {currentRepetition}/{totalRepetitions}")
void repeatedTest(RepetitionInfo repetitionInfo) {
System.out.println("repetitionInfo.getCurrentRepetition() = "
+ repetitionInfo.getCurrentRepetition());
System.out.println("repetitionInfo.getTotalRepetitions() = "
+ repetitionInfo.getTotalRepetitions());
}
속성은 value, name을 가지고 있으며 value는 반복할 횟수, name은 반복되는 테스트의 이름을 명시하면 된다. name에는 place holder를 사용할 수 있는데, 다음과 같다.
위의 예제를 실제로 수행하면 아래와 같이 표시가 된다.
반복적인 테스트를 할 때마다 어떤 파라미터 값도 바뀌어야 한다면 @ParameterizedTest 어노테이션을 사용할 수 있다.
@DisplayName("parameterized Test")
@ParameterizedTest(name = "[{index}] {displayName}, message={0}")
@ValueSource(strings = {"A", "B", "C", "D", "E"})
void parameterizedTest(String message) {
Assertions.assertThat(message).isEqualTo("A");
}
간단하게 @ValueSource 어노테이션을 통해 String[] 을 파라미터로 넘기고, 해당 파라미터를 테스트 메서드의 매개변수로 받아 테스트를 수행하면 된다.
@ParameterizedTest 역시 @RepeatedTest처럼 place holder를 통해 동적으로 테스트 네이밍을 할 수 있다.
파라미터의 값에 따라 테스트를 진행하기 때문에 결과가 당연히 변경된다.
1. @ValueSource
리터럴 값의 배열을 지정하여 간단하게 하나의 타입만을 매개변수로 받는 경우 사용할 수 있다.
기본적으로 모든 원시 타입이 가능하고, String 타입도 지원이 된다.
@DisplayName("parameterized Test")
@ParameterizedTest(name = "[{index}] {displayName}, message={0}")
@ValueSource(ints = {1, 2, 3, 4, 5})
void valueSource(int message) {
Assertions.assertThat(message % 2).isEqualTo(1);
}
JUnit에서는 매개변수로 받는 타입을 암묵적으로 캐스팅도 해주고 있다. 어떤 타입으로 캐스팅이 가능한지는 공식 문서를 참조하자.
2. @NullSource, @EmptySource, @NullAndEmptySource
@DisplayName("parameterized Test")
@ParameterizedTest(name = "[{index}] {displayName}, message={0}")
@ValueSource(strings = {"A", "B", "C", "D", "E"})
@NullAndEmptySource
void nullAndEmptySource(String message) {
Assertions.assertThat(message).isEqualTo(null);
}
null 값과 빈 값이 추가된 것을 확인할 수 있다.
3. ArgumentConverter 구현체 상속
만약 파라미터로 받아야 하는 타입이 캐스팅이 되지 않는다면 ArgumentConverter의 구현체를 상속 받아 개발자가 직접 커스텀 할 수 있다.
@Data
public class TestDto {
private String name;
public TestDto(String name) {
this.name = name;
}
}
예를 들어 @ValueSource의 값을 TestDto의 name 필드에 넣어서 파라미터로 받아야 한다고 해보자.
@DisplayName("argument conversion1 Test")
@ParameterizedTest(name = "[{index}] {displayName}, message={0}")
@ValueSource(strings = {"A", "B", "C", "D", "E"})
void argumentConversion1(@ConvertWith(TestDtoConverter.class) TestDto testDto) {
System.out.println("testDto.getName() = " + testDto.getName());
}
public static class TestDtoConverter extends SimpleArgumentConverter {
@Override
protected Object convert(Object source, Class<?> targetType) throws ArgumentConversionException {
Assertions.assertThat(targetType).isEqualTo(TestDto.class).as("TestDto로만 캐스팅이 가능합니다.");
return new TestDto(source.toString());
}
그럴 경우 위와 같이 SimpleArgumentConverter를 상속받아 개발자가 직접 캐스팅을 정의해주어야 한다. 공식 문서에서는 캐스팅 하는 타입이 지원 가능한지 Assertions로 미리 검증하는 방법을 소개하고 있다. ArgumentConverter의 구현체로는 여러 개가 있으니 이는 공식 문서를 참조하여 적당한 구현체를 상속받아 처리하도록 하자.
4. @CsvSource
단일 타입만을 파라미터로 받는 것이 아니라, 여러 타입을 받아야 할 경우에 @CsvSource를 사용할 수 있다.
@DisplayName("csvSource1 Test")
@ParameterizedTest(name = "[{index}] {displayName}, message={0}. {1}")
@CsvSource({"1, A", "2, B", "3, C", "4, D", "5, E"})
void csvSource(Integer num, String str) {
System.out.println("num = " + num);
System.out.println("str = " + str);
}
위와 같이 CsvSource를 통해 숫자형과 문자형을 동시에 받을 수 있다. 이는 각각 매개변수로 선언해도 되고, ArgumentsAccessor를 통해서 한 번에 받을 수도 있다.
@DisplayName("csvSource2 Test")
@ParameterizedTest(name = "[{index}] {displayName}, message={0}. {1}")
@CsvSource({"1, A", "2, B", "3, C", "4, D", "5, E"})
void csvSource2(ArgumentsAccessor accessor) {
Integer integer = accessor.getInteger(0);
String string = accessor.getString(1);
System.out.println("integer = " + integer);
System.out.println("string = " + string);
}
ArgumentAccessor에서 값을 꺼낼 때 인덱스를 명시해주면 된다. 0번 인덱스에 명시한 숫자는 Integer 타입으로 받기 위해 getInteger(0)를 사용했고, 1번 인덱스에 명시한 문자는 String 타입으로 받기 위해 getString(1)을 사용했다.
@CsvSource 역시 @ValueSource 처럼 다른 참조 타입으로 캐스팅을 할 수도 있다. 이 경우에는 단일 타입이 아니기 때문에 ArgumentConverter의 구현체를 상속받는 것이 아니라, ArgumentsAggregator를 구현해야 한다.
@Data
public class TestDto {
private String name;
private Integer age;
public TestDto(String name, Integer age) {
this.name = name;
this.age = age;
}
}
위와 같이 TestDto에는 String타입 name필드와, Integer타입 age필드를 선언해두었다. 이를 CsvSource를 통해 파라미터를 받아 해당 객체로 바로 캐스팅해보자.
@DisplayName("csvSource3 Test")
@ParameterizedTest(name = "[{index}] {displayName}, message={0}. {1}")
@CsvSource({"1, A", "2, B", "3, C", "4, D", "5, E"})
void csvSource3(@AggregateWith(TestDtoAggregator.class) TestDto testDto) {
System.out.println("testDto.getName() = " + testDto.getName());
System.out.println("testDto.getAge() = " + testDto.getAge());
}
static class TestDtoAggregator implements ArgumentsAggregator {
@Override
public Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext context) throws ArgumentsAggregationException {
return new TestDto(accessor.getString(1), accessor.getInteger(0));
}
}
ArgumentsAggregator를 구현하여 aggregateArguments 메서드를 재정의하면 된다. 여기서는 파라미터로 ArgumentsAccessor를 제공하는데, 이를 통해 CsvSource에 접근할 수 있다.
실제 Aggregator를 사용하기 위해서는 테스트 메서드의 매개변수에 @AggregateWith 어노테이션을 통해 사용할 Aggregator 클래스 타입을 명시해주어야 한다.
기본적으로 JUnit은 테스트 메서드를 수행할 때마다 해당 테스트 클래스의 인스턴스를 만든다. 이는 테스트 간의 의존성을 없애기 위해서이다. 한번 실제 확인해보자.
class InstanceTest {
private int value = 0;
@Test
void method1() {
value++;
System.out.println("value = " + value); // value = 1
System.out.println("this = " + this);
}
@Test
void method2() {
value++;
System.out.println("value = " + value); // value = 1
System.out.println("this = " + this);
}
}
int형 value라는 멤버 변수를 선언했다. 그리고 테스트 메서드를 두 개 만들어서, 각각 value의 값을 1씩 증가시켰다. 만약 기본적인 자바 애플리케이션이라면 value의 값은 최종적으로 2가 출력되어야 할 것이다. 하지만 결과를 보면 그렇지 않다.
결과를 보면 value는 각각 1로 출력이 되고, 인스턴스 역시 다른 해시값을 가지고 있는 것을 확인할 수 있다.
기본적으로 JUnit5 부터는 명시한 순서대로 테스트가 진행이 된다고 하지만, 이는 보장성이 없다. 물론 동일한 테스트 케이스들을 여러번 돌린다고 해도 JUnit 내부적으로 작성된 알고리즘에 따라 순서가 정해지기 때문에 항상 같은 순서로 실행되긴 한다. 하지만 언제든 순서는 변경될 수 있다. 언젠가는 method2 부터 시작할 수도, method1 부터 시작할 수도 있다. 따라서 테스트 순서에 따라 다른 결과가 나타날 수 있기 때문에, 테스트 간의 종속성을 제거하기 위해 인스턴스를 항상 새로 생성하는 것이다.
만약 테스트 메서드마다 인스턴스를 만들지 않고, 테스트 클래스별로 하나의 인스턴스만을 생성하고 싶다면 @TestInstance(TestInstance.Lifecycle.PER_CLASS) 어노테이션을 클래스 레벨에 명시하면 된다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class InstanceTest {
private int value = 0;
@Test
void method1() {
value++;
System.out.println("value = " + value);
System.out.println("this = " + this);
}
@Test
void method2() {
value++;
System.out.println("value = " + value);
System.out.println("this = " + this);
}
@BeforeAll
public void beforeAll() {
System.out.println("before value = " + value);
}
@AfterAll
public void afterAll() {
System.out.println("after value = " + value);
}
}
이러면 테스트마다 인스턴스를 생성하지 않아 같은 변수를 사용하게 된다. 결과를 확인해보자.
해시값도 똑같고, value의 값은 증가하는 것을 볼 수 있다.
참고로 @beforeAll, @afterAll의 경우에는 클래스 레벨에서 인스턴스를 생성하도록 변경할 경우 static으로 정의되지 않아도 사용이 가능하다. 기본적으로 메서드 실행마다 인스턴스를 만들 때에는 전역으로 해당 메서드를 공유해야 했지만, 이제는 그럴 필요가 없기 때문이다.
기본적으로 잘 짜여진 단위 테스트는 테스트 간 의존성이 없어야 한다. 말 그대로 단위 테스트이기 때문이다. 따라서 JUnit은 기본적으로 테스트의 순서를 보장하지 않는 것이며, 인스턴스를 각각 생성했던 것이다.
반면에 시나리오 테스트와 같이 상태를 유지해야 하는 경우에는 순서가 보장이 되어야 한다. 회원이 로그인을 하고, 조회를 하고, 수정을 하고 등등의 유스케이스를 기반으로 한 시나리오 테스트는 로그인 테스트, 조회 테스트, 수정 테스트 등의 순서로 테스트가 진행되어야 하기 때문이다.
테스트의 순서를 지정해야 할 경우 @TestMethodOrder 어노테이션을 명시하고 MethodOrderer의 구현체 클래스 타입을 속성에 넣어주면 된다.
JUnit5에서 제공되는 MethodOrderer 구현체는 아래와 같다.
1. MethodName
2. DisplayName
3. Random
4. OrderAnnotation
이 중에서는 역시 OrderAnnotation 전략을 사용하는 것이 간편하게 순서를 지정할 수 있다. 따라서 이를 구현해보자.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class TestOrder {
private int value = 0;
@Test
@Order(3)
void method1() {
value++;
System.out.println("value = " + value);
System.out.println("this = " + this);
}
@Test
@Order(1)
void method2() {
value++;
System.out.println("value = " + value);
System.out.println("this = " + this);
}
@Test
@Order(2)
void method3() {
value++;
System.out.println("value = " + value);
System.out.println("this = " + this);
}
}
각각 3, 1, 2의 순서로 진행되도록 작성했다. 결과를 확인해보자.
정상적으로 순서가 반영된 것을 확인할 수 있다.
다만 시나리오 테스트를 하는 경우 주로 순서를 지정하여 사용할텐데, 순서를 지정했다고 해서 역시 인스턴스를 하나만 생성하는 것은 아니다. 따라서 @TestInstance의 전략을 PER_CLASS로 수정하여 하나의 인스턴스만을 생성하고 상태를 유지하는 테스트와 결합해서 사용하는 것이 일반적이다.
JUnit은 properties 파일을 정의해서 여러 기본 전략들을 지정할 수 있다. test 패키지의 resources 패키지 하위에 junit-platform.properties 파일을 생성하고 작성하면 된다.
# 테스트 인스턴스 라이프사이클 설정
junit.jupiter.testinstance.lifecycle.default = per_class
# 테스트 메서드 순서 기본 전략 설정
junit.jupiter.testmethod.order.default = org.junit.jupiter.api.MethodOrderer$OrderAnnotation
# 테스트 클래스 순서 기본 전략 설정
junit.jupiter.testclass.order.default = org.junit.jupiter.api.ClassOrderer$OrderAnnotation
# 확장팩 자동 감지 기능
junit.jupiter.extensions.autodetection.enabled = true
# @Disabled 무시하고 실행
junit.jupiter.conditions.deactivate = org.junit.*DisabledCondition
# 테스트 이름 표기 전략 설정
junit.jupiter.displayname.generator.default = org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores
JUnit5 에서는 Extension을 통해 테스트를 진행할 때 무언가 기능을 확장할 수 있다. 테스트 조건 설정, 테스트 파라미터 주입, 테스트 인터셉터, 테스트 콜백 등등 여러 개의 Extension을 제공해주고 있는데, 자세한 건 공식 문서를 확인하자.
예제로 시간이 오래 걸리는 테스트의 경우 @SlowTest 어노테이션을 붙이지 않았다면 예외를 발생시키도록 해보자.
public class FindSlowTestExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
private static final Long THRESHOLD = 1000L;
@Override
public void beforeTestExecution(ExtensionContext context) throws Exception {
ExtensionContext.Store store = getStore(context);
store.put("START_TIME", System.currentTimeMillis());
}
@Override
public void afterTestExecution(ExtensionContext context) throws Exception {
ExtensionContext.Store store = getStore(context);
SlowTest annotation = context.getRequiredTestMethod().getAnnotation(SlowTest.class);
Long startTime = store.remove("START_TIME", long.class);
Long duration = System.currentTimeMillis() - startTime;
if (duration > THRESHOLD && annotation == null) {
throw new IllegalStateException("[" + context.getRequiredTestClass().getName() + "] - "
+ context.getRequiredTestMethod().getName() + "메서드는 @SlowTest 어노테이션을 붙일 것을 권장합니다.");
}
}
private ExtensionContext.Store getStore(ExtensionContext context) {
String testClassName = context.getRequiredTestClass().getName();
String testMethodName = context.getRequiredTestMethod().getName();
ExtensionContext.Store store = context.getStore(ExtensionContext.Namespace.create(testClassName, testMethodName));
return store;
}
}
BeforeTestExecutionCallback, AfterTestExecutionCallback 인터페이스를 구현하여 Extension을 정의할 수 있다. 각각 beforeTestExecution, afterTestExecution 추상 메서드가 정의되어 있는데 이는 테스트 시작 전, 테스트 시작 후에 발생하는 콜백 메서드이다.
ExtensionContext에서는 Store 라는 중첩 인터페이스가 정의되어 있고, 이는 Context의 생명 주기에 바인딩되어 있다고 한다. Store의 구현체를 통해 NameSpace를 기준으로 데이터를 저장할 수 있고, 이 곳에 테스트 시작 시간을 저장하여 테스트가 끝날 때 얼마나 소요됐는지 판단하고 이를 통해 오래 걸리는 테스트임에도 @SlowTest 어노테이션이 없는 경우 예외를 발생시키도록 했다.
@ExtendWith(FindSlowTestExtension.class)
public class ExtensionTest {
@Test
void slow_test1() throws Exception {
Thread.sleep(1000L);
}
@Test
@SlowTest
void slow_test2() throws Exception {
Thread.sleep(1000L);
}
}
Extension은 @ExtendWith 어노테이션의 속성에 Extension 구현체의 클래스 타입을 명시해주면 된다. 결과를 살펴보자.
1초가 넘게 걸리는 테스트이지만 @SlowTest 어노테이션이 명시되지 않은 slow_test1 번은 예외가 발생된 것을 확인할 수 있다.
만약 Extension 구현체에 무언가 상태가 필요하는 등의 외부에서 받아와야 하는 값이 있는 경우에는 @RegisterExtension 어노테이션을 고려해볼 수 있다.
위의 예시에서 THRESHOLD 값을 외부에서 받아오는 방식으로 변경해야 한다고 해보자.
public class FindSlowTestExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
private final Long THRESHOLD;
public FindSlowTestExtension(Long THRESHOLD) {
this.THRESHOLD = THRESHOLD;
}
...
}
이럴 경우에는 @ExtendWith 어노테이션을 통해서는 객체를 생성할 수 없기 때문에 @RegisterExtension을 통해 사용자가 직접 Extension 구현체를 생성해주어야 한다.
public class ExtensionTest {
@RegisterExtension
static FindSlowTestExtension findSlowTestExtension
= new FindSlowTestExtension(1000L);
@Test
void slow_test1() throws Exception {
Thread.sleep(1000L);
}
@Test
@SlowTest
void slow_test2() throws Exception {
Thread.sleep(1000L);
}
}
위와 같이 사용자가 직접 객체를 생성하고, Extension을 등록해주면 된다.