041. (Pragmatic Unit Testing in Kotlin with JUnit) 5. FIRST 원칙

5. FIRST 원칙

원제 : FIRST Properties of Good Tests

시간을 들여 작성한 테스트가 아래와 같은 결과를 보인다면, 테스트 코드의 유지보수 비용을 더 투입하게 되는 악순환이 벌어진다.

  • 테스트를 사용하는 사람에게 어떤 정보도 주지 못하는 테스트
  • 산발적으로 실패하는 테스트
  • 어떤 가치도 증명하지 못하는 테스트
  • 실행에 오랜 시간이 걸리는 테스트
  • 프로덕션 코드를 충분히 커버하지 못하는 테스트
  • 구현 세부사항과 강결합되어, 작은 변경에도 깨니는 테스트
  • 수많은 설정 고리로 점프하는 난해한 테스트

본 포스팅에서는 위와 같은 테스트를 작성하지않기위한 핵심 개념을 익혀보도록 하자.

5.1. FIRST

원제 : FIRST It Helps to Remember That Good Tests Are FIRST

가치있는 단위 테스트를 작성하기 위해서 아래 다섯가지 핵심 개념을 머릿속에 넣어두도록 하자.

흔히 단위테스트의 FIRST 원칙이라고 불리는 것들이다.

  • Fast : 단위 테스트는 빠르게 실행되고, 결과도 빠르게 도출되어야 한다.
  • Isolate 혹은 Independent : 단위테스트는 격리되어, 그 자체로 독립적으로 실행되어야 한다.
  • Repeatable : 단위 테스트는 반복해서 실행할 수 있어야 한다.
  • Self-validating : 단위 테스트는 자체 검증이 가능해야 한다.
  • Timely 혹은 Thorough : 단위 테스트는 적절한 시기에 작성되어야 한다.

각 원칙별로 자세히 알아보도록 하자.

5.2. Fast

원제 : [F]IRST: [F]ast!

단위 테스트가 빠르다 혹은 느리다는 마땅한 기준점이 없기에 딱 잘라서 말하기 어려운 부분이다.

일반적으로 빠른 테스트 코드는 단위 테스트 내의 코드만 실행하여, 수 밀리초 내에 테스트를 완료한다.

반면 느린 테스트 코드는 데이터베이스나 파일 혹은 네트워크처럼 외부 자원이나 프로세스를 참조하기에 얼마든지 상대적으로 오래 걸리게 된다.

시스템이 커질수록 테스트 코드도 증가할 것이고 이에 비례하여 테스트 코드를 수행하는 총 시간도 증가하게 될 것이다.

따라서 각자만의 기준을 정해두고, 테스트 코드의 빠른 피드백에도 신경 쓰는 것이 바람직하다.

이번엔 예제를 한 번 살펴보자.

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class StatCompiler {
private val controller = QuestionController()

fun responsesByQuestion(
answers: List<BooleanAnswer>
): Map<String, Map<Boolean, AtomicInteger>> {
val responses = mutableMapOf<Int, Map<Boolean, AtomicInteger>>()
answers.forEach {
incrementHistogram(responses, it)
}
return convertHistogramIdsToText(responses)
}

private fun convertHistogramIdsToText(
responses: Map<Int, Map<Boolean, AtomicInteger>>
): Map<String, Map<Boolean, AtomicInteger>> {
val textResponses = mutableMapOf<String, Map<Boolean, AtomicInteger>>()
responses.keys.forEach {
textResponses[controller.find(it).text()] = responses[it]!!
}
return textResponses
}

private fun incrementHistogram(
responses: MutableMap<Int, Map<Boolean, AtomicInteger>>,
answer: BooleanAnswer
) {
val histogram = getHistogram(responses, answer.questionId())
histogram[answer.toBoolean()]?.getAndIncrement()
}

private fun getHistogram(
responses: MutableMap<Int, Map<Boolean, AtomicInteger>>,
id: Int
): Map<Boolean, AtomicInteger> {
return if (responses.containsKey(id)) {
responses[id]!!
} else {
val histogram = createNewHistogram()
responses[id] = histogram
histogram
}
}

private fun createNewHistogram(): Map<Boolean, AtomicInteger> {
return mutableMapOf<Boolean, AtomicInteger>().apply {
put(false, AtomicInteger(0))
put(true, AtomicInteger(0))
}
}
}

위의 코드에서 responsesByQuestion() 메서드에 대한 테스트를 작성하려고 한다.

이 메서드는 주어진 질문에 대해 true와 false 답변이 담긴 histogram을 반환한다.

코드를 살펴보면 결국 convertHistogramIdsToText() 메서드를 호출한 결과를 반환하게 되고,

convertHistogramIdsToText() 호출하면 find() 연산으로 인한 데이터베이스 접근으로 테스트가 느려질 여지가 생긴다.

제품 코드를 먼저 수정해보자.

먼저 질문에 대한 id값과 질문 내용을 생성하는 questionText() 메서드를 작성한다.

1
2
3
4
5
6
7
8
9
10
11
fun questionText(
answers: List<BooleanAnswer>
): Map<Int, String> {
val questions = mutableMapOf<Int, String>()
answers.forEach {
if (questions.containsKey(it.questionId()).not()) {
questions[it.questionId()] = controller.find(it.questionId()).text()
}
}
return questions
}

이후 responsesByQuestion() 메서드에 질문 id값과 내용을 매핑하도록 파라미터를 추가한다.

1
2
3
4
5
6
7
8
9
10
fun responsesByQuestion(
answers: List<BooleanAnswer>,
questions: Map<Int, String> // HERE
): Map<String, Map<Boolean, AtomicInteger>> {
val responses = mutableMapOf<Int, Map<Boolean, AtomicInteger>>()
answers.forEach {
incrementHistogram(responses, it)
}
return convertHistogramIdsToText(responses, questions) // HERE
}

convertHistogramIdsToText() 메서드에도 questions를 넘겨주게끔 수정한다.

1
2
3
4
5
6
7
8
9
10
11
private fun convertHistogramIdsToText(
responses: Map<Int, Map<Boolean, AtomicInteger>>,
questions: Map<Int, String>
): Map<String, Map<Boolean, AtomicInteger>> {
val textResponses = mutableMapOf<String, Map<Boolean, AtomicInteger>>()
responses.keys.forEach {
// textResponses[controller.find(it).text()] = responses[it]!!
textResponses[questions[it]!!] = responses[it]!!
}
return textResponses
}

convertHistogramIdsToText() 메서드에 있던 controller에 대한 종속이 제거되었다.

이제 데이터베이스에 대한 접근이 없으므로 보다 빠른 피드백을 얻을 수 있는 테스트 코드를 작성할 수 있게 되었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
fun responsesByQuestionAnswersCountsByQuestionText() {
val stats = StatCompiler()
val answers = mutableListOf(
BooleanAnswer(1, true),
BooleanAnswer(1, true),
BooleanAnswer(1, true),
BooleanAnswer(1, false),
BooleanAnswer(2, true),
BooleanAnswer(2, true),
)
val questions = mutableMapOf<Int, String>()
questions[1] = "Tuition reimbursement?"
questions[2] = "Relocation package?"

val responses = stats.responsesByQuestion(answers, questions)

assertThat(responses["Tuition reimbursement?"][true], `is`(equalTo(3)))
assertThat(responses["Tuition reimbursement?"][false], `is`(equalTo(1)))
assertThat(responses["Relocation package?"][true], `is`(equalTo(2)))
assertThat(responses["Relocation package?"][false], `is`(equalTo(0)))

}

이처럼 테스트 코드의 빠른 동작을 보장하고, 외부 의존성을 최대한 걷어낸다면 빠른 피드백은 물론이고,

좋은 설계에 한 걸음 더 다가갈 수 있게된다.

5.3. Isolate

원제 : F[I]RST: [I]solate Your Tests

좋은 단위 테스트는 검증하려는 작은 코드에 집중해야한다.

직접적이든 간접적이든 테스트 코드와 상호작용하는 포인트가 늘어날수록 문제가 발생할 여지도 높아지기 때문이다.

특히 데이터 의존성은 많은 문제를 야기할 수 있다.

예를 들어 데이터베이스에 의존하는 테스트는 데이터베이스가 올바르지 않은 데이터를 가지고 있는 경우 테스트가 실패할 것이다.

외부 저장소와 상호작용하게 되면 테스트의 가용성 혹은 접근성 이슈로 실패할 가능성이 늘어나게 되므로,

단위 테스트는 철저히 격리 되어 작성되어야 한다.

격리라는 단어에서 알 수 있듯 단위 테스트는 또 다른 단위 테스트에 의존해서는 안된다.

결론적으로, 테스트 코드는 단일책임 원칙하에 의존성없이 독립적으로 실행되도록 작성해야 한다.

5.4. Repeatable

원제 : FI[R]ST: Good Tests Should Be [R]epeatable

좋은 테스트 코드는 반복적으로 실행할 수 있어야 한다.

정상적인 테스트 코드라고 가정할때 아무때나 실행하더라도, 항상 같은 결과를 빠르게 반환해줄 수 있어야하는 것이다.

그렇다면 반복가능한 테스트를 만들려면 어떻게 해야할까?

정답은 코드내 모든 환경을 통제해야하는 것이다.

직접 통제할 수 없는 외부 요인은 격리를 통해 제거해나가야 한다.

5.5. Self Validating

원제 : FIR[S]T: [S]elf-Validating

좋은 테스트 코드는 스스로 검증할 수 있어야 한다.

테스트 결과를 수동으로 검증하는 것은 많은 리소스를 낭비시키는 요인이 된다.

검증과 준비를 모두 자동화하여 배포까지의 오버헤드를 최대한 줄여나가는 방향이 권장된다.

5.6. Timely

원제 : FIRS[T]: [T]imely

단위 테스트의 작성은 언제라도 할 수 있찌만, 적절한 시점에 작성하는 것이 권장된다.

단위 테스트를 작성하는 것은 좋은 습관이며, 작성을 미룰 수록 코드 스멜이 계속해서 증가하게 될 것이다.

따라서 팀 문화로 자리잡을 수 있게끔 CI 환경에서 테스트 코드를 요구하도록 작업하는 것도 하나의 방법이다.

다만, 레거시에 대한 코드는 시간 낭비가 될 수도 있다.

당장 큰 결함없이 잘 동작한다면, 레거시 코드의 변경이 발생하는 시점에 테스트를 작성하도록 하자.