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