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