055. (Getting Started with Test-Driven Development) 5. JUnit 5 기초

5. JUnit 5 기초

여태까지 테스트 코드 작성에 JUnit Framework가 사용되었다.

주로 사용한 것은 테스트 메서드를 의미하는 어노테이션인 @Test와 검증을 위한 assertEquals() 메서드였다.

JUnit울 좀 더 잘 사용하기 위한 몇몇 방법을 알아보도록 하자.

참고 JUnit 4의 내용과, JUnit 4 대비 JUnit 5의 변경사항 등은 별도 포스팅으로 다룬다.

5.1. JUnit 5 모듈 구성

JUnit 5는 크게 3개의 요소로 구성되어 있다.

  1. JUnit Platform : 테스팅 프레임워크를 구동하기 위한 런쳐와 테스트 엔진을 위한 API를 제공한다.
  2. JUnit Jupiter : JUnit 5를 위한 테스트 API와 실행 엔진을 제공한다.
  3. JUnit Vintage : JUnit 3과 4로 작성된 테스트를 JUnit 5 플랫폼에서 실행하기 위한 모듈을 제공한다.

이 요소들의 주요 모듈 구조는 아래와 같다.

Architecture of Junit 5

JUnit 5는 테스트를 위한 API로 Jupiter Api를 제공하며, 이 API를 사용해서 테스트를 작성하고 실행할 수 있다.

아래는 IntelliJ IDE에서 gradle을 사용하는 모듈에 적용된 의존성 내역이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<orderEntry type="module-library">
<library name="junit.jupiter" type="repository">
<properties maven-id="org.junit.jupiter:junit-jupiter:5.10.2" />
<CLASSES>
<root url="jar://$MAVEN_REPOSITORY$/org/junit/jupiter/junit-jupiter/5.10.2/junit-jupiter-5.10.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/junit/jupiter/junit-jupiter-api/5.10.2/junit-jupiter-api-5.10.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/opentest4j/opentest4j/1.3.0/opentest4j-1.3.0.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/junit/platform/junit-platform-commons/1.10.2/junit-platform-commons-1.10.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/apiguardian/apiguardian-api/1.1.2/apiguardian-api-1.1.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/junit/jupiter/junit-jupiter-params/5.10.2/junit-jupiter-params-5.10.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/junit/jupiter/junit-jupiter-engine/5.10.2/junit-jupiter-engine-5.10.2.jar!/" />
<root url="jar://$MAVEN_REPOSITORY$/org/junit/platform/junit-platform-engine/1.10.2/junit-platform-engine-1.10.2.jar!/" />
</CLASSES>
<JAVADOC />
<SOURCES />
</library>
</orderEntry>

junit-jupiter 모듈은 junit-jupiter-api 모듈과 junit-jupiter-params 모듈, junit-jupiter-engine 모듈 등을 추가로 포함하고 있음을 알 수 있다.

5.2. @Test 어노테이션과 테스트 매서드

JUnit 모듈을 설정했다면 테스트 코드를 작성하고 실행할 수 있다.

여태까지 작성해왔듯이 테스트로 사용할 클래스를 만들고 @Test 어노테이션을 추가하기만 하면 된다.

참고 @Test 어노테이션을 붙이는 경우 메서드의 접근자가 private일 수 없다.

1
2
3
4
5
6
7
8
9
10
11
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class SumTest {

@Test
fun sum() {
val result = 2 + 3
assertEquals(5, result)
}
}

테스트 클래스의 이름을 작성하는 특별한 규칙은 없지만 보편적으로 테스트 대상인 클래스에 Test를 접미사로 붙인다.

JUnit의 Assertions 클래스는 assertEquals() 메서드와 같이 값을 검증하기 위한 목적의 다양한 정적 메서드를 제공한다.

테스트를 실행하는 메서드는 JUnit이 제공하는 검증 메서드를 이용해서 결과를 확인한다.

5.3. 주요 단언 메서드

Assertions 클래스에서 제공하는 주요 단언 메서드는 아래와 같다.

메서드 설명
assertEquals(expected, actual) 결과값 actual이 기대값 expected와 같은지 검사한다.
assertNotEquals(expected, actual) 결과값 actual이 기대값 expected와 다른지 검사한다.
assertSame(Object expected, Object actual) 두 객체가 동일한 객체인지 검사한다.
assertNotSame(Object unexpected, Object actual) 두 객체가 동일하지않은 객체인지 검사한다.
assertTrue(boolean condition) 값이 true인지 검사한다.
assertFalse(boolean condition) 값이 false인지 검사한다.
assertNull(Object actual) 값이 null인지 검사한다.
assertNotNull(Object actual) 값이 null이 아닌지 검사한다.
fail() 테스트를 실패처리한다.
assert(Object actual) 값이 null이 아닌지 검사한다.
assertThrows(Class expectedType, Executable executable) executable을 실행한 결과로 지정한 타입의 예외가 발생하는지 검사한다.
assertDoesNotThrows(Executable executable) executable을 실행한 결과로 지정한 타입의 예외가 발생하는않는지 검사한다.

참고 좀 더 많은 단언 메서드들은 org.junit.jupiter.api.Assertions를 참고하자.

assertEquals() 메서드의 경우 주요 타입별로 파라미터를 받을 수 있게 정의되어있다.

아래는 실제 의존성을 통해 참조한 assertEquals() 메서드 정의들이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@API(
status = Status.STABLE,
since = "5.0"
)
public class Assertions {
// ...
public static void assertEquals(short expected, short actual) {
// ...
}
public static void assertEquals(Byte expected, Byte actual) {
// ...
}
public static void assertEquals(int expected, int actual) {
// ...
}
/// ...
assertThrows(IllegalArgumentException.class, () -> { });
}

그 중 객체 비교를 위한 메서드인 assertEquals(Object expected, Object actual)의 경우 아래와 같이 사용할 수 있다.

1
2
3
val dateTime1 = LocalDate.now()
val dateTime2 = LocalDate.now()
assertEquals(dateTime1, dateTime2)

fail() 메서드는 테스트에 실패했을음 알리고 싶을 때 사용한다.

아래는 그 예시이다.

1
2
3
4
5
6
7
try {
val authService = AuthService()
authService.authenticate(null, null) // 파라미터가 null이면 내부에서 IllegalArgumentException 발생
fail()
} catch (e: IllegalArgumentException) {

}

예외가 발생할 것으로 기대했는데, 발생하지않아서 fail() 메서드가 호출되면 기대대로 동작하지않음을 식별할 수 있다.

다만 검증 대상이 예외의 발생 여부라면 fail() 보다는 아래의 메서드를 쓰는 것이 좋다.

1
2
3
4
assertThrows(java.lang.IllegalArgumentException::class.java) {
val authService = AuthService()
authService.authenticate(null, null) // 파라미터가 null이면 내부에서 IllegalArgumentException 발생
}

asser* 계열 메서드는 실패하면 다음 코드를 실행하지않는다.

이를 무시하고 모든 테스트를 수행하려면 aasertAll()을 사용하면 된다.

1
2
3
4
5
assertAll(
{ assertEquals(3, 5 / 2) },
{ assertEquals(4, 2 * 2) },
{ assertEquals(6, 11 / 2) },
)

assertAll()을 쓰면 실패한 테스트 코드의 목록을 모아서 에러 메시지로 보여준다.

5.4. 테스트 라이프사이클

5.4.1. @BefroeAll 어노테이션과 @AfterAll 어노테이션

JUnit은 각 테스트 메서드마다 아래 순서대로 테스트 코드를 실행한다.

  1. 테스트 메서드를 포함한 객체 생성
  2. (존재하면) @BeforeEach 어노테이션이 붙은 메서드 실행
  3. @Test 어노테이션이 붙은 메서드 실행
  4. (존재하면) @AfterEach 어노테이션이 붙은 메서드 실행

자세한 내용은 아래 포스팅을 참고하자.

참고
1.@BeforeEach에 대한 이해 : (Pragmatic Unit Testing in Kotlin with JUnit) 2. 진짜 JUnit 테스트 코드 작성하기
2. 라이프사이클 관련 : (Pragmatic Unit Testing in Kotlin with JUnit) 4. 테스트 코드 구조화

5.5. 테스트 메서드 간 실행 순서 의존과 필드 공유하지 않기

아래 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BadTest {
private val op = FileOperator()
companion object {
var file: File? = null
}

@Test
fun fileCreationTest() {
var createdFile = op.createFile()
assertTrue(createdFile.length > 0)
file = createdFile
}

@Test
fun readFileTest() {
var data = op.readDate(file)
assertTrue(data > 0)
}
}

이 코드는 fileCreationTest() 메서드를 를 통해 파일을 생성한 뒤 file 프로퍼티에 저장해두고, readFileTest() 메서드에서 접근한다.

이러한 방식은 fileCreationTest() 메서드가 readFileTest() 메서드보다 무조건 먼저 실행되어야한다는 게 전제되어야 한다.

하지만 JUnit의 버전에 따라 테스트 순서가 달라질 수 있으므로 이는 버전별로 테스트의 결과가 다르게 동작하는 코드가 된다.

각 테스트 메서드는 서로 독립적으로 동작해야 하며, 한 테스트 메서드의 결과가 다른 테스트의 실행 결과에 영향을 주어선 안된다.

참고 JUnit에서 테스트 메서드의 실행 순서를 정하는 기능을 제공하고 있긴하지만, 원론적으로 테스트 메서드를 독립적으로 동작해야 한다.

5.6. 추가 어노테이션 : @DisplayName, @Disabled

만료일 계산 관련 테스트 코드중 하나를 가져와보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
@DisplayName("첫 납부일과 만료일 일자가 다를때 만원 납부")
fun paymentTenThousandWhenFirstBillingDateAndExpiryDateNotSameDay() {
assertExpiryDate(
PayData(
firstBillingDate = LocalDate.of(2019, 1, 31),
billingDate = LocalDate.of(2019, 2, 28),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 3, 31)
)

assertExpiryDate(
PayData(
firstBillingDate = LocalDate.of(2019, 1, 30),
billingDate = LocalDate.of(2019, 2, 28),
payAmount = 10_000,
),
expectedExpiryDate = LocalDate.of(2019, 3, 30)
)
}

@DisplayName 어노테이션에 한글로 어떤 테스트가 목적인지 작성되어있다.

이 어노테이션을 작성하면 테스트 결과가 테스트 메서드의 이름이 아닌 @DisplayName에 작성된 텍스트로 출력된다.

ExpiryDateCalculatorTest Result

특정 테스트 메서드를 실행하고 싶지 않을 때는 @Disabled 어노테이션을 사용한다.

이 어노테이션이 붙으면 테스트 대상에서 배제된다.

1
2
3
4
5
6
@Disabled
@Test
@DisplayName("만원_납부하면_한달_뒤가_만료일이_됨")
fun expirationDateAfterPayingTenThousand() {
// ...
}

위와 같이 추가하면 아래와 같이 테스트가 무시된다.

Disabled Annotation Result

5.7. 모든 테스트 실행하기

지금까지 특정 테스트 메서드나 테스트 클래스를 대상으로 실행해왔다.

하지만 배포를 앞두고 있거나, 코드를 푸시하기전에 모든 테스트를 수행하려면 아래와 같이 명령어를 사용하거나, IDE에서 수행하면 된다.

1
2
3
4
5
6
7
// maven
mvn test
mvnw test

// gradle
gradle test
gradlew test

Run All Test