043. (Pragmatic Unit Testing in Kotlin with JUnit) 7. CORRECT 기억법

7. CORRECT 기억법

원제 : Boundary Conditions: The CORRECT Way

지난 포스팅에서 경계 조건에 대해 다룰 때 CORRECT 기억법에 대해 짤막하게 다루었다.

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

이번 포스팅에서는 CORRECT 기억법에 대해 좀 더 자세히 알아보도록 하자.

7.1. Conformance 준수

원제 : [C]ORRECT: [C]onformance

많은 데이터들은 특정 포맷이 정해져 있다.

예를 들어 이메일 주소인 mail@namhoon.kimname@samedomain 포맷이다.

이메일 이외에도 전화번호, 계좌 번호, 파일 이름등은 특정 포맷을 가지고 있는 경우가 많고, 이 포맷은 수많은 규칙들을 포함한다.

특정 구조적 데이터를 파싱해서 읽는 시나리오를 생각해보자.

이 구조적 데이터를 파싱할 때 필요한 테스트 경계 조건은 아래와 같다.

  • 헤더가 없고, 데이터와 트레일러만 존재하는 경우
  • 데이터가 없고, 헤더와 트레일러만 존재하는 경우
  • 트레일러가 없고, 헤더와 데이터만 존재하는 경우
  • 헤더만 존재하는 경우
  • 데이터만 존재하는 경우
  • 트레일러만 존재하는 경우

만약 데이터의 구조가 복잡하다면 위의 경우의 수는 급증하게 될 것이다.

따라서 해당 데이터가 최초로 주어지는 진입 지점에서 확실하게 검증하고, 그 뒤로는 검증하지 않도록 시스템의 흐름을 파악하여 작성한다면

최소한의 테스트 코드로 데이터 포맷을 검증할 수 있을 것이다.

7.2. Ordering 순서

원제 : C[O]RRECT: [O]rdering

데이터의 순서 혹은 컬렉션에 속한 데이터 한 조각의 위치는 코드의 비정상 동작을 유발할 수 있다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

private fun soleNeed(question: Question, value: Boolean, weight: Weight): Criteria {
val criteria = Criteria()
criteria.add(Criterion(Answer(question, value), weight))
return criteria
}

@Test
fun answersResultsInScoredOrder() {
val smeltInc = Profile("Smelt Inc.")
val langrsoft = Profile("Langrsoft")
val pool = ProfilePool()
val doTheyReimburseTuition = BooleanQuestion("Reimburses tuition?")

smeltInc.add(Answer(doTheyReimburseTuition, false))
pool.add(smeltInc)
langrsoft.add(Answer(doTheyReimburseTuition, true))
pool.add(langrsoft)
pool.score(soleNeed(doTheyReimburseTuition, true, Weight.Important))

val ranked: List<Profile> = pool.ranked()

assertThat(ranked.toTypedArray(), equalTo(arrayOf<Profile>(langrsoft, smeltInc)))
}

위 테스트 코드는 pool의 순서를 검증하기 위한 목적으로 작성되었다.

테스트 내용은 아래와 같다.

  • smeltInc 사에 부정 답변 추가
  • langrsoft 사에 긍정 답변 추가
  • pool에 각 질문 추가
  • 단일 요구 조건으로 학비 상환이 중요함을 명시
  • 위의 조건을 poolscore() 메서드를 통해 전달
  • 프로파일 순서상 langrsoft, smeltInc 순서대로 노출되는지 검증

이때 ranked() 함수의 구현에 따라 테스트가 실패할 수도 있다.

1
2
3
4
5
6
7
8
fun ranked(): List<Profile> {
Collections.sort(profiles,
Comparator { p1: Profile, p2: Profile ->
p1.score().compareTo(p2.score())
}
)
return profiles
}

정렬의 조건이 반대여서 실패하고 있으므로, 아래와같이 프로덕션 코드를 수정하여 테스트를 성공시킬 수 있다.

7.3. Range 범위

원제 : CO[R]RECT: [R]ange

Int의 표현 범위는 사람 나이 등을 표현하기에 이미 충분한 범위를 가지고 있다.

다만 개발자는 경계 조건을 대비해야하기 때문에 테스트 코드로 방어하는 것이 권장된다.

예를 들어어떤 베어링이 있다고 해보자.

베어링은 원이므로 각도의 범위는 최대 360임을 알 수 있다.

먼저 베어링 클래스를 구현해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Bearing(value: Int) {
companion object {
const val MAX = 359
}

private val value: Int

init {
if (value < 0 || value > MAX) throw BearingOutOfRangeException()
this.value = value
}

fun value(): Int = value

fun angleBetween(bearing: Bearing): Int = value - bearing.value
}

이를 이용해 베어링의 표현 범위를 테스트 코드로 방어해보자.

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
class BearingTest {
@Test(expected = BearingOutOfRangeException::class)
fun throwsOnNegativeNumber() {
Bearing(-1)
}

@Test(expected = BearingOutOfRangeException::class)
fun throwsWhenBearingTooLarge() {
Bearing(Bearing.MAX + 1)
}

@Test
fun answersValidBearing() {
assertThat(Bearing(Bearing.MAX).value(), equalTo(Bearing.MAX))
}

@Test
fun answersAngleBetweenItAndAnotherBearing() {
assertThat(Bearing(15).angleBetween(Bearing(12)), equalTo(3))
}

@Test
fun angleBetweenIsNegativeWhenThisBearingSmaller() {
assertThat(Bearing(12).angleBetween(Bearing(15)), equalTo(-3))
}
}

이 개념을 차용하면 특정 도형이 지정된 좌표계 영역을 벗어나지 않는지 등도 검증할 수 있다.

7.4. Reference 참조

원제 : COR[R]ECT: [R]eference

특정 메서드를 테스트할 때는 참조 관련되어서 아래 사항을 점검해야 한다.

  • 범위를 넘어서는 것을 참조하고 있는지?
  • 외부 의존성에 대해서 전부 인지하고 있는지?
  • 특정 상태를 가진 객체를 참조하고 있는지?
  • 그 외 반드시 존재해야하는 조건들이 있는지?

이처럼 참조 관계에 놓인 어떤 상태에 대해 사정할 때는, 해당 가정이 맞지 않을때에도 합리적인 행동을 하는지도 검증해야한다.

예를 들어 자동차 변속기를 떠올려보자.

Transmission 클래스는 크게 세 가지 시나리오를 가지고 있다고 가정한다.

  1. 가속 이후에 변속기를 주행으로 유지하는가?
  2. 주행 중에 주차로 바꾸는 파괴적인 요청을 무시하는가?
  3. 차량이 움직이지않으면 주차로 변속기 변경을 허용하는가?

위 가정을 테스트 코드로 옮겨보자.

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 TransmissionTest {
private var transmission: Transmission? = null
private var car: Car? = null
@Before
fun create() {
car = Car()
transmission = Transmission(car)
}

@Test
fun remainsInDriveAfterAcceleration() {
transmission.shift(Gear.DRIVE)
car.accelerateTo(35)
assertThat(transmission.getGear(), equalTo(Gear.DRIVE))
}

@Test
fun ignoresShiftToParkWhileInDrive() {
transmission.shift(Gear.DRIVE)
car.accelerateTo(30)
transmission.shift(Gear.PARK)
assertThat(transmission.getGear(), equalTo(Gear.DRIVE))
}

@Test
fun allowsShiftToParkWhenNotMoving() {
transmission.shift(Gear.DRIVE)
car.accelerateTo(30)
car.brakeToStop()
transmission.shift(Gear.PARK)
assertThat(transmission.getGear(), equalTo(Gear.PARK))
}
}

각 시나리오를 테스트하기위해 사전 조건들이 부여된 코드를 확인할 수 있다.

사후 조건의 경우 테스트 코드의 성공과 직결되므로, 검증의 대상이 된다.

메서드 호출의 결과로 발생하는 부작용을 없을까?

allowsShiftToParkWhenNotMoving() 테스트 코드의 경우 car.brakeToStop() 코드를 통해 자동차의 속력이 0으로 고정되는 부작용이 생긴다.

이러한 상태 변화들도 검증할 수 있도록 해야 한다.

7.5. Existence 존재

원제 : CORR[E]CT: [E]xistence

스스로에게 “주어진 값이 존재하는가?” 에 대해 검증해보면 많은 결함을 발견할 수 있다.

어떤 인자를 받거나 특정 프로퍼티를 유지하는 메서드가 있을 때, 그 값이 null이거나 범위를 벗어난다면 예외가 발생할 확률이 높을 것이다.

이러한 엣지 케이스들에 대해 테스트 코드를 작성하여 방어하고, 무엇보다 메서드가 그 자체로서 행위할 수 있도록 독립적으로 작성하는 습관을 들여야 한다.

7.6. Cardinality 기수

원제 : CORRE[C]T: [C]ardinality

숫자를 다루는 것은 언제나 실수한 가능성이 존재하는 영역이다.

우리는 특정 범위 내에서의 경계 조건은 무조건 테스트하도록 해야한다.

예를 들어 범위를 벗어난 값이나, 0, 1 등의 값들이다.

1보다 큰 대다수의 케이스는 해피케이스를 지나가게 될 것이므로, 필요하다면 이를 테스트셋으로 추가하는 방식으로 극복해야 한다.

결과적으로 0, 1, n 중에서 0, 1은 테스트 코드로 대응하고 n은 비즈니스 로직으로 갈음하되 필요하다면 테스트 셋으로 추가하는 등의 고도화를 수행해야 한다.

7.7. Time 시간

원제 : CORREC[T]: [T]ime

마지막 경계 조건은 바로 시간이다.

시간은 크게 세 가지 측면에서 경계 조건을 보인다.

  1. 상대적 시간 (시간 순서)
  2. 절대적 시간 (측정 시간)
  3. 동시성

예를 들어 로그인은 로그아웃 이전에 수행되어야하고, 파일을 읽기전에 파일을 먼저 열어야하며, 스트림을 닫기 전에 읽기 동작을 수행하는 등을 떠올릴 수 있다.

데이터의 순서가 중요한 것처럼, 메서드의 호출 순서도 중요하다.

상대적 시간 중 가장 일반적인 것은 타임아웃 문제이다.

특정 자원을 참조함에 있어 유효기간이 짧은 경우, 코드의 대기 시간도 잘 결정해야 IDLE 상태로 진입하지 않을 것이다.