038. (Pragmatic Unit Testing in Kotlin with JUnit) 2. 진짜 JUnit 테스트 코드 작성하기

2. 진짜 JUnit 테스트 코드 작성하기

원제 : Getting Real with JUnit

앞선 포스팅에서 단순한 평균값에 대한 검증을 테스트 코드로 작성해보았다.

하지만 아쉽게도 실제 코드의 복잡도는 매우 높기에 테스트 코드가 와닿지 않았을 수도 있다.

이번 포스팅에서는 좀 더 복잡한 코드베이스를 기준으로 테스트 코드를 작성해보자.

코드 베이스를 분석하여, 단일 경로를 커버하는 테스트 코드를 작성해보고

이후 두 번째 테스트 코드를 통해 첫 번째로 작성한 테스트 코드의 유용성을 체감해본 뒤,

테스트 구조에 대해서 파악해보자.

2.1. 테스트 대상 이해하기

원제 : Understanding What We’re Testing: The Profile Class

우리가 분석할 코드베이스는 Profile 이라는 이름을 가진 클래스이다.

이 클래스는 사용자와 노동자 간의 매칭을 위해 생성되는 클래스로 특정 질문에 대한 Y/N 답변을 기준으로 점수를 매긴다.

아래 코드를 확인해보자.

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
class Profile {

private val answers = hashMapOf<String, Answer>()
private var score: Int = 0
private var name: String


constructor(name: String) {
this.name = name
}

fun getName(): String = name

fun add(answer: Answer) {
answers.put(answer.questionText, answer)
}

fun matches(criteria: Criteria): Boolean {
score = 0

var kill = false
var anyMatches = false

criteria.forEach { criterion ->
var answer = answers.get(criterion.answer.questionText)

var match = criterion.weight == Weight.DontCare || answer?.match(criterion.answer) == true

if (match.not() && criterion.weight == Weight.MustMatch) {
kill = true
}
if (match) {
score += criterion.weight.value
}
anyMatches = anyMatches || match
}
if (kill) {
return false
}
return anyMatches
}

fun score(): Int = score
}

위 코드를 하나씩 분석해보자.

1
2
3
fun add(answer: Answer) {
answers.put(answer.questionText, answer)
}

사용자가 노동자에게 어떠한 질문에 대해 긍정적인 답변을 했다면, 이 답변을 Answer 객체에 담아 add() 메서드를 호출해 Profile 객체에 추가한다.

1
var answer = answers.get(ceriterion.answer.questionText)

답변 객체 AnswerQuestion 객체를 참조하여 그 질문에 대한 답변을 포함하고 있다.

1
2
3
criteria.forEach { ceriterion ->
...
}

Criteria 객체는 단순히 Ceriterion를 담는 컨테이너로, Ceriterion 객체를 통해 사용자가 노동자를 꼭 채용하려하는지, 채용하지않아도 괜찮은지를 판정한다.

최종적으로 프로파일에 맞는 기준에 부합하지 않으면 matches() 메서드는 false를, 나머지 경우엔 true를 반환한다.

특기할 점으로 matches() 메서드는 score라는 프로퍼티를 갱신하고 있는 점을 알 수 있다.

참고 함수형 프로그래밍에서 함수 외부에 있는 객체 혹은 변수를 변경하는 행위는 부작용 을 의미한다.

2.2. 어떤 테스트를 작성할 것인지 결정하기

원제 : Determining What Tests We Can Write

꽤나 자세하네 Profile 클래스에 대해서 분석해보았다.

분석 후 본격적인 테스트 코드를 작성하기 전에 얼마나 많은 테스트 코드가 필요한지 고민이 선행되어야 한다.

이른 바 객체의 특정 행위로 인해 발생하는 경우의 수를 예상하는 것이 그 예시이다.

가장 먼저 반복문이나 분기를 일으키는 if 등을 확인해보는 것이 좋다.

그 다음으로 데이터의 변경에 대한 부작용을 생각해보는 것이다.

가장 단순한 경우의 수는 Criteria 객체가 단 한 개의 Criterion 객체를 가진 경우로 부작용을 최소화한 채로 의도된 동작을 수행하게 될 것이다.

이를 비즈니스에 대한 주요 흐름(happy path) 이라고 부른다.

참고
주요 흐름이란 흔히 해피 케이스라고 부르는 시나리오의 성공적인 실행을 말한다.
(Unit Test Principles) 8. 왜 통합 테스트를 해야 하는가? - 8.1.2 테스트 피라미드 다시 보기

Profile 클래스가 가진 테스트 코드 작성을 위한 모든 경우의 수를 나열해보자.

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
52
53
54
55
56
57
58
59
60
class Profile {

private val answers = hashMapOf<String, Answer>()
private var score: Int = 0
private var name: String


constructor(name: String) {
this.name = name
}

fun getName(): String = name

fun add(answer: Answer) {
answers.put(answer.questionText, answer)
}

fun matches(criteria: Criteria): Boolean {
score = 0

var kill = false
var anyMatches = false

// 1. Criteria 객체가 Criterion 객체를 하나도 포함하고 있지 않은 경우
// 2. Criteria 객체가 Criterion 객체를 2개 이상 포함하고 있는 경우
criteria.forEach { criterion ->
// 3. answers에서 반환한 Answer 객체가 null인 경우
// 4. criterion.answer 혹은 criterion.answer.questionText 값이 null인 경우
var answer = answers.get(criterion.answer.questionText)

// 5. criterion.weight의 값이 Weight.DontCare 여서 match가 true로 초기화되는 경우
// 6. answer?.match(criterion.answer)이 true여서 match가 true로 초기화되는 경우
// 7. 5번과 6번 조건이 모두 false여서 match가 false로 초기화되는 경우
var match = criterion.weight == Weight.DontCare || answer?.match(criterion.answer) == true

// 8. match가 false이고 criterion.weight의 값이 Weight.MustMatch 여서 kill이 true로 초기화되는 경우
// 9. match가 true여서 kill이 false로 유지되는 경우
// 10. criterion.weight의 값이 Weight.MustMatch가 아니어서 kill이 false로 유지되는 경우
if (match.not() && criterion.weight == Weight.MustMatch) {
kill = true
}

// 11. match의 값이 true여서 score 값이 업데이트 되는 경우
// 12. match의 값이 false여서 score 값이 업데이트 되지 않는 경우
if (match) {
score += criterion.weight.value
}
anyMatches = anyMatches || match
}
// 13. kill의 값이 true여서 matches 메서드가 false를 반환하는 경우
if (kill) {
return false
}
// 14. anyMatches의 값이 true여서 matches 메서드가 true를 반환하는 경우
// 15. anyMatches의 값이 false여서 matches 메서드가 false를 반환하는 경우
return anyMatches
}

fun score(): Int = score
}

모든 분기점 및 데이터의 변경 포인트마다 주석으로 작성하였다.

총 15개의 경우의 수가 존재함을 알 수 있다.

그렇다면 15개의 경우의 수가 맞춰 테스트 코드도 15개를 작성해야할까?

자세히 살펴보면 특정 조건을 충족하는 경우 후위에 위치하는 경우가 종속되는 부분이 보일 것이다.

이렇게 종속적인 조건들은 하나의 테스트로 묶을 수 있다.

2.3. 단일 경로 커버하기

원제 : Covering One Path

Profile 클래스의 maches() 메서드의 복잡도는 어디가 제일 높을까?

대부분 반복문을 선택할 것이다. 이 반복문을 따라 한 가지 경로를 커버하는 테스트 코드를 작성해보자.

먼저 준비물로는 뭐가 필요할지 고민해보자.

우선 Profile 객체를 하나 생성해야할 것이고, matches() 메서드에 파라미터로 넘겨줄 Criteria 객체가 필요하다.

이제 테스트 코드를 준비해보자.

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

@Test
fun test() {
val profile = Profile("Bull Hockey, Inc.")
val question: Question = BooleanQuestion(1, "Got bonuses?")
val profileAnswer = Answer(question, false)
profile.add(profileAnswer)
val criteria = Criteria()
val criteriaAnswer = Answer(question, true)
val criterion = Criterion(criteriaAnswer, Weight.MustMatch)

criteria.add(criterion)
}
}

테스트를 할 준비를 마쳤다.

이제 실행과 검증 로직을 추가해보고, test() 도 좀 더 적절한 이름으로 변경해보자.

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

@Test
fun matchAnswersFalseWhenMustMatchCriteriaNotMet() {
val profile = Profile("Bull Hockey, Inc.")
val question: Question = BooleanQuestion(1, "Got bonuses?")
val profileAnswer = Answer(question, false)
profile.add(profileAnswer)

val criteria = Criteria()
val criteriaAnswer = Answer(question, true)
val criterion = Criterion(criteriaAnswer, Weight.MustMatch)
criteria.add(criterion)

val matches = profile.matches(criteria)

assertFalse(matches)
}
}

테스트 코드를 통해 검증하고자 하는 의미를 강조하기 위해 메서드명을 교체하였다.

교체한 메서드명을 통해 사용자가 원하는 매칭 결과가 아닐 경우 실패가 나와야 정상인 케이스를 검증할 수 있음을 유추할 수 있다.

여기서 한 번 더 개선해보자.

테스트 코트는 총 10줄로 그다지 길지 않게 느껴지지만, 모든 조건을 동일한 방식으로 검증하면 150줄의 테스트 코드를 작성해야할 것이다.

기존 코드의 길이보다 테스트 코드의 길이가 너무 커지는 것은 과도한 유지보수 비용이 필요할 수도 있다.

2.4. 두 번째 테스트 작성하기

원제 : Tackling a Second Test

어떻게하면 유지보수 비용을 줄일 수 있을까?

일단 두 번째 테스트를 작성해보자.

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

// ...

@Test
fun matchAnswersTrueForAnyDontCareCriteria() {
val profile = Profile("Bull Hockey, Inc.")
val question: Question = BooleanQuestion(1, "Got milk?")
val profileAnswer = Answer(question, false)
profile.add(profileAnswer)

val criteria = Criteria()
val criteriaAnswer = Answer(question, true)
val criterion = Criterion(criteriaAnswer, Weight.DontCare)
criteria.add(criterion)

val matches = profile.matches(criteria)

assertTrue(matches)
}
}

처음으로 작성한 테스트 코드와 비교해보면 로직의 단 두 줄만 바뀐 것을 확인할 수 있다.

나머지 부분을 공통화하는 방법이 있다면 획기적으로 코드의 길이를 줄일 수 있을 것이다.

2.5. @BeforeEach 메서드 사용하기

원제 : Initializing Tests with @Before Methods

이제 테스트 코드의 공통 부분을 별도로 빼서 재사용하는 방법을 알아보자.

JUnit은 본격적인 테스트의 수행 전에 미리 초기화할 수 있는 방법을 제공한다.

@BeforeEach 어노테이션을 이용한 메서드를 작성하면 된다.

참고 JUnit 4는 @Before 이지만, JUnit 5부터 @BeforeEach를 사용해야 한다.

아래 코드를 확인해보자.

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
class ProfileTest {

private lateinit var profile: Profile
private lateinit var question: BooleanQuestion
private lateinit var criteria: Criteria

@BeforeEach
fun create() {
profile = Profile("Bull Hockey, Inc.")
question = BooleanQuestion(1, "Got bonuses?")
criteria = Criteria()
}

@Test
fun matchAnswersFalseWhenMustMatchCriteriaNotMet() {
val profileAnswer = Answer(question, false)
profile.add(profileAnswer)

val criteriaAnswer = Answer(question, true)
val criterion = Criterion(criteriaAnswer, Weight.MustMatch)
criteria.add(criterion)

val matches = profile.matches(criteria)

assertFalse(matches)
}

@Test
fun matchAnswersTrueForAnyDontCareCriteria() {
val profileAnswer = Answer(question, false)
profile.add(profileAnswer)

val criteriaAnswer = Answer(question, true)
val criterion = Criterion(criteriaAnswer, Weight.DontCare)
criteria.add(criterion)

val matches = profile.matches(criteria)

assertTrue(matches)
}
}

@BeforeEach 어노테이션이 붙은 메서드는 @Test 어노테이션이 붙은 메서드가 실행되기전마다 호출되어 프로퍼티를 초기화한다.

두 개의 테스트 코드의 분량도 공통부분이 제거되어 줄어든 것을 확인할 수 있다.

여기서 가독성을 위해 테스트 코드를 좀 더 다듬어보자.

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
class ProfileTest {

private lateinit var profile: Profile
private lateinit var question: BooleanQuestion
private lateinit var criteria: Criteria

@BeforeEach
fun create() {
profile = Profile("Bull Hockey, Inc.")
question = BooleanQuestion(1, "Got bonuses?")
criteria = Criteria()
}

@Test
fun matchAnswersFalseWhenMustMatchCriteriaNotMet() {
profile.add(Answer(question, false))
criteria.add(Criterion(Answer(question, true), Weight.MustMatch))

val matches = profile.matches(criteria)

assertFalse(matches)
}

@Test
fun matchAnswersTrueForAnyDontCareCriteria() {
profile.add(Answer(question, false))
criteria.add(Criterion(Answer(question, true), Weight.DontCare))

val matches = profile.matches(criteria)

assertTrue(matches)
}
}

테스트 코드를 리팩토링하여 준비(Arrange), 실행(Act), 검증(Assert) 의 영역을 한 줄 혹은 두 줄의 코드로 끝내었다.

또한 빠진 공통 부분은 이제 테스트의 관심사에서 멀어졌기때문에 어떤 것을 테스트할지 좀 더 집중할 수 있게 된다.