039. (Pragmatic Unit Testing in Kotlin with JUnit) 3. JUnit 검증 딥 다이브

3. JUnit 검증 딥 다이브

원제 : Digging Deeper into JUnit Assertions

3.1. JUnit의 검증

원제 : Assertions in JUnit

JUnit에서 검증은 테스트 내부에서 호출할 수 있는 정적 메서드를 의미한다.

해당 검증 내의 조건이 참인지 거짓인지 검증하고 결과에 따라 테스트를 성공시키거나 실패시키는 역할을 한다.

참고 테스트 도중 예외가 발생하였을 때, 이를 처리하지않으면 실패가 아닌 오류(error)로 리폿된다.

JUnit은 크게 두 가지의 검증 스타일을 제공하는데

하나는 JUnit 자체에 포함된 전통적인 스타일의 검증 스타일이고, 나머지 하나는 Hamcrest 라이브러리를 이용한 상대적으로 세련된 방식의 스타일이다.

물론 두 가지 검증 스타일을 섞어 쓰는 것도 가능은 하지만 일반적으로 하나의 스타일로 통일해서 쓰는 것이 권장된다.

3.1.1. assertTrue

원제 : assertTrue

가장 기본적인 검증은 아래와 같은 조건이 참인지 판단해보는 것이다.

아래와 같은 형태로 JUnit 5의 assertTrue를 참조할 수 있다.

1
2
3
4
5
6
7
import org.junit.jupiter.api.Assertions.assertTrue

// ...

public static void assertTrue(boolean condition) {
AssertTrue.assertTrue(condition);
}

간단한 사용 예시는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
internal class AssertTest {

@Test
fun hasPositiveBalance() {
account.deposit(50)
assertTrue(account.hasPositiveBalance())
}

@Test
fun depositIncreasesBalance() {
val initialBalance = account.balance()
account.deposit(100)
assertTrue(account.balance > initialBalance)
}
}

aasertTrue()에 파라미터로 넘기는 조건이 참인지 거짓인지 판정하는 것임을 추측할 수 있다.

테스트 코드를 수행하기 위해 필요한 정보를 좀 더 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
internal class AssertTest {

private val account: Account

@BeforeEach
fun createAccount() {
account = Account("an account name")
}

@Test
fun hasPositiveBalance() {
account.deposit(50)
assertTrue(account.hasPositiveBalance())
}

@Test
fun depositIncreasesBalance() {
val initialBalance = account.balance()
account.deposit(100)
assertTrue(account.balance() > initialBalance)
}
}

@BeforeEach 어노테이션이 붙은 메서드를 작성하여 모든 테스트 코드의 전에 Account 객체를 생성하였다.

3.1.2. assertThat을 통한 명확한 값의 비교

원제 : assertThat Something Is Equal to Another Something

JUnit을 통한 단위 테스트에서 대부분의 검증은 기대하는 값 그리고 실제로 반환된 값을 비교한다.

예를 들어 특정 잔고가 “0보다 크다” 라고 비교하기 보다는 “100이 아니다” 라고 명시적으로 비교하는 것이 더 선호된다.

이때 hamcrest 라이브러리의 assertThat() 메서드를 사용하면 아래와 같이 작성할 수 있다.

1
assertThat(account.balance(), equalTo(100))

assertThat() 메서드는 첫 번째 매개변수로 실제 표현식(actual) 을, 두 번째 매개변수로 매처(matcher) 를 넘겨받아 두 매개변수의 결과를 비교한다.

이렇게 작성하는 방식은 테스트 코드를 실제 술어처럼 읽을 수 있기때문에 높은 가독성을 표현하는 데 도움을 준다.

아래 assertThat() 메서드의 구현부를 보면 이 의도를 좀 더 자세히 알 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static <T> void assertThat(T actual, Matcher<? super T> matcher) {
assertThat("", actual, matcher);
}

public static <T> void assertThat(String reason, T actual, Matcher<? super T> matcher) {
if (!matcher.matches(actual)) {
Description description = new StringDescription();
description.appendText(reason)
.appendText("\nExpected: ")
.appendDescriptionOf(matcher)
.appendText("\n but: ");
matcher.describeMismatch(actual, description);

throw new AssertionError(description.toString());
}
}

언뜻 보기에 assertTrue()assertThat()의 차이는 거의 없어보인다.

하지만 실제 테스트를 수행해서 실패하는 경우 단순히 실패로 처리하느냐, 좀 더 나은 정보를 출력해주느냐의 차이가 있다.

만약 계좌의 잔고가 101인 경우에 아래와 같이 테스트한다고 가정해보자.

1
assertTrue(account.balance() == 100)

101과 100은 다르기 때문에 테스트는 실패하며 아래와 같은 메시지를 출력한다.

1
2
3
4
5
expected: <true> but was: <false>
Expected :true
Actual :false

org.opentest4j.AssertionFailedError: expected: <true> but was: <false>

이번엔 assertThat()을 사용해보자.

1
assertThat(account.balance(), equalTo(100))

이 테스트도 실패하며 아래와 같은 메시지를 출력한다.

1
2
3
4
5
Expected: <100>
but: was <101>
java.lang.AssertionError:
Expected: <100>
but: was <101>

3.1.3. 주요 Hamcrest Matcher 살펴보기

원제 : Rounding Out the Important Hamcrest Matchers

Hamcrest 라이브러리내 CoreMatcher 클래스는 바로 매처 작업을 할 수 있도록 도구를 제공해준다.

제공해주는 모든 메서드는 아래 링크를 참고하면 된다.

참고 Hamcrest - CoreMatchers.java

배열 검증

1
2
assertThat(arrayOf("a", "b", "c"), equalTo(arrayOf("a", "b"))) // fail
assertThat(arrayOf("a", "b", "c"), equalTo(arrayOf("a", "b", "c"))) // success

컬렉션 검증

is

1
assertThat(account.name, `is`(equalTo("an account name")))

not

1
assertThat(account.name, `is`(not(equalTo("an different account name"))))

이렇듯 JUnit에 Hamcrest 라이브러리를 사용하는 경우 아래와 같은 방식의 테스트를 진행할 수 있다.

  1. 객체의 타입을 검증한다.
  2. 두 객체의 참조가 같은 인스턴스인지 검증한다.
  3. 다수의 매처를 결합하여 둘 다 혹은 둘 중에 어던 것이든 성공하는 지 검증한다.
  4. 어떤 컬렉션이 특정 요소를 포함하거나 조건에 부합하는 지 검증한다.
  5. 어떤 컬렉션이 아이템 몇 개를 모두 포함하는 지 검증한다.
  6. 어떤 컬렉션에 있는 모든 요소가 매처를 준수하는 지 검증한다.

3.1.4. 부동소수점 비교 검증

원제 : Comparing Two Floating-Point Numbers

컴퓨터에서 부동소수점은 정확한 값이 아닌 근사치로 표현된다.

근사치로 표현하는 경우 테스트의 때로는 성공하고 때로는 실패하는 등의 결과로 이어질 수 있다.

아래와 같은 테스트 코드가 있다고 가정해보자.

1
assertThat(2.32 * 3, equalTo(6.96))

2.32 * 3은 6.96이므로 테스트는 성공할 것이라고 기대된다.

하지만 실제로 테스트를 수행하면 아래와 같은 메시지를 출력하며 실패하게 된다.

1
2
3
4
5
Expected: <6.96>
but: was <6.959999999999999>
java.lang.AssertionError:
Expected: <6.96>
but: was <6.959999999999999>

즉 우리가 검증하려는 대상이 Float 혹은 Double로 표현되는 부동소수점인 경우 두 부동소수점 사이의 공차나 허용 오차를 지정해야한다.

이를 위해 테스트 코드를 아래와 같이 바꿔보자.

1
assertTrue((2.32 * 3 - 6.96).absoluteValue < 0.0005)

이제 테스트는 성공한다.

다만 우리는 가독성을 잃어버리게 되었다.

잃어버린 가독성을 되찾기위해 Hamcrest를 활용해보자.

1
assertThat(2.32 * 3, closeTo(6.96, 0.0005))

위와 같이 closeTo() 매처를 사용하면 가독성도 확보할 수 있다.

3.1.5. 검증에 대한 명세

원제 : Explaining Asserts

위에서 언급한 assertThat()에 대한 구현체를 다시 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static <T> void assertThat(T actual, Matcher<? super T> matcher) {
assertThat("", actual, matcher);
}

public static <T> void assertThat(String reason, T actual, Matcher<? super T> matcher) {
if (!matcher.matches(actual)) {
Description description = new StringDescription();
description.appendText(reason)
.appendText("\nExpected: ")
.appendDescriptionOf(matcher)
.appendText("\n but: ");
matcher.describeMismatch(actual, description);

throw new AssertionError(description.toString());
}
}

모든 assertThat()은 결국 assertThat(String reason, T actual, Matcher<? super T> matcher)을 호출하고 있는데

여기서 첫 번째 매개변수인 reason을 확인할 수 있다.

reason은 검증의 근거를 설명해주는 역할을 수행한다.

1
2
3
4
5
@Test
fun testWithWorthlessAssertionComment() {
account.deposit(50)
assertThat("account balance is 100", account.balance(), equalTo(50))
}

위와 같이 reason을 추가하면 검증의 근거를 확인할 수도 있다.

다만 테스트 메서드의 이름만 보고도 알 수 있게 작성하는 것이 더욱 좋은 방향이다.

3.2. 예외를 예상하는 세 가지 방법

원제 : Three Schools for Expecting Exceptions

모든 코드가 해피 케이스만 보장되면 좋겠지만, 예외를 던지는 경우도 많다.

예를 들어 클라이언트가 잔고보다 많은 돈을 인출하려는 경우에 예외를 던지는 것을 생각해볼 수 있다.

이때 JUnit을 이용하면 세 가지 방법으로 예외를 던지는 것을 명시할 수 있다.

3.2.1. 어노테이션을 활용하는 방법

원제 : Simple School: Using an Annotation

어노테이션으로 예외 발생을 기대하게할 수도 있다.

아래 예제를 보자.

1
2
3
4
@Test(expected = InsufficientFundsException::class)
fun throwsWhenWithdrawingTooMuch() {
account.withdraw(100)
}

위 테스트 코드에서 InsufficientFundsException 예외가 발생하는 경우에는 기대된 동작이므로 테스트는 성공한다.

3.2.2. try-catch를 활용하는 방법

원제 : Old School: Try and Fail-or-Catch

전통적인 예외처리 방식인 try-catch를 활용하는 것도 방법이다.

1
2
3
4
5
6
7
8
9
@Test(expected = NullPointerException::class)
fun throwsWhenWithdrawingTooMuch() {
try {
account.withdraw(100)
fail()
} catch(e: InsufficientFundsException) {

}
}

만약 예외가 발생하는 경우 상태를 검증하려는 경우 아래와 같이 처리하는 것도 방법이다.

1
2
3
4
5
6
7
8
9
@Test(expected = NullPointerException::class)
fun throwsWhenWithdrawingTooMuch() {
try {
account.withdraw(100)
fail()
} catch(e: InsufficientFundsException) {
asssertThat(e.message, equalTo("balance only 0"))
}
}

3.2.3. ExpectedException을 활용하는 방법

원제 : New School: ExpectedException Rules

JUnit은 커스텀 규칙을 정의하여 테스트 실행 도중 발생하는 일에 대한 통제권을 강화할 수 있다.

특히 ExpectedException을 활용하면 예외를 검사하는 데 편리하다.

이제 새롭게 만들어 잔고가 0인 계좌에서 돈을 인출하는 테스트를 설계해보자.

1
2
3
4
5
6
7
8
9
10
@Rule
val thrown = ExpectedException.none()

@Test
fun exceptionRule() {
thrown.expect(InsufficientFundsException::class.java)
thrown.expectMessage("balance only 0")

account.withdraw(100)
}

thrown 규칙 인스턴스는 InsufficientFundsException 예외가 발생함을 알려주는 규칙이 된다.

3.2.4. 예외 무시

원제 : Exceptions Schmexceptions

개발자가 작성하는 테스트는 대부분 해피 패스인 경우가 많다.

하지만 검증된 예외를 처리할 수 있도록 테스트 코드를 작성하여 좀 더 안정적인 애플리케이션을 개발하는 것이 중요하다.

결론적으로 검증된 예외를 처리하려고 try-catch 블록을 수행하는 것보다 다른 예외를 던지도록 하여 검증하는 것이 좋다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
@Test
fun readsFromTestFile() throws IOException {
val fileName = "test.txt"
val writer = BufferredWriter(FileWriter(fileName))
writer.write("test data")
writer.close()
// ...
}

위와 같은 테스트 코드처럼 예외를 처리하지않고 발생하게 하는 것이 오히려 테스트 코드를 통한 애플리케이션의 강건성 확보에 더욱 도움이 될 것이다.