042. (Pragmatic Unit Testing in Kotlin with JUnit) 6. Right-BICEP 원칙

6. Right-BICEP 원칙

원제 : What to Test: The Right-BICEP

6.1. Right-BICEP

이전 포스팅의 FIRST 원칙이 좋은 테스트를 작성하는 방법에 대한 것이라면

이무엇을 테스트하는 것이 중요한지 도와주는 지침도 있다.

이른바 Right-BICEP 원칙이다.

  • Right : 결과가 올바른가?
  • Boundary Conditions : 경계 조건은 맞는가?
  • Inverse Relationships : 역관계는 검사할 수 있는가?
  • Cross-Checking : 다른 수단을 활용해 교차 검증할 수 있는가?
  • Error Conditions : 오류 조건을 강제로 발생시킬 수 있는가?
  • Performance : 성능 조건은 기준에 부합하는가?

이번 포스팅에서는 각 원칙들에 대해서 알아보도록 하자.

6.2. Right

원제 : [Right]-BICEP: Are the Results Right?

테스트 코드는 무엇보다 기대 결과는 산출하고 이를 검증할 수 있어야 한다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
fun answersArithmeticMeanOfTwoNumbers() {
val collection = ScoreCollection()
with(collection) {
add(object : Scoreable {
override fun getScore() = 5
})
add(object : Scoreable {
override fun getScore() = 7
})
}

val actualResult = collection.arithmeticMean()

assertThat(actualResult, equalTo(6))
}

1장에서 다룬 테스트 코드를 다시 가져와 보았다.

collection에 더 많은 숫자나 더 큰 수를 넣어 테스트를 좀 더 강하게 만들 수도 있겠지만, 결국엔 해피 케이스의 범주 안에 속하기에 특별한 의미를 가지기 어렵다.

해피 케이스의 테스트 목적은 “정상적인 코드의 동작이 테스트의 성공을 보장한다” 이며, 이 테스트가 실패했을 때 코드의 동작에 이상이 생겼다는 지표로 삼으면 되는 것이다.

6.3. Boundary Conditions

원제 : Right-[B]ICEP: Boundary Conditions

극단적인 입력 값을 가진 코너 케이스는 해피 케이스에선 발견하기 어렵다.

따라서 테스트 코드를 통해 처리해야한다.

이때 우리가 생각할 수 있는 경졔 조건은 아래와 같다.

  • 모호하고 일관성이 없는 입력 값 : !*W:X\&Gi/w$→>$g/h#WQ@
  • 잘못된 포맷의 데이터 : fred@foobar.
  • 오버플로우를 발생시키는 수를 다루는 연산
  • 값이 없거나 비어있는 경우 : 0, 0.0, "", null
  • 현실적 혹은 상식적인 기댓값을 벗어나는 값 : person’s age of 150 years.
  • 중복을 허용하면 안되는 곳에 존재하는 중복 값
  • 정렬이 안 된 정렬 리스트
  • 정렬된 값들로 구성된 정렬되지 않은 리스트
  • 정렬 알고리즘에 이미 정렬된 값을 주입하거나, 의도적으로 알고리즘의 worst case를 유도하는 값을 주입하는 경우
  • 기능의 선행, 후행이 지켜지지 않는 경우

이제 예제를 통해 파악해보기위해 1장의 ScoreCollection 코드를 다시 가져오자.

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

private val scores = arrayListOf<Scoreable>()

fun add(scoreable: Scoreable) {
scores.add(scoreable)
}

fun arithmeticMean(): Int {
val total = scores
.map(Scoreable::getScore)
.sum()

return total / scores.size
}
}

만약 scores 리스트에 아무 원소도 없다면 어떻게 될까?

1
total / scores.size

위의 코드에서 아래와 같은 오류가 발생하게 될 것이다.

1
java.lang.ArithmeticException: / by zero

먼저 테스트 코드를 통해 0으로 나누는 시나리오에 대해서 방어하도록 하자.

1
2
3
4
5
6
@Test
fun answersZeroWhenNoElementsAdded() {
val collection = ScoreCollection()

assertThat(collection.arithmeticMean(), equalTo(0))
}

이후 실제 코드를 보강한다.

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

private val scores = arrayListOf<Scoreable>()

fun add(scoreable: Scoreable) {
scores.add(scoreable)
}

fun arithmeticMean(): Int {
if (scores.size == 0) {
return 0
}

val total = scores
.map(Scoreable::getScore)
.sum()

return total / scores.size
}
}

이제 scores가 가진 원소가 없는 경우의 테스트는 성공이다.

반대로 표현 범위를 넘어서는 경우엔 어떻게 될까?

이번에도 테스트 코드를 먼저 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
fun dealsWithIntegerOverflow() {
val collection = ScoreCollection()
with(collection) {
add(object : Scoreable {
override fun getScore() = Int.MAX_VALUE
})
add(object : Scoreable {
override fun getScore() = 1
})
}

assertThat(collection.arithmeticMean(), equals(1_073_741_824))
}

Int의 표현 범위를 벗어나면 값이 의도와는 달라지므로, 표현 범위를 바꿔주기 위해 아래와 같은 조치를 취할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ScoreCollection {

private val scores = arrayListOf<Scoreable>()

fun add(scoreable: Scoreable) {
scores.add(scoreable)
}

fun arithmeticMean(): Int {
if (scores.size == 0) {
return 0
}
val total = scores
.map { it.getScore().toLong() }
.sum()

return (total / scores.size).toInt()
}
}

표현 범위가 더 넓은 Long으로 캐스팅 후 결과값을 다시 Int로 바꾸는 방식이다.

이는 결과값이 절대 Int의 표현 범위를 넘을 수 없는 구조기에 가능한 방식이다.

6.3.1. CORRECT 기억법

원제 : Remembering Boundary Conditions with CORRECT

경계 조건을 쉽게 기억할 수 있는 CORRECT 기억법을 소개한다.

  • Conformance : 값이 기대한 양식을 준수하고 있는가?
  • Ordering : 값의 집합이 적절하게 정렬되었는가? (혹은 정렬되지않았는가?)
  • Range : 이성적인 최솟값과 최대값의 범위안에 속하는가?
  • Reference : 코드 자체에서 통제할 수 없는 외부 참조를 포함하고 있는가?
  • Existence : 값이 존재하는가? (Non-Null), 값이 0이 아닌가?, 값이 비어있는가?
  • Cardinality : 정확히 충분한 값들이 있는가?
  • Time : 모든 것이 의도된 순서대로 발생했는가?

6.4. Inverse Relationships

원제 : Right-B[I]CEP: Checking Inverse Relationships

때때로 논리적인 역 관계를 적용하여 행위를 검사할 수 있다.

예를 들어 곱셈 행위를 나눗셈으로 검증하거나, 덧셈 행위를 뺄셈으로 검증하는 것이 이에 해당한다.

이 역관계를 이해하기 위해 뉴턴의 제곱근 알고리즘을 구현해보자.

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

companion object {
const val TOLERANCE = 1E-16

fun squareRoot(n: Double): Double {
var approx = n
while (abs(approx - n / approx) > TOLERANCE * approx) {
approx = (n / approx + approx) / 2.0
}
return approx
}
}
}

테스트 코드는 아래와 같다.

1
2
3
4
5
6
@Test
fun squareRoot() {
val result = Newton.squareRoot(250.0)

assertThat(result * result, closeTo(250.0, Newton.TOLERANCE))
}

입력값으로 주어진 250.0의 제곱근을 구한 뒤, 이 제곱근의 제곱이 250.0에 근사하는 지 검증한다.

6.5. Cross-Checking

원제 : Right-BI[C]EP: Cross-Checking Using Other Means

하나의 문제를 해결하는 솔루션은 여러 개 존재할 수 있다.

물론 우리는 가장 최적의 솔루션 하나를 선택하여 개발을 진행하지만, 탈락한 솔루션을 교차검증에 쓸 수가 있다.

위의 뉴턴 제곱근 공식과 표준 라이브러리에서 제공하는 제곱근 메서드를 활용해 교차 검증한다면 아래와 같다.

1
2
3
4
5
6
7
@Test
fun squareRoot() {
val result = Newton.squareRoot(250.0)

// assertThat(result * result, closeTo(250.0, Newton.TOLERANCE))
assertThat(result, closeTo(sqrt(250.0), Newton.TOLERANCE))
}

6.6. Error Conditions

원제 : Right-BIC[E]P: Forcing Error Conditions

해피 케이스가 아닌 엣지 케이스도 테스트 대상이다.

엣지 케이스를 테스트한다는 것은 비정상적인 상황을 테스트해야 함을 말하며,

이를 테스트 코드로 표현하기 위해선 비정상적인 동작을 재현해야한다는 것을 의미한다.

비정상적인 동작의 예시는 아래와 같다.

  • 메모리가 가득 찬 경우
  • 디스크 공간이 가득 찬 경우
  • 서버와 클라이언트의 시간대가 다른 경우
  • 네트워크 약전계
  • 시스템 과부하
  • 제한된 색상 팔레트
  • 너무 높거나 낮은 해상도

위와 같은 케이스도 커버한다면 좋은 단위 테스트에 한 걸음 더 다가갈 수 있을 것이다.

6.7. Performance

원제 : Right-BICE[P]: Performance Characteristics

많은 개발자들은 성능 문제를 해결하기 위해 병목 지점을 찾고, 최적해가 무엇인지 추측한다.

이때 이 추측이 잘못되는 경우가발생할 수가 있다.

따라서 추측을 믿고 바로 프로덕션 코드를 수정하기보다는, 단위 테스트를 설계하여 추측이 맞는지 검증하는 것이 좋다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Test
fun findAnswers() {
val dataSize = 5000
for (i in 0 until dataSize) {
profile.add(Answer(BooleanQuestion(i, "$i"), false))
}
profile.add(Answer(PercentileQuestion(dataSize, "$dataSize", arrayOf<String>()), 0))

val numberOfTimes = 1000
val elapsedMs = running(numberOfTimes) {
profile.find { a ->
a.question()::class.java == PercentileQuestion::class.java
}
}

assertTrue(elapsedMs < 1000)
}

fun running(times: Int, func: Runnable): Long {
val start = System.nanoTime()
for (i in 0 until times) {
func.run()
}
val stop = System.nanoTime()
return (stop - start) / 1000000
}

위 테스트는 특정 코드가 정해진 시간 안에 수행되는 지 검증한다.

그렇다면 1초안에 1000번 실행이 안되면 어떻게 되는 걸까?

이 성능이란 부분은 너무 많은 것이 임의의 조건으로 이루어져 있다.

이처럼 단위 성능 측정시 기준점을 잘 정하고, 최적화를 수행하는 것이다.

최적화 이전의 N번의 테스트에 대한 평균값을 기준으로 기준점을 정한 뒤, 최적화가 정말 의미있는지 비교하는 식이다.