속성 기반 테스트를 사용해야 하는 이유

보통 개발자들이 작성하는 테스트는 예제 기반 테스트(Example-based Tests)이다. 입력과 기대값을 제공하고, 두 값이 일치하는지 확인하여 일치하지 않으면 테스트에 실패한다. 문제는 예상한 것 이외에 엣지 케이스(edge cases)와 입력될 때 발생하는 오류를 놓치기가 매우 쉽다는 것이다.

실패한 테스트는 프로그램이 잘못됐음을 증명할 수는 있다. 하지만 테스트에 성공했다는 사실이 테스트한 프로그램이 올바르다는 사실을 증명하지는 못한다. 성공한 테스트는 버그를 찾을 수 있을 만큼 똑똑하지 못하다는 사실을 증명할 뿐이다.

결국 우리는 우리가 작성한 프로그램이 올바르다는 것을 증명해야 한다. 버그의 위험성은 프로그램이 취할 수 있는 구현의 가짓수에 비례한다. 따라서 우리는 가능한 구현의 개수를 최소화해야 한다.

구현의 개수를 최소화하는 한 가지 방법은 추상화 이다. 정수 리스트에서 주어진 수의 배수를 찾고, 찾은 배수 중에 최댓값을 찾는 프로그램을 예제로 살펴보자. 전통적인 프로그래밍에서는 인덱스를 사용하는 루프를 통해 다음 예제와 같이 이 목표를 달성할 수 있다.

fun maxMultiple(multiple: Int, list: List<Int>): Int {
  var result = 0
  for (i in 1 until list.size) {
    if (list[i] / multiple * multiple == list[i] && list[i] > result) {
      result = list[i]
    }
  }
  return result
}

이런 프로그램은 어떻게 테스트할까? 코드에 버그가 보이므로 테스트를 작성하기 전에 버그부터 잡을 생각이 들 것이다. 하지만 우리가 이 버그를 만든 당사자라면 버그를 찾기 어려울 것이다. 버그를 만든 당사자는 아마도 몇 가지 제한적인 값에 대해 구현을 테스트해 보고 싶을 것이다. 두 파라미터가 정수와 리스트이므로 Int 타입의 multiple 파라미터에 0을 넣거나 빈 리스트를 list에 넣어 테스트한다. multiple에 0을 넣으면 java.lang.ArithmeticException: / by zero 예외가 발생하고, 빈 리스트를 넣으면 0이 결과로 나온다. 여기서 multiple에 0을 넘기는 동시에 list에 빈 리스트를 넘기면 예외가 발생하지 않고 0이 결과로 나온다는 사실에 유의한다.

이 예제에서 다른 버그는 첫 번째 원소가 무시된다는 데 있다. 대부분의 경우에는 이는 문제가 되지 않는다.

  • 리스트가 비어 있는 경우
  • 첫 번째 파라미터가 0(0으로 나누는 오류를 이미 해결했으므로)
  • 첫 번째 원소가 첫 번째 파라미터의 배수가 아닌 경우
  • 첫 번째 원소가 첫 번째 파라미터의 배수이긴 하지만 가장 큰 배수는 아닌 경우

물론 우리는 지금 무엇을 해야 하는지 알고 있다. 첫 번째 파라미터로 0이 아닌 값을, 리스트의 첫 번째 원소로 가장 큰 배수를 넣은 테스트를 작성하는 것이다. 하지만 우리가 그런 테스트를 고안해낼 정도로 똑똑하다면 이런 버그를 만들지도 않았을 테니, 이 경우에는 테스트가 유용하지 않다.

일부 프로그래머들은 구현을 하기 전에 테스트를 작성 해야 한다고 말한다. 그런 의견에 완전히 동의하지만, 여기서 그런 접근 방법이 어떤 도움을 줄 수 있을까? 아마도 우리가 0이나 빈 리스트에 대한 테스트를 작성하기는 하겠지만, multiple에 0이 아닌 수가 들어가고 list의 첫 번째 원소가 가장 큰 배수인 테스트를 만들어야 한다는 사실을 어떻게 미리 알 수 있을까? 구현을 알고 있는 경우에만 그런 생각을 할 수 있을 것이다.

구현을 알고 테스트를 작성하면 우리가 구현을 작성한 사람이기 때문에 편향될 수밖에 없어서 이상적이지 않다. 반면에 우리가 작성하지 않은 구현에 대한 테스트를 작성한다면 실패하는 테스트를 작성하려고 노력하는 과정이 재미있을 것이다.

하지만 실제 해야 할 일은 구현을 보지 않고 프로그램이 깨지는 테스트를 만들어내는 것이다. 우리가 테스트와 구현을 모두 작성하므로 구현을 보지 않고 테스트를 만들기에 가장 좋은 시점은 구현을 시작하기 전 밖에 없다. 따라서 개발 과정은 다음과 같아야 한다.

  1. 인터페이스를 작성한다.
  2. 테스트를 작성한다.
  3. 구현을 작성하고 테스트에 성공하는지 검사한다.

1. 인터페이스 작성하기

인터페이스 작성은 쉽다. 대부분의 인터페이스 작성은 함수 시그니처 작성으로 이루어진다.

fun maxMultiple(multiple: Int, list: List<Int>): Int = TODO()

2. 테스트 작성하기

전통적인 테스트라면 다음과 같이 시작할지도 모른다.

internal class MyTest : StringSpec() {
  init {
    "maxMultiple" {
      val multiple = 2
      val list = listOf(4, 11, 8, 2, 3, 1, 14, 9, 5, 17, 6, 7)
      maxMultiple(multiple, list).shouldBe(14)
    }
  }
}

우리가 사용하려는 구체적인 값의 종류대로 테스트를 많이 작성할 수 있다. 물론 0이나 빈 리스트같이 특별한 값은 반드시 테스트해야 한다. 하지만 그런 특별한 값이 아닌 값에 대한 테스트를 어떻게 작성할 수 있을까?

일반적으로 프로그래머들이 택하는 방법은 일부 입력값을 택하고 그에 따른 출력이 올바른지 보는 것이다. 앞의 예제에서는 테스트 대상 함수에 2와 [4, 11, 8, 2, 3, 1, 14, 9, 5, 17, 6, 7]를 넘긴 결과가 14와 같은지 비교하는 것으로 테스트를 작성할 수 있다.

하지만 14라는 값(예상값)을 어떻게 찾았을까? 함수에 넘길 튜플(2와 리스트)에 대해 우리가 구현하려고 생각한 것과 똑같은 과정을 적용해 14를 얻는다. 사람이 완벽하지 않기 때문에 이런 사고 과정은 실패할 수 있다. 하지만 대부분의 경우 테스트가 성공하는데, 이는 우리가 같은 일을 머릿속에서 14를 계산하기 위해 한 번 수행하고 이때 생각해 낸 로직을 그대로 구현한 컴퓨터 프로그램에서 다시 한 번 더 수행하기 때문이다. 이는 구현을 먼저 작성하고 주어진 파라미터를 가지고 그 구현을 실행한 다음에 같은 출력을 검증하기 위한 테스트를 작성하는 것과 같다.


속성 기반 테스트는 무엇인가

속성 기반 테스트(Property-Based Tests)는 결과가 입력 데이터와 관련 있는 어떤 속성(property)을 만족하는지 검증하는 것 이다. 예를 들어 두 문자열을 연결하는 프로그램(+)을 작성한다면 검증해야 할 속성은 다음과 같다.

  • (stinrg1 + string2).length == string1.length + string2.length
  • (string + "" == string)
  • "" + string == string
  • string1.reverse() + string2.reverse() == (string1 + string2).reverse()

이런 속성을 테스트하면 프로그램이 올바르다는 사실을 보장하기에 충분할 것이다. 이런 속성을 검증하는 테스트가 있으면 실제 결과를 생각해내려고 고생할 필요 없이 임의로 만든 수백만 개의 문자열에 대해 속성을 검증해볼 수 있다. 문제가 되는 것을 속성을 검증할 수 있느냐 없느냐 뿐이다.

코딩보다 먼저 해야 할 일은 함수의 입력과 출력을 고려할 때 검증해야만 하는 속성이 무엇인지 찾아보는 관점 에서 문제를 살펴보는 것이다. 이런 관점에서 생각해 보면 부작용이 없는 함수가 속성을 훨씬 더 쉽게 찾아낼 수 있다는 사실을 금방 깨닫게 된다.

앞의 최대 배수 문제를 생각해 보자. 인터페이스를 작성할 때 이런 속성을 알아내기란 항상 쉽지만은 않다. ((Int, List<Int>), Int) 라는 타입의 모든 튜플에 대해 마지막 Int가 앞의 Int와 List<Int> 쌍을 함수에 적용해 만들어낸 결과가 올바른 경우에만 참이 되는 검증 가능한 속성의 집합을 찾아내야 한다는 점을 기억하라.

우리가 원하는 속성 중 몇가지를 찾는 것은 쉬울 수도 있지만, 중요한 속성을 찾아내는 것은 훨씬 어렵다. 이상적인 경우 결과가 올바르다는 사실을 검증할 수 있는 최소 속성 집합을 찾아내야 한다. 하지만 함수를 구현할 때 사용하는 것과 같은 논리를 사용하지 않고 그런 속성을 찾아내야 한다. 이런 속성을 찾는 방법은 다음 두 가지가 있다.

  • 항상 성립해야 하는 조건을 찾는다.
  • 항상 성립하지 않아야만 하는 조건을 찾는다.

예를 들어 리스트의 원소에 대해 이터레이션하는 경우 첫 번째 파라미터(multiple)의 배수이면서 함수가 반환하는 결과보다 큰 원소가 리스트 안에 있을 수 없다. 문제는 이런 속성을 테스트하는 동안 함수를 구현할 때와 똑같은 알고리즘을 사용할 수도 있다는 점이다. (똑같은 알고리즘을 쓴다면) 함수를 두 번 호출해서 같은 결과가 나오는지 비교하는 것보다 쓸모가 더 있지는 않다! 이런 문제를 해결할 방법이 바로 추상화 이다.


3. 추상화와 속성 기반 테스트

우리가 해야 할 일은 문제의 각 부분을 추상화할 방법을 찾고 각 부분을 구현하는 함수를 작성한 다음에 각각을 별도로 테스트하는 것이다.

fun maxMultiple(multiple: Int, list: List<Int>): Int =
  list.fold(initialValue) { acc, int -> ... }

fun maxMultiple(multiple: Int, list: List<Int>): Int =
  list.foldLeft(initialValue) { acc -> { int -> ... } }

이제 fold를 사용할 함수를 테스트해야 한다. 그에 따라 앞 코드처럼 람다와 같은 익명 함수를 더 이상 쓰지 않고 대신에 다음과 같이 코드를 작성한다.

fun maxMultiple(multiple: Int, list: List<Int>): Int =
  list.fold(0, ::isMaxMultiple)

fun isMaxMultiple(acc: Int, value: Int) = ...

이렇게 함으로써 테스트하기 쉽게 문제를 바꾸었다. 테스트가 쉬워진 이유는 이터레이션 부분을 추상화했기 때문이다. 그리고 이터레이션 부분은 테스트할 필요가 없으며, 이미 다른 곳에서 테스트가 이루어졌다. 우리가 코틀린 fold 함수를 사용한다면 이는 코틀린 언어를 믿는 것이다.

이제 우리가 풀어야 할 문제는 약간 교묘한 해결책이 필요하다. multiple 파라미터의 값이 2라고 하자. 이 경우 구현은 당연히 다음과 같다.

fun isMaxMultiple(acc: Int, elem: Int): Int =
  if (elem / 2 * 2 == elem && elem > acc) elem else acc

우리가 해야 할 일은 2라는 수를 multiple이라는 파라미터로 바꾸는 것이며, 로컬 함수를 사용하면 쉽게 바꿀 수 있다.

fun maxMultiple(multiple: Int, list: List<Int>): Int {
  fun isMaxMultiple(acc: Int, elem: Int): Int =
    if (elem / 2 * 2 == elem && elem > acc) elem else acc
  return list.fold(0, ::isMaxMultiple)
}

이런 구현에서 곤란한 점은 이 구현이 테스트를 쉽게 작성하도록 돕지 못한다는 것이다. 여기서도 추상화가 해결책이다. multiple 파라미터를 다음과 같이 추상화한다.

fun isMaxMultiple(multiple: Int) =
  { max: Int, value: Int ->
    when {
      value / multiple * multiple == value && value > max -> value
      else -> max
    }
  }

// 위 코드와 동일
fun isMaxMultiple(multiple: Int) =
  { max: Int, value: Int ->
    when {
      value % multiple == 0 && value > max -> value
      else -> max
    }
  }

이제 이 함수를 단위 테스트할 수 있다. isMaxMultiple을 이 테스트에서 다음과 같이 사용한다.

fun test(value: Int, max: Int, multiple: Int): Boolean {
  val reslut = isMaxMultiple(multiple)(max, value)

  ... // 속성 검증
}

테스트할 속성이 몇 가지 더 있다.

  • result >= max
  • result % multiple == 0 || result == max
  • (result % multiple == 0 && result >= value) || result % multiple != 0

다른 속성을 찾을 수도 있다. 이상적으로는 함수가 만들어내는 결과가 올바름을 증명할 수 있는 최소 속성 집합을 찾아야 한다. 실제로는 일부 겹치는 부분이 있어도 문제가 되지는 않는다. 속성이 겹치거나 남아돌아도 해가 되지 않는다. 오히려 속성이 모자라면 더 문제가 된다.


Reference