JUnit5
JUnit5은 자바의 Unit 테스트 프레임워크이다. JUnit5을 사용해 자바 코드를 테스트 할 수 있다.
- 자바 8 이상 버전을 요구한다.
- JUnit5는 JUnit Platform, JUnit Jupiter, JUnit Vintage 3가지 모듈로 구성된다.
- JUnit Platform : JUnit Platform은 테스트를 발견하고 테스트 계획을 생성하는 테스트엔진 인터페이스를 정의하고 있다. Platform은 TestEngine을 통해서 테스트를 발견하고, 실행하고, 결과를 보고한다.
- JUnit Jupiter : Jupiter API로 테스트 코드를 작성하고 Jupiter API 기반의 테스트 코드를 실행하기 위한 테스트 엔진(Jupiter Engine)을 제공한다. Jupiter API는 JUnit 5에 새롭게 추가된 테스트 코드용 API이다.
- JUnit Vintage : JUnit 3,4 테스트 코드를 실행할 수 있는 테스트 엔진을 제공한다.
아래의 공식 사이트에서 더 많은 정보를 확인할 수 있다.
https://junit.org/junit5/docs/current/user-guide/#overview
용어 정의
Platform 관련 용어
- Container : 다른 컨테이너나 테스트를 자식으로 포함하는 테스트 트리의 노드 (ex - 테스트 클래스)
- Test : 실행될 때 예상되는 동작을 확인할 수 있는 테스트 트리의 노드 (ex - @Test 메서드)
Jupiter 모듈 관련 용어
- Lifycycle method : @BeforeAll, @AfterAll 등의 애노테이션을 가진 메서드
- Test Class : 적어도 하나 이상의 테스트 메서드를 가진 클래스. 즉 컨테이너
- Test Method : @Test, @RepeatedTest, @ParameterizedTest 등의 애노테이션을 가진 인스턴스 메서드. @Test를 제외하고 다른 애노테이션은 테스트 트리에 컨테이너를 생성
테스트 클래스, 테스트 메서드
테스트 클래스는 abstract이면 안되며 반드시 하나의 생성자만을 가져야만 한다. 또한 테스트 메서드와 라이프 사이클 메서드는 abstract이면 안되며 값을 반환해서는 안된다. (@TestFactory 제외)
테스트 클래스, 테스트 메서드, 라이프 사이클 메서드의 접근제한자는 꼭 public일 필요는 없지만 private는 절대 안된다. 기술적인 특별한 이유가 없는한 접근제한자를 생략하는 것을 추천한다.
Display Names
@DisplayName을 통해 테스트 클래스 또는 메서드에 커스텀 display name을 붙일 수 있다. display name은 테스트 결과에 표시된다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@DisplayName("A special test case")
class DisplayNameDemo {
@Test
@DisplayName("Custom test name containing spaces")
void testWithDisplayNameContainingSpaces() {
}
@Test
@DisplayName("╯°□°)╯")
void testWithDisplayNameContainingSpecialCharacters() {
}
@Test
@DisplayName("😱")
void testWithDisplayNameContainingEmoji() {
}
}
Assertions
JUnit5는 JUnit 4의 assertion 메서드와 Java 람다와 같이 사용하기 적합한 assertion 메서드를 제공한다. 모든 assertion 메서드는 org.junit.jupiter.api.Assertions 클래스의 static 메서드로 정의되어 있다.
assertion 메서드로 테스트 결과를 도출한다. 참고로 jupiter의 assertion보다는 assrtj 라이브러리를 사용하는 것이 좋다. 더 직관적이고 보기 쉬운 assertion 메서드와 오류 출력 결과를 제공하기 때문이다. 공식문서에서도 assrtj와 같은 assertion 외부 라이브러리 사용을 권장한다.
import static java.time.Duration.ofMillis;
import static java.time.Duration.ofMinutes;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.CountDownLatch;
import example.domain.Person;
import example.util.Calculator;
import org.junit.jupiter.api.Test;
class AssertionsDemo {
private final Calculator calculator = new Calculator();
private final Person person = new Person("Jane", "Doe");
@Test
void standardAssertions() {
assertEquals(2, calculator.add(1, 1));
assertEquals(4, calculator.multiply(2, 2),
"The optional failure message is now the last parameter");
assertTrue('a' < 'b', () -> "Assertion messages can be lazily evaluated -- "
+ "to avoid constructing complex messages unnecessarily.");
}
@Test
void groupedAssertions() {
// In a grouped assertion all assertions are executed, and all
// failures will be reported together.
assertAll("person",
() -> assertEquals("Jane", person.getFirstName()),
() -> assertEquals("Doe", person.getLastName())
);
}
@Test
void dependentAssertions() {
// Within a code block, if an assertion fails the
// subsequent code in the same block will be skipped.
assertAll("properties",
() -> {
String firstName = person.getFirstName();
assertNotNull(firstName);
// Executed only if the previous assertion is valid.
assertAll("first name",
() -> assertTrue(firstName.startsWith("J")),
() -> assertTrue(firstName.endsWith("e"))
);
},
() -> {
// Grouped assertion, so processed independently
// of results of first name assertions.
String lastName = person.getLastName();
assertNotNull(lastName);
// Executed only if the previous assertion is valid.
assertAll("last name",
() -> assertTrue(lastName.startsWith("D")),
() -> assertTrue(lastName.endsWith("e"))
);
}
);
}
@Test
void exceptionTesting() {
Exception exception = assertThrows(ArithmeticException.class, () ->
calculator.divide(1, 0));
assertEquals("/ by zero", exception.getMessage());
}
@Test
void timeoutNotExceeded() {
// The following assertion succeeds.
assertTimeout(ofMinutes(2), () -> {
// Perform task that takes less than 2 minutes.
});
}
@Test
void timeoutNotExceededWithResult() {
// The following assertion succeeds, and returns the supplied object.
String actualResult = assertTimeout(ofMinutes(2), () -> {
return "a result";
});
assertEquals("a result", actualResult);
}
@Test
void timeoutNotExceededWithMethod() {
// The following assertion invokes a method reference and returns an object.
String actualGreeting = assertTimeout(ofMinutes(2), AssertionsDemo::greeting);
assertEquals("Hello, World!", actualGreeting);
}
@Test
void timeoutExceeded() {
// The following assertion fails with an error message similar to:
// execution exceeded timeout of 10 ms by 91 ms
assertTimeout(ofMillis(10), () -> {
// Simulate task that takes more than 10 ms.
Thread.sleep(100);
});
}
@Test
void timeoutExceededWithPreemptiveTermination() {
// The following assertion fails with an error message similar to:
// execution timed out after 10 ms
assertTimeoutPreemptively(ofMillis(10), () -> {
// Simulate task that takes more than 10 ms.
new CountDownLatch(1).await();
});
}
private static String greeting() {
return "Hello, World!";
}
}
JUnit Lite Cycle
JUnit5로 테스트 시 기본적인 라이프 사이클은 아래와 같다.
JUnit은 각 테스트 메서드 실행 전에 테스트 클래스의 인스턴스를 새로 만든 후 테스트 메서드를 실행한다. 이는 각 테스트 메서드가 독립적으로 동작하도록 만듬으로써 사이드 이펙트를 피하게 한다.
예를 들어보자. 다음의 테스트 클래스가 있다.
class PerMethodLifecycleTest {
public PerMethodLifecycleTest() {
System.out.println("Constructor");
}
@BeforeAll
static void beforeTheEntireTestFixture() {
System.out.println("Before the entire test fixture");
}
@AfterAll
static void afterTheEntireTestFixture() {
System.out.println("After the entire test fixture");
}
@BeforeEach
void beforeEachTest() {
System.out.println("Before each test");
}
@AfterEach
void afterEachTest() {
System.out.println("After each test");
}
@Test
void firstTest() {
System.out.println("First test");
}
@Test
void secondTest() {
System.out.println("Second test");
}
}
테스트 클래스를 테스트하면 아래의 출력이 나온다.
Before the entire test fixture
Constructor
Before each test
First test
After each test
Constructor
Before each test
Second test
After each test
After the entire test fixture
테스트 메서드 작성 시 사전에 테스트에 필요한 데이터(객체) 로드 또는 환경 설정이 필요하다. 이런 사전 작업 코드가 테스트 메서드마다 중복된다면 테스트 코드 유지 보수에 좋지 않다. 중복을 제거하기 위해 테스트마다 중복되는 코드를 한 곳에 고정시키는 것을 Fixture라고 한다. Fixture 코드의 동작 시점은 테스트 결과를 바꿀 수 있을만큼 중요한데 JUnit은 여러 시점에서 동작하는 라이프 사이클 메서드를 제공하고 있다. 따라서 테스터는 라이프 사이클 메서드로 Fixture 코드의 동작 시점을 쉽게 정할 수 있다.
Assumptions
JUnit5는 JUnit4의 assumption 메서드와 람다와 같이 사용하기 적합한 assumption 메서드를 제공한다. 모든 assumption 메서드는 org.junit.jupiter.api.Assumptions 클래스의 정적 메서드로 정의되어 있다.
assumption 메서드로 조건에 따라 테스트를 수행할 수 있다. assertions 메서드가 실패하면 테스트의 결과도 실패인 반면 assumption 메서드(assumeTrue, assumeFalse)가 실패하면 테스트 결과는 실패가 아닌 테스트 중단이다.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.jupiter.api.Assumptions.assumingThat;
import example.util.Calculator;
import org.junit.jupiter.api.Test;
class AssumptionsDemo {
private final Calculator calculator = new Calculator();
@Test
void testOnlyOnCiServer() {
assumeTrue("CI".equals(System.getenv("ENV")));
// remainder of test
}
@Test
void testOnlyOnDeveloperWorkstation() {
assumeTrue("DEV".equals(System.getenv("ENV")),
() -> "Aborting test: not on developer workstation");
// remainder of test
}
@Test
void testInAllEnvironments() {
assumingThat("CI".equals(System.getenv("ENV")),
() -> {
// perform these assertions only on the CI server
assertEquals(2, calculator.divide(4, 2));
});
// perform these assertions in all environments
assertEquals(42, calculator.multiply(6, 7));
}
}
이 밖에도 테스트를 위한 여러 유용한 기능을 제공한다.
참고)
https://junit.org/junit5/docs/current/user-guide/#overview