JUnit 5 어서션
JUnit 5 어서션
테스트가 "제대로 동작한다"는 걸 어떻게 증명할까요? 가장 흔한 방법이 검증(assertion)을 걸어 실제 값이 기대값과 같은지 확인하는 거예요. JUnit Jupiter는 JUnit 4가 가진 assertion 메서드 대부분을 제공하면서, Java 람다와 잘 어울리는 몇 가지 메서드를 덧붙였어요. 이 페이지에서는 그 사용법을 예제와 함께 살펴볼게요.
본문
JUnit Jupiter의 모든 assertion은 org.junit.jupiter.api.Assertions 클래스의 static 메서드예요.
assertion 메서드는 선택적으로 세 번째 파라미터로 검증 실패 메시지를 받아요. 이 메시지는 String이거나 Supplier<String>일 수 있죠.
Supplier<String>(예: 람다 표현식)을 쓰면 메시지를 지연 평가(lazy)해요. 메시지 생성이 복잡하거나 시간이 걸리는 경우, 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.Tag;
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");
// Lazily evaluates generateFailureMessage('a','b').
assertTrue('a' < 'b', () -> generateFailureMessage('a','b'));
}
@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!";
}
private static String generateFailureMessage(char a, char b) {
return "Assertion messages can be lazily evaluated -- "
+ "to avoid constructing complex messages unnecessarily." + (a < b);
}
}
assertTimeoutPreemptively()의 선점형 타임아웃
Assertions 클래스의 여러 assertTimeoutPreemptively() 메서드는 제공받은 executable이나 supplier를 호출 코드와 다른 스레드에서 실행해요. 이 동작은 executable이나 supplier 안에서 실행되는 코드가 java.lang.ThreadLocal 저장 공간에 의존한다면 바람직하지 않은 부작용을 만들 수 있어요.
대표적인 예가 Spring Framework의 트랜잭션 테스트 지원이에요. Spring의 테스트 지원은 테스트 메서드가 호출되기 전에 트랜잭션 상태를 현재 스레드의 ThreadLocal에 바인딩하죠. 그래서 assertTimeoutPreemptively()에 넘긴 executable이나 supplier가 트랜잭션에 참여하는 Spring 관리 컴포넌트를 호출하면, 그 컴포넌트가 수행한 동작은 테스트 트랜잭션과 함께 롤백되지 않아요. 오히려 테스트 트랜잭션이 롤백되는데도 영속 저장소(예: 관계형 DB)에 커밋돼 버리죠.
ThreadLocal 저장 공간에 의존하는 다른 프레임워크에서도 이와 비슷한 부작용을 만날 수 있어요.
Kotlin 어서션 지원
JUnit Jupiter에는 Kotlin에서 쓰기 좋은 assertion 메서드도 몇 가지 있어요. Kotlin assertion은 모두 org.junit.jupiter.api 패키지의 최상위(top-level) 함수예요.
import example.domain.Person
import example.util.Calculator
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertAll
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.assertInstanceOf
import org.junit.jupiter.api.assertNotNull
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.assertTimeout
import org.junit.jupiter.api.assertTimeoutPreemptively
import java.time.Duration
class KotlinAssertionsDemo {
private val person = Person("Jane", "Doe")
private val people = setOf(person, Person("John", "Doe"))
@Test
fun `exception absence testing`() {
val calculator = Calculator()
val result =
assertDoesNotThrow("Should not throw an exception") {
calculator.divide(0, 1)
}
assertEquals(0, result)
}
@Test
fun `expected exception testing`() {
val calculator = Calculator()
val exception =
assertThrows<ArithmeticException> ("Should throw an exception") {
calculator.divide(1, 0)
}
assertEquals("/ by zero", exception.message)
}
@Test
fun `grouped assertions`() {
assertAll(
"Person properties",
{ assertEquals("Jane", person.firstName) },
{ assertEquals("Doe", person.lastName) }
)
}
@Test
fun `grouped assertions from a stream`() {
assertAll(
"People with first name starting with J",
people
.stream()
.map {
// This mapping returns Stream<() -> Unit>
{ assertTrue(it.firstName.startsWith("J")) }
}
)
}
@Test
fun `grouped assertions from a collection`() {
assertAll(
"People with last name of Doe",
people.map { { assertEquals("Doe", it.lastName) } }
)
}
@Test
fun `timeout not exceeded testing`() {
val fibonacciCalculator = FibonacciCalculator()
val result =
assertTimeout(Duration.ofMillis(1000)) {
fibonacciCalculator.fib(14)
}
assertEquals(377, result)
}
@Test
fun `timeout exceeded with preemptive termination`() {
// The following assertion fails with an error message similar to:
// execution timed out after 10 ms
assertTimeoutPreemptively(Duration.ofMillis(10)) {
// Simulate task that takes more than 10 ms.
Thread.sleep(100)
}
}
@Test
fun `assertNotNull with a smart cast`() {
val nullablePerson: Person? = person
assertNotNull(nullablePerson)
// The compiler smart casts nullablePerson to a non-nullable object.
// The safe call operator (?.) isn't required.
assertEquals(person.firstName, nullablePerson.firstName)
assertEquals(person.lastName, nullablePerson.lastName)
}
@Test
fun `assertInstanceOf with a smart cast`() {
val maybePerson: Any = person
assertInstanceOf<Person>(maybePerson)
// The compiler smart casts maybePerson to a Person object,
// allowing to access the Person properties.
assertEquals(person.firstName, maybePerson.firstName)
assertEquals(person.lastName, maybePerson.lastName)
}
}
서드파티 assertion 라이브러리
JUnit Jupiter가 제공하는 assertion 기능은 많은 테스트 시나리오에서 충분해요. 하지만 더 강력하고 추가적인 기능이 필요할 때가 있어요. 그럴 때 JUnit 팀은 AssertJ, Hamcrest, Truth 같은 서드파티 assertion 라이브러리 사용을 권장해요. 개발자는 자신이 선호하는 assertion 라이브러리를 자유롭게 쓰면 되죠.
예를 들어 JUnit Jupiter 테스트에서 AssertJ의 assertThat()을 쓰는 방법은 다음과 같아요. AssertJ 라이브러리가 클래스패스에만 추가되어 있으면, org.assertj.core.api.Assertions에서 assertThat(), assertThatException() 같은 메서드를 정적으로 임포트해서 쓸 수 있어요.
import static org.assertj.core.api.Assertions.assertThat;
import example.util.Calculator;
import org.junit.jupiter.api.Test;
class AssertJAssertionsDemo {
private final Calculator calculator = new Calculator();
@Test
void assertWithAssertJ() {
assertThat(calculator.subtract(4, 1)).isEqualTo(3);
}
}
프로젝트 클래스패스에서 Jupiter의 Assertions 빼기
프로젝트의 모든 테스트가 Jupiter 대신 특정 서드파티 assertion 라이브러리를 쓰도록 강제하고 싶다면, Jupiter의 Assertions 클래스가 사용되면 빌드가 실패하도록 Checkstyle이나 다른 정적 분석 도구로 규칙을 설정할 수 있어요.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE module PUBLIC "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN" "https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<property name="severity" value="error" />
<module name="TreeWalker">
<module name="com.puppycrawl.tools.checkstyle.checks.regexp.RegexpSinglelineJavaCheck">
<property name="id" value="jupiterAssertions"/>
<property name="maximum" value="0"/>
<property name="format" value="org\.junit\.jupiter\.api\.(Assertions|Assumptions)\."/>
<property name="message" value="Jupiter Assertions/Assumptions should not be used in this project. Please use ... instead."/>
<property name="ignoreComments" value="true"/>
</module>
</module>
</module>
더 알아보기
- JUnit 5 애노테이션 —
@Test와 라이프사이클 애노테이션 개요 - JUnit 5 파라미터화 테스트 — 여러 인자로 테스트를 반복 실행하는 방법
- JUnit 5 태깅과 필터링 —
@Tag로 테스트를 분류하고 실행 대상을 좁히는 방법