'JUnit5'에 해당되는 글 2건

  1. 2019.05.19 JUnit5의 기능 소개 - 두번째
  2. 2019.05.11 JUnit5의 기능 소개
Java/JUnit2019. 5. 19. 16:43
반응형

이번에는 이전글에서 언급하지 않은 내용과 잘사용되지는 않지만 알아두면 좋을법한 내용에 대해서 알아보도록 하겠습니다.

JUnit5 모듈 구성

JUnit5는 이전버전과 달리 크게 3개의 서브 프로젝트로 구성된 복수의 모듈로 구성되어 있습니다. 또한 각각의 프로젝트는 용도에 따라서 몇개의 서브 모듈로 구성되어 있습니다.

1. JUnit Platform

JUnit 테스트를 실행하기 위한 기반이 되는 모듈입니다. Gradle, Maven과 같은 빌드툴은 물론 IDE를 위한 모듈도 각각 존재합니다. Platform이 제공하는 TestEngine을 구현한 모듈을 찾아서 실행해준다고 보면 될 것 같습니다.

2. JUnit Jupiter

테스트를 기술하기 위한 프로그램, 확장 모델들이 담겨있는 모듈입니다. 엄밀한 의미에서는 맞지않겠지만 흔히 JUnit5라고 언급할 경우에 이 모듈을 지칭한다고 생각하시면 될 듯 합니다.

3. JUnit Vintage

JUnit의 4, 3 버전을 실행하기 위한 TestEngine을 제공하는 모듈입니다. 이 모듈을 통해서 JUnit5와 병행 사용이 가능합니다.

어노테이션(Annotation)

이전글과 마찬가지로 JUnit5에서 새롭게 추가되었거나 이전과 달라진 어노테이션에 대해서 비교를 통해서 알아보도록 하겠습니다.

@TestMethodOrder - 테스트 실행 순서 지정

5.4버전부터 테스트의 순서를 지정할 수 있는 @TestMethodOrder 어노테이션이 제공됩니다. 일반적으로 테스트는 순서에 의존하지 않도록 작성하는 것이 유지보수 측면에서 바람직합니다. 하지만 시퀀셜한 업무흐름을 순서대로 테스트하는 것이 테스트 코드 작성에 편할 경우에 사용하는 경우가 있습니다.

이전 버전에서 제공되던 @FixMethodOrder 어노테이션은 메소드 이름에 의한 순서만 지정할 수 있었으므로 테스트 메소드 앞에 알파벳을 넣어서 순서를 지정하거나 하는 불편한 방법을 취했습니다. 새롭게 추가된 @TestMethodOrder 어노테이션은 알파벳 순서 이외에도 어노테이션으로 직접 순서를 지정하거나 랜덤한 순서로 실행하는 기능을 제공합니다.

@TestMethodOrder(OrderAnnotation.class)
class TestMethodOrderExample {

    @Order(3)
    @Test
    void test1() {
        System.out.println("test");
    }

    @Order(1)
    @Test
    void test3() {
        System.out.println("test3");
    }

    @Nested
    @TestMethodOrder(OrderAnnotation.class)
    class NestedTest {

        @Order(4)
        @Test
        void test2() {
            System.out.println("test2");
        }

        @Order(2)
        @Test
        void test4() {
            System.out.println("test4");
        }
    }
}

위의 코드는 어노테이션에 테스트의 순서를 지정하는 방식의 예제입니다. 한가지 아쉬운 점은 @TestMethodOrder가 클래스 단위로 설정 가능하기 때문에 중첩된 테스트는 별도로 순서를 가진다는 점입니다.

Built-in으로 제공되는 기능이외에도 MethodOrderer를 구현하여 @TestMethodOrder에 지정하면 특정한 순서를 지정하여 테스트를 수행할 수 있습니다. Built-in 기능들도 동일하게 MethodOrderer를 구현하여 제공되고 있습니다.

@TestFactory - 동적 테스트 클래스 생성

일반적으로 JUnit의 단위테스트는 @Test 어노테이션을 작성한 테스트 메소드에 기술하는 것으로 적용됩니다. 이를 사용하지 않고 동적으로 테스트를 작성할 수 있게 해주는 것이 바로 @TestFactory입니다. 일반적으로 사용할 일이 거의 없을 것으로 생각되지만 이전에 소개해드린 @ParameterizedTest와 비슷하지만 보다 유연하게 테스트를 작성할 수 있어 보입니다.

class TestFactoryExample {

    @TestFactory
    Stream<DynamicTest> test() {
        class TestTemplate {
            String name;
            int age;
            String nationality;

            TestTemplate(String name, int age, String nationality) {
                this.name = name;
                this.age = age;
                this.nationality = nationality;
            }
        }

        return Stream.of(
                new TestTemplate("홍길동", 19, "Korean"),
                new TestTemplate("John", 17, "U.S.A")
        ).map(t -> dynamicTest("test " + t.name, () -> {
            assertTrue(t.age > 18, t.name + "'s age");
            assertEquals("Korean", t.nationality, t.name + "'s nationality");
        }));
    }
}

위의 코드와 같이 테스트 코드는 org.junit.jupiter.api.DynamicNode를 상속받은 클래스를 콜렉션, 배열 혹은 스트림으로 반환하면 해당 테스트가 실행됩니다. 다만 반환받은 테스트가 하나의 테스트와 유사하게 실행되어 생명주기(LifeCycle) 메소드도 테스트 메소드 하나 실행한 것과 동일하게 실행됩니다.

5.4.2현재 DynamicNode를 상속받은 DynamicTest, DynamicContainer를 제공하고 있습니다. 기본적으로는 위의 예제와 같이 DynamicTest를 이용하여 테스트를 생성하면 되며 계층적으로 테스트를 생성하고 싶은 경우에는 DynamicContainer를 이용하실 수 있습니다.

@ExtendWith - 확장기능 구현하기

단위 테스트간에 공통적으로 사용할 기능을 구현하여 @ExtendWith를 통하여 적용할 수 있는 기능을 제공합니다. 확장기능은 org.junit.jupiter.api.extension.Extension 인터페이스를 상속한 인터페이스로 되어있으며 JUnit5에서 제공하는 기능의 상당수가 이 기능을 통해서 지원되고 있습니다.

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ExtendWith(DisabledOnOsCondition.class)
@API(status = STABLE, since = "5.1")
public @interface DisabledOnOs {
//...
}

위의 코드는 JUnit에서 제공하는 OS별로 실행을 제한하는 기능의 어노테이션입니다. 보시는 바와 같이 해당 어노테이션은 @ExtendWith(DisabledOnOsCondition.class)가 선언된 합성 어노테이션으로 실제 기능이 @ExtendWith를 통해서 실행된다는 사실을 알 수 있습니다.

그러면 JUnit5에서 제공하는 확장기능의 인터페이스에 대해서 알아보도록 하겠습니다.

  • ExecutionCondition - 테스트 혹은 단위로 해당 테스트를 실행할 지 여부를 결정하는 인터페이스입니다. evaluate가 반환하는 ConditionEvaluationResult에 의해서 테스트의 실행 여부를 결정하게 됩니다.
  • BeforeAllCallback - @BeforeAll에 앞서서 실행됩니다.
  • BeforeEachCallback - @BeforeEach 직전에 실행됩니다.
  • BeforeTestExecutionCallback - 테스트가 실행되기 직전에 실행딥니다.
  • AfterTestExecutionCallback - 테스트가 실행된 직후에 실행됩니다.
  • AfterEachCallback - @AfterEach 직후에 실행됩니다.
  • AfterAllCallback - @AfterAll 직후에 실행됩니다.
  • TestInstancePostProcessor - 테스트 클래스의 인스턴스를 생성한 직후에 실행됩니다.(@BeforeAll 직후)
  • TestExecutionExceptionHandler - 테스트 메소드에서 던져진 예외을 핸들링 합니다.
  • ParameterResolver - 테스트에 파라메터를 바인딩해야하는 경우 사용

위의 목록이 인터페이스들을 활용하여 확장기능을 구현할 수 있습니다.

public class ExecutionTimeExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    @Override
    public void beforeTestExecution(final ExtensionContext context) throws Exception {
        getStore(context).put("_TIME_", System.nanoTime());
    }

    @Override
    public void afterTestExecution(final ExtensionContext context) throws Exception {
        final long duration = System.nanoTime() - getStore(context).get("_TIME_", long.class);
        System.out.println(context.getRequiredTestMethod().getName() + " - " + TimeUnit.MILLISECONDS.convert(duration, TimeUnit.NANOSECONDS));
    }

    private ExtensionContext.Store getStore(final ExtensionContext context) {
        return context.getStore(ExtensionContext.Namespace.create(getClass(), context.getRequiredTestMethod()));
    }
}

위의 예제코드는 테스트의 실행후에 실행시간을 출력하는 예제입니다. ExtensionContext 내부에 저장할 공간을 만들어서 실행직전에 시간을 저장하고 테스트 실행후에 해당 시간을 참조하여 실행 시간을 출력합니다. 해당 기능을 적용하고 싶은 테스트클래스 혹은 테스트 메소드 위에 @ExtendWith(ExecutionTimeExtension.class)와 같이 선언하면 적용할 수 있습니다. 또한 여러개를 동시에 지정할 수 있습니다.

JUnit4에서는 @Rule@ClassRule을 이용하여 해당기능을 지원했습니다.

public class ExecutionTimeRule extends Stopwatch {

    private final Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    protected void finished(final long nanos, final Description description) {
        System.out.println(description.getMethodName() + " - " + TimeUnit.MILLISECONDS.convert(nanos, TimeUnit.NANOSECONDS));
    }
}

위의 예제코드는 동일한 기능을 JUnit4에서 제공하는 Stopwatch를 확장하여 구현한 예제입니다.

@RegisterExtension - 절차적으로 확장기능 사용하기

@ExtendWith 어노테이션을 통해서 확장기능을 선언적으로 등록할 수 있다라고 한다면 @RegisterExtension을 통해서는 절차적 즉, 프로그램 코드를 이용하여 확장기능을 등록할 수 있습니다. @ExtendWith로 충분하다고 생각되므로 크게 유용성을 못느낄수도 있지만 있겠지만 어노테이션 기반으로 사용하는 경우와는 다르게 생성자(Constructor)를 통해 확장기능에 의존성을 주입하거나, 빌더등을 통해서 프로그램을 통한 설정이 가능해집니다.

또한, static 필드도 제한없이 지원하므로 클래스 레벨의 확장기능들도 제한없이 적용할 수 있습니다.

class RegisterExtensionExample {

    @RegisterExtension
    static BeforeAllCallbackExample beforeAll = new BeforeAllCallbackExample();

    @RegisterExtension
    BeforeEachCallbackExample beforeEach = new BeforeEachCallbackExample();

    //Some test
}

위의 예제와 같이 확장기능을 등록할 수 있으며 생명주기에 맞춰 static으로 선언된 것도 확인할 수 있다. 또한 @RegisterExtension으로 등록하고자 하는 필드는 null이거나 private으로 지정되면 안됩니다.

@RepeatedTest - 반복테스트

동일한 테스트를 반복해서 수행해야할 경우 사용하는 어노테이션입니다. 성능적인 이슈를 확인하거나 하는 반복적인 테스트를 수행할 경우에 사용할 수 있을 듯합니다.

class RepeatedTestExample {

    @RepeatedTest(10)
    @DisplayName("반복 테스트")
    void repeatedTest() {
        //...
    }

    @RepeatedTest(value = 10, name = "{displayName} 중 {currentRepetition} of {totalRepetitions}")
    @DisplayName("반복 테스트")
    void repeatedTest2() {
        //...
    }

}

위의 예제와 같이 반복적인 테스트를 수행할 수 있습니다. 또한, @RepeatedTest가 제공하는 세가지 플레이스홀더({displayName}, {currentRepetition}, {totalRepetitions})를 이용하면 각 이터레이션의 DisplayName을 커스텀하게 출력할 수 있습니다. 이터레이션에 관한 정보는 아래에서 소개할 RepetitionInfo를 통해서도 접근할 수 있습니다.

생성자와 메소드에 파라메터 의존성 주입

이전버전에서 기본적으로 제공되지 않았던 의존성 주입(DI: Dependency Injection)이 지원됩니다. Spring과 같이 별도의 DI 컨테이너를 사용하는 경우에는 거의 사용할 일이 없으므로 간단하게 알아보도록 하겠습니다.

JUnit에서 지원하는 의존성 주입은 ParameterResolver라는 인터페이스의 구현을 확장기능으로 추가하는 형태로 동작됩니다. Built-in으로 아래와 같은 ParameterResolver가 제공됩니다.

ParameterResolver 설명
TestInfoParameterResolver 파라메터의 타입이 TestInfo인 경우 값으로 바인딩 시켜주는 Resolver입니다. TestInfo는 테스트의 DisplayName, 클래스, 메소드, 태그 등과 같은 테스트에 관련된 정보를 가지고 있어 테스트 클래스 내부에서 사용할 수 있습니다.
RepetitionInfoParameterResolver @RepeatedTest, @BeforeEach, @AfterEach의 메소드의 파라메터가 RepetitionInfo인 경우에 값으로 바인딩 시켜주는 Resolver입니다. @RepeatedTest를 통해 반복 테스트를 수행하는 경우 관련된 정보를 참조할 수 있습니다. 반복수행중이 아닌 경우에는 참조할 수 없으므로 주의가 필요합니다.
TestReporterParameterResolver TestReporter이 파라메터의 타입인 경우 바인딩 시켜줍니다. TestReporter를 통해서는 현재 태스트의 정보를 리포트 정보로 출력하는 것이 가능합니다.
class ParameterResolverExample {

    @Test
    @DisplayName("TestInfo 인젝션")
    void testInfo(TestInfo testInfo) {
        System.out.println(testInfo.getDisplayName());
    }

    @RepeatedTest(10)
    @DisplayName("RepetitionInfo 인젝션")
    void testInfo(RepetitionInfo repetitionInfo) {
        System.out.println(repetitionInfo.getCurrentRepetition() + " of " + repetitionInfo.getTotalRepetitions());
    }

    @Test
    @DisplayName("TestReporter 인젝션")
    void testInfo(TestInfo testInfo, TestReporter testReporter) {
        testReporter.publishEntry(testInfo.getDisplayName());
    }
}

Spring 기반의 테스트를 작성할 경우에는 Spring에서 제공하는 SpringRunnerParameterResolver의 구현을 제공하므로 생성자나 메소드의 @AutoWired가 동작합니다.

@SpringBootTest
class SomeSpringTest {

    @Autowired
    SomeBean someBean;

    SomeSpringTest(@AutoWired SomeElseBean someElseBean) {
        //...
    }

    @Test
    void test(@AutoWired SomeElseBean someElseBean) {
        //...
    }
}

위와같이 Spring기반의 테스트 작성시 필드 인젝션 이외에도 생성자나 메소드단위에서 DI가 가능합니다. 다만 @Nested 테스트에서는 동작하지 않으므로 주의가 필요합니다.(5.3.2버전에서 확인)

ParameterResolver의 구현방법에 대해서는 JUnit의 샘플로 제공되고 있는 RandomParametersExtension을 참조하시면 될 듯합니다.

default 메소드

Java8에서 도입된 default 메소드가 이용 가능하게 되었습니다. 단위테스트 작성시에 어느 정도는 타입이 정형화 되는 경우가 많으므로 추상(Abstract) 클래스에 공통적으로 사용할 테스트 설정들을 기술해서 사용하는 경우가 있습니다. 이 경우에 Java는 다중상속을 지원하지 않으므로 약간씩 다른 설정을 적용하기 위해서는 추상클래스를 여러개 작성하거나 테스트 별로 같은 설정을 기술하거나 하는 귀차니즘이 발생했습니다.

5에서 사용할 수 있게된 default 메소드를 이용하면 이부분의 불편함을 해소할 수 있을것으로 생각됩니다.

interface DefaultTestEachInterface {

    @BeforeEach
    default void beforeEach(TestInfo testInfo) {
        System.out.println("Test execute - " + testInfo.getDisplayName());
    }

    @AfterEach
    default void afterEach(TestInfo testInfo) {
        System.out.println("Test executed - " + testInfo.getDisplayName());
    }

}
@TestInstance(Lifecycle.PER_CLASS)
interface DefaultTestAllInterface {

    @BeforeAll
    default void beforeAll() {
        System.out.println("beforeAll");
    }

    @AfterAll
    default void afterAll() {
        System.out.println("afterAll");
    }

}
class DefaultMethodExample implements DefaultTestAllInterface, DefaultTestEachInterface {

    @Test
    void test() {
        System.out.println("test");
    }

}

위의 예제와 같이 공통으로 설정할 부분을 별도의 정의해서 인터페이스를 구현하는 방식으로 사용할 수 있습니다. 한가지 주의할 점은 interface 에는 static 이 지정되지 않으므로 @BeforeAll과 같이 클래스 단위로 설정해야할 것이 있다면 @TestInstance(Lifecycle.PER_CLASS)를 선언해 주어야 한다는 점입니다.

JUnit4에서 JUnit5로 마이그레이션

JUnit5는 JUnit4와 달라진 어노테이션 (혹은 클래스)도 있지만 같은 어노테이션이라고 하더라도 패키지가 다릅니다. 이는 같이 사용하는 경우를 고려한 조치가 아닐까 생각됩니다. 따라서 전체적으로 패키지명을 일괄 변경하고 일부 달라진 어노테이션 등의 명칭을 변경할 필요가 있습니다. 아래의 내용을 참조하여 마이그레이션을 수행하시면 간단하게 JUnit5로 전환하실 수 있습니다.

  • 의존성 변경(JUnit4관련 의존성을 배제하고 추가하면 변경할 부분을 쉽게 알 수 있음)
  • 패키지명 org.junit 에서 org.junit.jupiter.api 으로 변경
  • expected, timeout 등과 같이 @Test 어노테이션에서 배제된 내용 Assertion(단정문)으로 변경하여 적용
  • @Before -> @BeforeEach, @After -> @AfterEach 등과 같이 변경된 생명주기(LifeCycle)어노테이션 변경
  • Enclose -> @Nested 중첩 테스트 관련 변경 적용
  • Parameterized -> @ParameterizedTest 매개변수화 테스트 관련 변경 적용
  • @Ignore -> @Disabled로 변경
  • 필수는 아니지만 @DisplayName을 이용하거나 새롭게 추가된 @DisplayNameGeneration을 이용하여 테스트명을 보기쉽게 변경
  • @RunWith, @Rule@ExtendWith로 변경 특히@Rule을 이용하여 구현한 확장기능은 @ExtendWith와 호환성이 없으므로 다시 구현해야 할 수 있습니다.
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {SomeTestConfiguration.class})
public class SomeTest {
    //JUnit4
}
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {SomeTestConfiguration.class})
public class SomeTest {
    //JUnit5
}

Spring을 사용하는 경우에는 외와 같이 @RunWith@ExtendWith 바꿔서 Spring의 설정을 적용할 수 있습니다.

@RunWith(SpringRunner.class)
@SpringBootTest
public class SomeTest {
    //JUnit4
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(SpringBootTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
public @interface SpringBootTest {
}

Spring Boot를 사용하시는 경우에는 위의 예제처럼 @SpringBootTest을 통하여 설정을 로드하게 되는데 @SpringBootTest 어노테이션에는 @ExtendWith(SpringExtension.class) 가 포함되어 있으므로 JUnit5로 변경시에는 @RunWith(SpringRunner.class)를 삭제하시면 됩니다.

junit-jupiter-migrationsupport 모듈

JUnit4의 @Rule의 마이그레이션을 지원해주는 junit-jupiter-migrationsupport라는 서브 모듈이 존재합니다만 5.4.2버전 현재 제한적인 Rule만 지원해 주는 것으로 보입니다.

This module provides support for JUnit 4 rules within JUnit Jupiter. Currently, this support is limited to subclasses of the org.junit.rules.Verifier and org.junit.rules.ExternalResource rules of JUnit 4, respectively.

Please note that a general support for arbitrary org.junit.rules.TestRule implementations is not possible within the JUnit Jupiter extension model.

junit-jupiter-migrationsupport github repository

위의 글에서 확인할 수 있듯 지원하는 Rule은 org.junit.rules.Verifier, org.junit.rules.ExternalResource 두개입니다. 더이상의 지원은 하지 않을 것으로 보이므로 가능하면 JUnit5의 인터페이스에 맞춰서 재구현 하는 것이 바람직해 보입니다.

해당모듈은 JUnit5의 테스트에서 JUnit4의 확장기능을 수행하려고 할 경우 사용할 수 있습니다. 또한 5.4이후에는 확장기능은 아니지만 @Ignore를 그대로 사용할 수 있는 기능이 추가되었습니다.(@Ignore정도는 일괄 변경이 가능할 것 같은데 굳이 왜 제공하는지는 의문입니다)

@EnableJUnit4MigrationSupport
class JUnit4IgnoreTestExample {
    @Test
    @Ignore
    void test() {
        System.out.println("이테스트는 JUnit4의 @Ignore에 의해서 무시됩니다.");
    }
}

위의 예제는 @Ignore를 5버전의 테스트에서 사용하는 설정입니다. @EnableJUnit4MigrationSupport을 해당 테스트의 클래스에 설정하면 적용됩니다. 해당 어노테이션은 @Ignore@Rule도 함께 사용할 수 있도록 설정하는 어노테이션입니다. @Rule만 사용할 경우에는 @EnableRuleMigrationSupport을 선언하시면 됩니다.

JUnit5와 JUnit4를 같이 사용하기

시간이 없거나 귀차니즘으로 인하여 마이그레이션을 진행하기 어려운 경우에는 기존 코드는 그대로 두고 새로 작성하는 코드만 JUnit5를 이용하는 방식으로 같이 사용할 수 있습니다.

dependencies {
    testCompile('junit:junit:4.12')

    testCompile 'org.junit.jupiter:junit-jupiter-api:5.4.2'
    testCompile 'org.junit.jupiter:junit-jupiter-params:5.4.2'
    testRuntime 'org.junit.jupiter:junit-jupiter-engine:5.4.2'

    testCompile 'org.junit.vintage:junit-vintage-engine:5.4.2'
}

위와 같이 JUnit5와 JUnit4의 의존성을 추가하고 JUnit5 플랫폼에서 JUnit4를 실행할 수 있게 해주는 junit-vintage-engine를 같이 추가하면 기존의 소스를 고치지않고 같이 사용하실 수 있습니다.

IntelliJ에서 JUnit5,4를 같은 모듈에 두고 실행할 경우 위와같이 그룹핑되어 테스트가 수행됩니다.

마치며

두편의 글을 통해서 JUnit5에 대해서 알아보았습니다. 전반적인 개념은 JUnit4와 크게 다르지 않으므로 쉽게 적용하실 수 있을 듯 합니다. 한번 사용해보는건 어떨까요? 또한 이 글에 소개한 내용만으로 충분하다고 생각되지만 이외에도 다양한 기능들이 있으므로 확인해 보시기 바랍니다.

참고자료

JUnit 5 User Guide

연관글

JUnit5의 기능소개

'Java > JUnit' 카테고리의 다른 글

JUnit5의 기능 소개  (0) 2019.05.11
Posted by Reiphiel
Java/JUnit2019. 5. 11. 21:25
반응형

JUnit5는 2017년9월 5.0이 릴리스 되었습니다. 릴리스되고 시간이 제법 경과되었지만 아직까지는 많이 사용되고 있는 것 같이 않아서 JUnit4와 비교를 통해서 사용법에 대해서 알아보도록 하겠습니다.

지원현황

  • Java8이상 지원
  • IntelliJ IDEA 2016.2이후부터 지원
  • Eclipse Oxygen이후부터 지원
  • Kotlin 지원

의존성

JUnit5는 이전버전과는 달리 여러 모듈로 구성되어 있습니다.

testCompile 'org.junit.jupiter:junit-jupiter-api:5.4.2'
testRuntime 'org.junit.jupiter:junit-jupiter-engine:5.4.2'
//@ParameterizedTest를 사용하기 위해서 필요한 의존성
testCompile 'org.junit.jupiter:junit-jupiter-params:5.4.2'

테스트 작성해보기

class HelloworldTest {
    @Test
    void test() {
        //Some test
    }
}

특별히 의미를 가지는 테스트는 아닙니다만 이전 버전과의 차이점을 설명드리기 위해서 작성해보았습니다. 위의 예제코드상에서 보면 테스트 클래스와 메소드는 더이상 가시성(visibility)이 public으로 선언되지 않아도 동작합니다. 물론 기존과 같이 public으로 선언하거나 protected로 선언해도 동일하게 동작합니다. 테스트 클래스는 일반적으로 클래스단위로 완결되는 경우가 많으므로 IDE에서 자동으로 생성해주는 경우를 제외하면 더 적은 타이핑으로 같은 코드를 작성할 수 있습니다.

Long story, short: we (the JUnit 5 team) believe in the principle "Less is more", meaning the less you have to type to achieve your goal, the better! - Sam Brannen(JUnit Core Commiter)

Why is the default access modifier in JUnit 5 package-private?

Assertion(단정문)

JUnit5에서도 이전 버전과 마찬가지로 Assertion은 기본적인 것만 제공하므로 AssertJ, Hamcrest와 같은 것들은 여전히 사용하는 것이 편리합니다. 다만 몇가지 추가된 사항에 대해서 살펴보도록 하겠습니다.

Assertion 전체실행

Assertion문 전체를 실행할 수 있는 기능이 추가되었습니다. 이전버전에서는 Assertion에 실패할 경우 나머지 Assertion문이 실행되지 않았기때문에 전체를 성공시기키위해서는 여러번 테스트를 수행하면서 코드를 수정해야 전체를 평가할 수 있게됨으로서 단위테스트 수행횟수를 감소시킬 수 있을듯합니다.

class AssertAllExample {
  @Test
  //전체 단정문이 실행된다.
  void test() {
      String name = "Name";
      assertAll("Heading",
              () -> assertEquals("John", name),
              () -> assertEquals("Doe", name),
              () -> {
                  assertAll("Heading",
                          () -> assertEquals("John", name),
                          () -> assertEquals("Doe", name)
                  );
              }
      );
  }

  @Test
  //단정문이 한개만 실행된다
  void test1() {
      String name = "Name";
      assertAll("Heading", () -> {
                  assertEquals("John", name);
                  assertEquals("Doe", name);
              }
      );
  }
}

위의 코드와 같이 org.junit.jupiter.api.Assertions#assertAll의 인자로 Functional Interface(org.junit.jupiter.api.function.Executable) 통해서 전달함으로 전체 단정문을 한번에 평가할 수 있습니다. 단정문을 Statement로 전달하게 되면 이전과 동일하게 단정문이 실패하면 테스트가 종료됩니다.

예외의 상태 Assertion

예외의 내부 상태를 검증할 수 있는 기능이 추가되었습니다. 이전버전에서는 @Test 어노테이션에 기대하는 예외를 기술하는 방식(예: @Test(expected=IllegalArgumentException.class))으로 테스트를 수행했기 때문에 단순히 예외가 발생하는지 여부만 검증할 수 있었습니다.

class ExceptionTestExample {
    @Test
    @DisplayName("예외의 상태 테스트")
    void exception() {
        Throwable e = assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("Argument Required");
        });
        assertEquals("Argument Required", e.getMessage());
    }

    @Test
    @DisplayName("예외 미발생")
    void exceptionNotThrow() {
        assertDoesNotThrow(() -> System.out.println("Do Something"));
    }
}

위의 코드와 같이 단정문을 통해서 예외 발생을 검증하고 그 예외를 반환받아서 예외의 상태를 검증할 수 있습니다. 예외가 던져지지 않음을 검증하는 단정문도 5.2부터 추가되었습니다. 특정 코드블럭을 검증할 때 사용할 수 있다고 javadoc에 기술되어 있습니다만 사용할 일이 그다지 많아보지이는 않습니다.

Timeout(타임아웃) Assertion

Timeout 단정문이 추가되었습니다. 이전버전에서도 @Test(timeout=2000L) 어노테이션을 혹은 @Rule을 통해 검증이 가능했습니다. 하지만 단정문을 사용하게됨으로서 테스트 메소드 내부에 제약시간안에 수행해야할 처리만 기술해야했던 것과 다르게 데이터를 생성하는 부분과 같은 것들을 좀더 유연하게 기술할 수 있게되어 사용성이 개선되었습니다.

class TimeoutExample {
  @Test
  @DisplayName("타임아웃 준수")
  void timeoutNotExceeded() {
      assertTimeout(ofMinutes(2), () -> Thread.sleep(10));
  }

  @Test
  @DisplayName("타임아웃 초과")
  void timeoutExceeded() {
      assertTimeout(ofMillis(10), () -> Thread.sleep(100));
  }

  @Test
  @DisplayName("타임아웃 초과(assertTimeoutPreemptively)")
  void timeoutExceededWithPreemptiveTermination() {
      assertTimeoutPreemptively(ofMillis(10), () -> {
          Thread.sleep(100);
      });
  }
}

assertTimeout은 지정한 코드블럭 전체가 실행된 이후에 타임아웃을 검증하지만 assertTimeoutPreemptively을 이용하면 시간이 초과된 경우 즉시 검증실패 처리할 수 있습니다. 다만 별도의 스레드를 통해서 테스트가 동작하게되므로 ThreadLocal을 많이 사용하는 Spring기반의 프로젝트에서 사용하기에는 힘들어 보입니다.

@Rule
public Timeout timeout = Timeout.seconds(5);

@Test
public void exceed_timeout() throws Exception {
    Thread.sleep(6000);
}

위의코드는 JUnit4의 @Rule을 이용하여 Timeout을 검증하는 예제입니다. @Rule을 통해서 검증을 수행하므로 해당 테스트 클래스 내부 전체 메소드에 적용됩니다. 약간씩 다른 시간을 검증하기 위해서 테스트 클래스를 따로따로 선언해야하는 불편함이 있습니다.

Assumptions(전제문)

특정 조건을 전제하고 테스트를 수행할 수 있습니다.

class AssumptionsExample {
  @Test
  @DisplayName("개발환경에서만 수행")
  void dev_env_only() {
      assumeTrue("DEV".equals(System.getenv("ENV")),
              () -> "개발 환경이 아닙니다.");
      //단정문이 실행되지 않음
      assertEquals("A", "A");
  }

  @Test
  @DisplayName("일부만 개발환경에서 수행")
  void some_test() {
      assumingThat("DEV".equals(System.getenv("ENV")),
              () -> {
                  //단정문이 실행되지 않음
                  assertEquals("A", "B");
              });
      //단정문이 실행됨
      assertEquals("A", "A");
  }
}

위의코드에서 assumeTrue를 통해서 전제조건을 검증한 경우에는 이후의 테스트 전체가 실행되지 않지만 assumingThat을 통해서 전제조건을 검증한 경우에는 해당 메소드이 파라메터로 전달된 코드블럭만 실행되지 않고 이후의 테스트는 정상적으로 수행되게 됩니다. CI와 같이 특정환경에서만 테스트를 진행해야하는 경우에 사용할 수 있습니다.

어노테이션(Annotation)

JUnit5에서 새롭게 추가되었거나 이전과 달라진 어노테이션에 대해서 비교를 통해서 알아보도록 합시다.

@Test

가장 먼저 살펴볼 어노테이션은 바로 @Test입니다. 이 어노테이션은 사실 새로울 것이 없는 어노테이션이지만 위에서 소개해 드린 timeout, 예외관련 단정문 추가와 함께 해당내용을 설정할 수 있는 속성이 제거되어 단순한 마커 인터페이스(Marker Interface)로 바뀌었습니다.

생명주기(LifeCycle) 어노테이션

기존에 제공하던 생명주기 어노테이션을 좀더 명확한 이름으로 바뀌어서 그대로 제공됩니다. 기존의 @BeforeClass, @AfterClass, @Before, @After 어노테이션이 각각 @BeforeAll, @AfterAll, @BeforeEach, @AfterEach으로 변경되었습니다.

@DisplayName

새롭게 제공되는 어노테이션 중 가장 많이 사용할 것 같은 @DisplayName 은 IDE나 빌드 툴에서 표시할 테스트 이름을 지정하는 기능입니다. 메소드명을 사용할때는 사용할 수 없었던 공백, 특수문자, 이모지등을 지정할 수 있습니다.

@DisplayName("특수 테스트\uD83D\uDE00")
class DisplayNameExample {
    @Test
    @DisplayName("굉장한 테스트입니다.")
    void test() {
    }
}

IntelliJ에서 위의 단위테스트를 수행하면 아래와 같은 결과가 출력됩니다.

 

@DisplayName에 지정한 이름이 표시됩니다.

@DisplayNameGeneration

JUnit5.4부터 추가된 기능으로 @DisplayNameGeneration이 있습니다. 표시명을 생성을 프로그램 코드로 지정할 수 있는 기능입니다. @DisplayName이 있어서 쓸일은 없어보입니다. 다만 이전에 테스트명의 가독성을 올리기 위해서 많이 사용하는 언더스코어로 구분하기 방법을 이용한 경우라면 기본으로 제공되는 ReplaceUnderscores Generator를 사용해서 JUnit4에서 JUnit5로 마이그레이션 할 때 유용하게 사용할 수 있지않을까 생각됩니다.

@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class DisplayNameGeneration_Example {
    @Test
    void 완벽한_테스트입니다() {

    }
}

@Nested(계층화)

테스트 클래스안에서 내부 클래스(Inner Class)를 정의해 테스트를 계층화할 수 있습니다. 내부클래스로 정의하기 때문에 부모클래스의 멤버필드에 접근할 수 있는 것은 물론이고 Before/After와 같은 테스트 생명주기에 관계된 메소드들도 계층에 맞춰 동작합니다.

class NestedExample {
    @BeforeAll
    static void beforeAll() {
        System.out.println("Parent beforeAll");
    }
    @AfterAll
    static void afterAll() {
        System.out.println("Parent afterAll");
    }
    @BeforeEach
    void beforeEach() {
        System.out.println("Parent beforeEach");
    }
    @AfterEach
    void afterEach() {
        System.out.println("Parent afterEach");
    }
    @Test
    void test() {
        System.out.println("Parent test");
    }

    @Nested
    class Child {
        @BeforeEach
        void beforeEach() {
            System.out.println("Child beforeEach");
        }
        @AfterEach
        void afterEach() {
            System.out.println("Child afterEach");
        }
        @Test
        void test() {
            System.out.println("Child Test");
        }
    }
}

위의 예제는 어떤 순서로 실행될까요. 잠깐 생각해본 후에 확인해 보세요.

일단 @BeforeAll 메소드가 테스트의 시작부분에실행됩니다. 이후에 부모 클래스의 테스트가 먼저 실행되는데 전후로 부모 클래스의 @BeforeEach와 @AfterEach가 실행됩니다. 그리고 자식 클래스의 테스트가 실행되기 전에 부모 클래스와 자식 클래스의 @BeforeEach가 각각 실행되면 자식 클래스와 부모 클래스의 @AfterEach가 실행됩니다. 마지막으로 @AfterAll이 실행되고 테스트가 종료됩니다. 실제 수행한 결과는 아래와 같습니다.

 

생명주기 테스트 실행 결과

그에 반해서 JUnit4에서 지원했던 Enclosed를 통한 계층화는 정적 중첩 클래스(Static Nested Class)를 이용해야하므로 각각의 클래스가 별개의 생명주기를 가집니다. 따라서 그룹화 이상의 의미를 두기가 힘듭니다.

@RunWith(Enclosed.class)
public class EnclosedExample {

    public static class Enclosed {

        @Before
        public void setUp() {
            System.out.println("Enclosed before");
        }

        @After
        public void tearDown() {
            System.out.println("Enclosed after");
        }

        @Test
        public void test() {
            System.out.println("Enclosed ");
        }

    }
}

위와같이 Enclosed를 이용하여 계층화를 할 수 있습니다. 다만 부모 클래스에서 정의된 테스트가 수행되지 않는다는 점, 또한 클래스 단위의 생명주기(Lifecycle) 어노테이션(@BeforeClass, @AfterClass)이 기대한 순서와는 다르게 동작하는 문제(이부분은 왠지 버그처럼 생각되기도 합니다) 그리고 제일 중요한 문제인 @RunWith에 Enclosed를 지정해야하므로 다른 Runner를 사용하는데 제약이 생기는 부분이 사용을 주저하게 만들지 않을까 생각됩니다.

@ParameterizedTest(매개변수화 테스트)

Parameterized Test는 반복적으로 수행해야할 테스트의 데이터를 정의할 수 있는 기능입니다. 루프문등을 이용해서 수행할 수도 있다고 생각할 수 있지만 테스트에 사용할 데이터와 코드가 분리되므로 가독성 향상, 테스트의 성격이나 목적등을 잘 드러낼 수 있을것으로 생각됩니다.

class ParameterizedTestExample {

    @ParameterizedTest
    @ValueSource(ints = {10, 20})
    void test1(int i) {
        System.out.println("test1 - " + i);
    }

    @ParameterizedTest
    @NullSource
    void test2(Integer i) {
        System.out.println("test2 - " + i);
    }

    @ParameterizedTest
    @EmptySource
    void test3(String i) {
        System.out.println("test3 - " + "[" + i + "]");
    }

    @ParameterizedTest
    @NullAndEmptySource
    void test4(String i) {
        System.out.println("test4 - " + "[" + i + "]");
    }

    @ParameterizedTest
    @NullSource
    @ValueSource(ints = {10, 20})
    void test5(Integer i) {
        System.out.println("test5 - " + "[" + i + "]");
    }

    @ParameterizedTest
    @CsvSource({
            "10, 20",
            "30, 40"
    })
    void test6(int i, int j) {
        System.out.println("test6 - " + "[" + i + "],[" + j + "]");
    }

}

위의 예제처럼 @ParameterizedTest를 선언한 테스트에 데이터소스를 지정하는 방식으로 테스트 데이터를 지정할 수 있습니다. @NullSource, @EmptySource를 통해서 null이나 빈문자열 등을 전달할 수 있으며 @ValueSource를 통해서 값을 전달 할 수 있습니다. 파라메터의 타입과 맞아야 하므로 하나만 전달할 수 있습니다. 여러개의 데이터를 전달해야할 경우에는 @CsvSource를 이용할 수 있습니다. 이외에도 Csv를 파일로부터 가져올 수 있는 @CsvFileSource와 enum타입을 전달할 수 있는 @EnumSource도 있습니다.

@RunWith(Parameterized.class)
public class ParameterizedExample {

    @Parameterized.Parameters
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][]{
                {0, 1, 2L}, {2, 3, 4L}
        });
    }

    private int i;
    private int j;
    private long k;

    public ParameterizedExample(int i, int j, long k) {
        this.i = i;
        this.j = j;
        this.k = k;
    }

    @Test
    public void test() {
        System.out.println(i + "," + j + "," + k);
    }

}

이에 반해서 JUnit4에서 지원하는 Parameterized는 위의 예제와 같이 클래스 단위로 기능하고 @RunWith를 사용하게 됨으로 다른 Runner를 사용하는데 제약이 생기므로 거의 사용되지 않았었습니다.

@Disabled

테스트를 수행할 수 없는 상황이 생겼을 경우 해당 테스트 코드를 코멘트아웃해서 임시로 문제를 회피하거나 했습니다. 하지만 코멘트 처리를 하게되면 테스트의 존재자체가 잊혀질 수 있기때문에 한번 코멘트된 다시 살아나는 경우를 보기가 힘들었습니다. 새로 추가된 @Disabled를 이용하면 테스트를 일시 중단시키고 지정한 메시지를 출력시켜주므로 테스트의 존재여부를 항상 인지할 수 있습니다. 이기능은 JUnit4의 @Ignore와 동일하게 동작합니다.

class DisabledExample {

    @Test
    @Disabled("문제가 해결될때까지 테스트 중단")
    void test() {
        System.out.println("테스트");
    }

    @Test
    void test2() {
        System.out.println("테스트2");
    }
    
}

위의 코드를 실행하면 아래와 같이 중단되었다는 사실을 인지할 수 있는 상태로 테스트가 실행되지 않습니다.

@Tag

클래스나 메소드단위의 테스트의 필터링을 위해서 태깅용으로 제공되는 어노테이션입니다. JUnit4의 카테고리와 유사한 기능입니다. CI 혹은 빌드툴, IDE 등에서 태깅된 테스트를 필터링하여 테스트를 실행하거나 할 수 있습니다.

조건부실행 Annotation

조건부로 테스트를 수행할 수 있는 어노테이션에 대해서 간단하게 알아보도록 하겠습니다. 위에서 소개한 Assumptions을 이용해서 동일한 기능을 수행할 수 있고 상세한 컨트롤이 가능하지만 아무래도 편의성 측면에서 추가적으로 제공하는 것으로 보입니다.

@EnabledOnOs, @DisabledOnOs

특정 OS플랫폼 별로 실행여부를 결정해야하는 테스트가 있다면 아래와 같이 테스트를 작성할 수 있습니다.

class ConditionalOsExecutionExample {
  @Test
  @EnabledOnOs(MAC)
  void 맥전용() {
      System.out.println("맥에서만 실행");
  }
  @Test
  @EnabledOnOs({LINUX, MAC})
  void 리눅스_맥_전용() {
      System.out.println("리눅스나 맥에서만 실행");
  }
  @Test
  @DisabledOnOs({WINDOWS, MAC})
  void 윈도우_맥_제외() {
      System.out.println("윈도우나 맥에서 미실행");
  }
}

@EnabledOnJre, @DisabledOnJre

Java 버전별로 실행여부를 결정해야할 테스트가 있을 경우 사용할 수 있는 어노테이션입니다.

class ConditionalEnvironmentExample {
  @Test
  @EnabledOnJre(JAVA_8)
  void java8() {
      System.out.println("JAVA8에서만 실행");
  }
  @Test
  @EnabledOnJre({JAVA_9, JAVA_10})
  void java9or10() {
      System.out.println("JAVA9, 10에서 실행");
  }
  @Test
  @DisabledOnJre(JAVA_9)
  void not_java9() {
      System.out.println("JAVA8에서 미실행");
  }
}

@EnabledIfSystemProperty, @DisabledIfSystemProperty

System 프로퍼티(System#getProperty)를 확인해서 테스트 실행여부를 결정해야할때 사용할 수 있는 어노테이션입니다.

@Test
@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
void only64bit() {
}
@Test
@DisabledIfSystemProperty(named = "test", matches = "true")
void onlyTestProperty() {
}

@EnabledIfEnvironmentVariable, @DisabledIfEnvironmentVariable

System 환경변수(System#getenv)를 확인해서 테스트 실행여부를 결정할 경우 사용할 수 있는 어노테이션입니다.

@Test
@EnabledIfEnvironmentVariable(named = "ENV", matches = "DEV")
void development() {
}
@Test
@DisabledIfEnvironmentVariable(named = "ENV", matches = "DEV")
void notDevelopment() {
}

@EnabledIf, @DisabledIf

이 어노테이션은 Java Script 표현식을 평가해서 실행여부를 결정할 수 있는 어노테이션입니다. Java에서 사용할 수 있는 Java Script 엔진을 이용해서 표현식을 평가합니다. 기본값은 Nashorn입니다. 현재(5.4버전)까지는 실험적인 기능(experimental feature) 이므로 코드를 소개하지는 않겠습니다.

메타 어노테이션(Meta-Annotation)과 합성 어노테이션(Composed-Annotations)

JUnit Jupiter의 어노테이션은 메타 어노테이션으로 사용할 수 있습니다. 다시말해 합성 어노테이션을 정의해서 사용할 수 있습니다.

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Test
@EnabledOnOs(MAC)
public @interface MacOnlyTest {
}
@MacOnlyTest
void test() {
    //Some Test
}

위와 같은 어노테이션을 만들어서 제공하게 되면 코드량을 감소시킬 수 있으므로 편리하게 사용할 수 있습니다.

마치며

JUnit5에서 제공되는 기본 기능들에 대해서 간단하게 알아보았습니다. 이전 버전에서 불편하게 생각되었던 것들이 상당 부분 해소되었다고 생각됩니다. 그럼에도 아직까지 많이 사용되지 않는 이유는 Spring Boot Starter에서 JUnit버전이 4에 머물고 있기때문이 아닐까라고 조심스럽게 생각해봅니다.

참고자료

JUnit 5 User Guide

연관글

JUnit5의 기능소개 두번째

'Java > JUnit' 카테고리의 다른 글

JUnit5의 기능 소개 - 두번째  (0) 2019.05.19
Posted by Reiphiel