JUnit5

YoungJun Kim·2022년 10월 17일
0

Application Test

목록 보기
1/6

JUnit 이란

JUnit은 자바 프로그래밍 언어용 유닛 테스트 프레임워크이다. JUnit은 TDD를 구현하는 면에서 중요하며 SUnit과 함께 시작된 XUnit이라는 이름의 유닛 테스트 프레임워크 계열의 하나이다.

JUnit은 단위 테스트를 작성하는 자바 개발자의 93%가 사용하는 테스트 프레임워크이다.
TestNG, Spock 등의 테스트 프레임워크도 있지만, JUnit를 주로 많이 사용하기 때문에 이에 대해 학습하고자 한다.


JUnit5의 구조

이전 버전인 JUnit4 는 하나의 모듈로 되어 다른 라이브러리를 참조하는 형식이었지만, JUnit5로 넘어오면서 JUnit5 자체에 세 개의 다른 하위 프로젝트의 여러 다른 모듈로 구성되어 있다.

JUnit Platform

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 Jupiter는 JUnit 5에서 테스트를 작성하고 확장을 하기 위한 새로운 프로그래밍 모델과 확장 모델의 조합이다. Jupiter sub-project는 Jupiter를 기반으로 한 테스트를 실행하기 위한 테스트 엔진을 제공해준다.

JUnit Vintage

JUnit Vintage는 하위 호환성을 위해 JUnit3과 JUnt4를 기반으로 작선된 테스트를 실행하기 위해 테스트 엔진을 제공해준다.


JUnit5 요구 사항

JUnit5을 사용하기 위해서는 런타임 시점에 java 8 버전 이상이 필요하며, 이전 버전의 JDK로 컴파일 된 코드를 테스트할 수 있긴 하다.


기본 어노테이션

@Test

테스트를 수행하는 메서드를 지정한다.

특징

  1. private 으로 지정할 수 없다.
  2. static 으로 지정할 수 없다.
  3. return 타입은 void로 고정해야 한다.
  4. 파라미터를 정의할 경우 ParameterResolvers를 통해 값을 가져올 수 있다.

실행 순서

  1. 실행 순서는 보장되지 않는다. (명확하지 않은 알고리즘을 사용하여 실행 순서를 정렬하기에, 항상 순서가 변경될 수 있다.)
  2. 일반적으로 단위 테스트는 수행 순서에 지장이 없어야 하지만, 순서를 지정해야 할 경우 테스트 클래스 또는 테스트 인터페이스에 @TestMethodOrder 어노테이션을 붙이고 MethodOrder 구현체를 지정하는 방법을 사용할 수 있다.

@BeforeAll

테스트 클래스 안에 모든 테스트 메스트 메서드들이 실행되기 전에 단 한번 수행되는 메서드를 지정한다.

특징

  1. private 으로 지정할 수 없다.
  2. return 타입은 void로 고정해야 한다.
  3. 기본은 static 메서드로 작성해야 한다.
  4. @TestInstance(Lifecycle.PER_CLASS) 주석이 없는 한 @Nested 테스트 클래스 또는 인터페이스의 default 메서드에서 지원되지 않는다.
  5. 파라미터를 정의할 경우 ParameterResolvers를 통해 값을 가져올 수 있다.

상속

  1. 테스트 클래스를 상속한 경우 @BeforeAll 메서드가 슈퍼클래스에서 숨겨지거나, 서브클래스에서 재정의 된 경우를 제외한다면 슈퍼클래스의 @BeforeAll 메서드는 상속된다.
  2. 슈퍼클래스의 @BeforeAll 메서드는 서브클래스의 @BeforeAll 메서드보다 먼저 실행된다.
  3. 인터페이스의 @BeforeAll 메서드는 해당 인터페이스의 구현체에 선언된 @BeforeAll 메서드보다 먼저 실행된다.

실행 순서

  1. 여러 @BeforeAll 메서드를 정의한 경우 실행 순서는 항상 보장되지 않는다.
  2. @AfterAll 메서드에 연결되지 않기 때문에 이 둘을 연관해서 사용한 경우 의도와 맞지 않을 수 있다.
    • CreateA와 CreateB 라는 메서드를 @BeforeAll로 정의하고, DestroyA와 DestoryB를 @AfterAll로 정의한 경우, 개발자가 CreateA 먼저 수행 -> 종료 시 DestroyA 먼저 수행을 의도하더라도 순서가 보장되지 않아 의도와는 부합하지 않을 수 있다.
  3. 해당 이유로 테스트 인터페이스당 하나의 @BeforeAll 메서드를 선언하는 것을 권장한다.

@AfterAll

테스트 클래스 안에 모든 테스트 메스트 메서드들이 실행되고 난 후에 단 한번 수행되는 메서드를 지정한다.

특징

  1. private 으로 지정할 수 없다.
  2. return 타입은 void로 고정해야 한다.
  3. 기본은 static 메서드로 작성해야 한다.
  4. @TestInstance(Lifecycle.PER_CLASS) 주석이 없는 한 @Nested 테스트 클래스 또는 인터페이스의 default 메서드에서 지원되지 않는다.
  5. 파라미터를 정의할 경우 ParameterResolvers를 통해 값을 가져올 수 있다.

상속

  1. 테스트 클래스를 상속한 경우 @AfterAll 메서드가 슈퍼클래스에서 숨겨지거나, 서브클래스에서 재정의 된 경우를 제외한다면 슈퍼클래스의 @AfterAll 메서드는 상속된다.
  2. 슈퍼클래스의 @AfterAll 메서드는 서브클래스의 @AfterAll 메서드가 실행된 후에 실행된다.
  3. 인터페이스의 @AfterAll 메서드는 해당 인터페이스의 구현체에 선언된 @AfterAll 메서드가 실행된 후에 실행된다.

실행 순서

  1. 여러 @AfterAll 메서드를 정의한 경우 실행 순서는 항상 보장되지 않는다.
  2. @BeforeAll 메서드에 연결되지 않기 때문에 이 둘을 연관해서 사용한 경우 의도와 맞지 않을 수 있다. (@BeforeAll 설명 참고)
  3. 해당 이유로 테스트 인터페이스당 하나의 @AfterAll 메서드를 선언하는 것을 권장한다.

@BeforeEach

테스트 메서드들이 실행되기 전에 항상 수행되는 메서드를 지정한다.

특징

  1. private 으로 지정할 수 없다.
  2. return 타입은 void로 고정해야 한다.
  3. static 메서드로 정의될 수 없다.
  4. @TestInstance(Lifecycle.PER_CLASS) 주석이 없는 한 @Nested 테스트 클래스 또는 인터페이스의 default 메서드에서 지원되지 않는다.
  5. 파라미터를 정의할 경우 ParameterResolvers를 통해 값을 가져올 수 있다.

상속

  1. 테스트 클래스를 상속한 경우 @BeforeEach 메서드가 슈퍼클래스에서 숨겨지거나, 서브클래스에서 재정의 된 경우를 제외한다면 슈퍼클래스의 @BeforeEach 메서드는 상속된다.
  2. 슈퍼클래스의 @BeforeEach 메서드는 서브클래스의 @BeforeEach 메서드보다 먼저 실행된다.
  3. 인터페이스의 @BeforeEach 메서드는 해당 인터페이스의 구현체에 선언된 @BeforeEach 메서드보다 먼저 실행된다.

실행 순서

  1. 여러 @BeforeEach 메서드를 정의한 경우 실행 순서는 항상 보장되지 않는다.
  2. @AfterEach 메서드에 연결되지 않기 때문에 이 둘을 연관해서 사용한 경우 의도와 맞지 않을 수 있다. (@BeforeAll - @AfterAll과 동일한 특징이다.)
  3. 해당 이유로 테스트 인터페이스당 하나의 @BeforeEach 메서드를 선언하는 것을 권장한다.

@AfterEach

테스트 메서드들이 실행된 후에 항상 수행되는 메서드를 지정한다.

특징

  1. private 으로 지정할 수 없다.
  2. return 타입은 void로 고정해야 한다.
  3. static 메서드로 정의될 수 없다.
  4. @TestInstance(Lifecycle.PER_CLASS) 주석이 없는 한 @Nested 테스트 클래스 또는 인터페이스의 default 메서드에서 지원되지 않는다.
  5. 파라미터를 정의할 경우 ParameterResolvers를 통해 값을 가져올 수 있다.

상속

  1. 테스트 클래스를 상속한 경우 @AfterEach 메서드가 슈퍼클래스에서 숨겨지거나, 서브클래스에서 재정의 된 경우를 제외한다면 슈퍼클래스의 @AfterEach 메서드는 상속된다.
  2. 슈퍼클래스의 @AfterEach 메서드는 서브클래스의 @AfterEach 메서드가 실행된 후에 실행된다.
  3. 인터페이스의 @AfterEach 메서드는 해당 인터페이스의 구현체에 선언된 @AfterEach 메서드가 실행된 후에 실행된다.

실행 순서

  1. 여러 @AfterEach 메서드를 정의한 경우 실행 순서는 항상 보장되지 않는다.
  2. @BeforeAll 메서드에 연결되지 않기 때문에 이 둘을 연관해서 사용한 경우 의도와 맞지 않을 수 있다.
  3. 해당 이유로 테스트 인터페이스당 하나의 @AfterEach 메서드를 선언하는 것을 권장한다.

@Disabled

@Test 어노테이션이 붙은 테스트 메서드를 비활성 해야하는 경우 사용한다. 테스트가 진행되지 않는다.

특징

  1. value 속성에 테스트를 수행하지 않아야 하는 이유를 명시할 수 있다.
  2. 클래스 레벨에 해당 어노테이션을 붙일 경우 해당 클래스의 모든 테스트 메서드는 수행하지 않는다.
  3. 메서드 레벨에 붙은 경우 테스트 클래스 자체는 인스턴스화된다, 다만 @Disabled 어노테이션이 붙은 메서드는 테스트를 진행하지 않기에 @BeforeEach, @AfterEach 어노테이션을 붙인 메서드 역시 실행되지 않는다.

테스트 이름 표시

@DisplayNameGeneration

클래스 레벨에 작성하는 어노테이션이다. 해당 클래스의 테스트 이름을 전략에 따라 변경할 수 있다.

@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);
    }

}

위의 메서드를 예로 들어 어떻게 표현되는지 확인해보자.

Standard

JUnit5의 기본 전략이다. 테스트 메서드명 자체를 이름으로 지정한다.

  • create_name_test_arg(String)
  • create_name_test_noarg()

Simple

파라미터가 없는 경우 괄호를 제거한다.

  • create_name_test_arg(String)
  • create_name_test_noarg

ReplaceUnderscores

언더 스코어(_)를 제거한다.

  • create name test arg(String)
  • create name test noarg()

IndicativeSentences

테스트 클래스의 이름과 함께 표시한다.

  • Test, create_name_test_arg(String)
  • Test, create_name_test_noarg()

DisplayName

클래스, 메서드 레벨에 붙여 사용자가 테스트 명을 지정할 수 있다.
클래스 레벨에 붙이면 해당 테스트 클래스의 테스트 이름이 변경되며, 메서드 레벨에 붙이면 해당 메서드의 테스트 이름이 변경된다.

@DisplayName("test class")
class StudyTest {

    @Test
    @DisplayName("test method")
    void create_test() {
        assertThat(1).isEqualTo(1);
    }

}

Assertions

기본적으로 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 문법에 대해서 모두 다루긴 어렵고, 공식 문서를 참조하도록 하자.


Assumptions

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에 태그 명을 직접 문자로 일일이 작성해야 해서 추후 태그 명이 변경될 경우 모든 어노테이션의 값을 변경해 주어야 한다. 또 컴파일 오류가 당연히 아니기 때문에 오탈자에 취약할 수밖에 없다.

하지만 사용자 정의 어노테이션으로 관리하면 어노테이션만 잘 정의했다면 오류가 날 일이 없고 관리도 쉽다.


테스트 반복

@RepeatedTest

테스트를 반복적으로 수행해야 할 경우 사용할 수 있는 어노테이션이다.

    @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를 사용할 수 있는데, 다음과 같다.

  • {displayName}: 테스트 명을 출력한다.
  • {currentRepetition}: 현재 몇 번 반복되었는지 출력한다.
  • {totalRepetitions}: 총 반복할 횟수가 몇 번인지 출력한다.

위의 예제를 실제로 수행하면 아래와 같이 표시가 된다.

@Parameterized Test

반복적인 테스트를 할 때마다 어떤 파라미터 값도 바뀌어야 한다면 @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

  • @NullSource: null 값을 하나 추가한다.
  • @EmptySource: 빈 값을 하나 추가한다.
  • @NullAndEmptySource: null 값과 빈 값을 각각 하나씩 추가한다.
    @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) 어노테이션을 클래스 레벨에 명시하면 된다.

@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.class)

테스트의 순서를 지정해야 할 경우 @TestMethodOrder 어노테이션을 명시하고 MethodOrderer의 구현체 클래스 타입을 속성에 넣어주면 된다.

JUnit5에서 제공되는 MethodOrderer 구현체는 아래와 같다.

1. MethodName

  • 메서드 명을 String의 compareTo 메서드를 통해 비교하여 순서를 지정한다.
  • 만약 두 메서드의 이름이 같을 경우(오버로딩 된 경우) 매개변수 목록의 문자열들을 비교하여 순서를 지정한다.

2. DisplayName

  • DisplayName을 기반으로 순서를 지정한다.

3. Random

  • 해당 클래스의 정적 초기화 동안에 System.nanoTime()에 의해 반환되는 값을 시드로하여 랜덤한 값을 부여해 순서를 지정한다.
  • 시드는 사용자 지정으로 정의할 수 있다.

4. OrderAnnotation

  • JUnit의 @Order 어노테이션을 기준으로 순서를 지정한다.
  • @Order의 값이 동일하다면, 임의로 정렬된다.
  • @Order 어노테이션이 붙어 있지 않은 경우에는 @Order 어노테이션이 명시된 테스트 뒤에 임의로 실행된다.

이 중에서는 역시 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 Property 설정

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

JUnit5 에서는 Extension을 통해 테스트를 진행할 때 무언가 기능을 확장할 수 있다. 테스트 조건 설정, 테스트 파라미터 주입, 테스트 인터셉터, 테스트 콜백 등등 여러 개의 Extension을 제공해주고 있는데, 자세한 건 공식 문서를 확인하자.

예제로 시간이 오래 걸리는 테스트의 경우 @SlowTest 어노테이션을 붙이지 않았다면 예외를 발생시키도록 해보자.

Extension 구현

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 어노테이션이 없는 경우 예외를 발생시키도록 했다.

Extension 등록 - @ExtendWith

@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

만약 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을 등록해주면 된다.

profile
반갑습니다. 주니어 백엔드 개발자 김영준입니다.

0개의 댓글