13 minute read

속성 기반 테스트(Property-based Testing)에 대해 들어본 적이 있다. SUT(System Under Test)에 대한 속성(Property)을 정의한다. 테스트 프레임워크 도움을 받아서 테스트를 자동화한다. 하지만 유닛 테스트(Unit Testing)를 처음 접했을 때보다 더 막막하다. 어떻게 속성을 정의해야 하는가?

찾아보니 책이 있다. ’Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)’를 찾았다. 훌륭하다. 감명받았다. 내가 속성 기반 테스트를 바로 짤 수 있는지는 모르겠지만 그래도 안개는 조금 걷혔다. 꼭지 몇 개를 블로그에 정리해 보려고 한다.

속성 기반 테스트(Property-based Testing)와 예제 기반 테스트(Example-based Testing)

속성 기반 테스트를 알아보기 전에 다른 테스트 방식을 먼저 알아보자. 우리가 일반적으로 테스트 코드를 짤 때, 사용하는 방식을 뭐라고 하는가? 예제 기반 테스트(Example-based Testing)라고 한다.

Traditional tests are often example-based: you make a list of a bunch of inputs to a given program and then list the expected output.

전통적인 테스트는 주로 예제 기반입니다. 주어진 프로그램에 대한 여러 입력 목록을 만든 다음 예상되는 출력을 나열합니다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)

책에서는 전통적인 테스트(Traditional tests)라고 부르지만 인터넷 검색을 해보니 예제 기반 테스트(Example-based Testing)라는 용어도 사용하고 있어서 더 직관적인 ’예제 기반 테스트’라고 부르기로 했다.

금전등록기(Cash Register)에서 돈을 거슬러주는 cash라는 프로그램에 대한 테스트를 예로 들어보자. 사용 언어는 Elixir다.

# Money in the cash register
register = [{20.0, 1}, {10.0, 2}, {5.0, 4}, {1.0, 10}, {0.25, 10}, {0.01, 100}]

# cash 함수는 금전등록기에 있는 금액, 가격, 지불한 금액을 인자로 받아서 잔돈을 리턴한다
assert [{10.0, 1}] = cash(register, 10.0, 20.0)
assert [{10.0, 1}, {0.25, 1}] = cash(register, 9.75, 20.0)
assert [{0.01, 18}] = cash(register, 0.82, 1.0)
assert [{10.0, 1}, {5.0, 1}, {1.0, 3}, {0.25, 2}, {0.01, 13}] = cash(register, 1.37, 20.0)

익숙하고 친근하다. 코드 리뷰할 때, 이런 테스트 코드를 보면서 edge case를 제대로 테스트했는지 확인한다. 잔돈을 안 줘도 되는 상황에 대한 테스트가 빠진 것 같아요. 뭐 이런 식으로 말이다.

예제 기반 테스트가 아닌 속성 기반 테스트를 짠다면 어떻게 짜면 될까?

forall {register_money, price_to_pay, money_paid} <- generate() do
  change = cash(register_money, price_to_pay, money_paid)
  sum(change) == money_paid - price_to_pay && fewest_pieces_possible(register_money, change)
end

유효한 금전등록기에 있는 금액, 가격, 지불한 금액을 만들어낸다. 이건 제네레이터(generator)로 규칙을 정해준다. 검증할 속성을 정의한다. 잔돈을 모두 더하면 지불한 금액에서 가격을 뺀 금액과 같아야 한다. 잔돈은 가장 적은 지폐 개수여야 한다.

property-based tests have nothing to do with listing examples by hand. Instead, you’ll want to write some kind of meta test: you find a rule that dictates the behavior that should always be the same no matter what sample input you give to your program and encode that into some executable code a property.

속성 기반 테스트는 예제를 직접 나열하는 것과는 아무런 관련이 없습니다. 대신 일종의 메타 테스트를 작성하고 싶을 것입니다. 프로그램에 어떤 샘플 입력이 들어오든 항상 동일해야 하는 동작을 지시하는 규칙을 찾고 이를 실행 가능한 코드에 속성으로 인코딩합니다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)

예제를 직접 나열하는 방식이 아니다. 테스트할 입력의 규칙을 정한다. 그리고 어떤 입력이 들어오든지 간에 만족해야 할 규칙을 찾고 그걸 속성으로 정의한다. 메타 테스트를 작성하면 이후는 테스트 프레임워크가 도와준다. 고른 분포로 입력을 생성한다. 실패하면 Shrinking 과정을 거쳐서 재연할 수 있는 입력이 무엇인지 실패 입력을 뽑아준다.

In fact, when we test with properties, the design and growth of tests requires an equal part of growth and design of the program itself. A common pattern when a property fails will be to figure out if it’s the system that is wrong or if it’s our idea of what it should do that needs to change. We’ll fix bugs, but we’ll also fix our understanding of the problem space. We’ll be surprised by how things we thought we knew are far more complex and tricky than we thought, and how often it happens.

실제로 속성을 사용하여 테스트할 때 테스트의 설계 및 성장에는 프로그램 자체의 성장 및 설계와 동일한 부분이 필요합니다. 속성이 실패할 때 일반적인 패턴은 시스템에 문제가 있는지, 아니면 변경해야 할 작업에 대한 우리의 생각인지 파악하는 것입니다. 우리는 버그를 수정할 것이지만 문제 공간에 대한 이해도 수정할 것입니다. 우리가 알고 있다고 생각했던 일들이 우리가 생각했던 것보다 훨씬 더 복잡하고 까다로우며, 그런 일이 얼마나 자주 일어나는지 보면 우리는 놀랄 것입니다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)

속성을 정의하려면 문제 공간에 대한 이해가 반드시 필요하다. 그래서 예제 기반 테스트보다 짜기가 힘든 것인지도 모른다. 내가 이제까지 짜 본 예제 기반 테스트를 돌이켜보면 속성을 정확하게 파악하지 않아도 몇 가지 값을 넣어보면서 테스트를 작성할 수 있었다.

Elixir 속성 기반 테스트 프레임워크

속성 기반 테스트는 테스트 프레임워크가 중요하다. 프레임워크가 하는 일이 유닛 테스트 프레임워크보다 훨씬 더 많다. 입력을 고르게 만들어야 한다. 사용자가 커스텀한 입력을 만드는 Generator를 지원해야 한다. 실패했을 때, Shrinking 기능을 지원해서 쉽게 재연할 수 있는 입력을 뽑아줘야 한다. 입력 분포에 대한 통계를 지원해야 한다. stateless 뿐만 아니라 stateful 속성 기반 테스트를 지원해야 한다.

PropEr, PropCheck

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019) 책이 이 라이브러리를 사용해서 설명한다. 이 책 기반으로 속성 기반 테스트를 접했다. 그래서 내가 아는 속성 기반 테스트에 필요한 모든 기능을 다 지원한다. 내가 알고 있는 좁은 속성 기반 테스트에서는 최고의 라이브러리다. Erlang 기반이라 Elixir에서도 사용할 수 있다. Elixir에서는 래핑한 PropCheck 라이브러리를 사용하면 된다.

StreamData

Elixir로 만든 속성 기반 테스트 라이브러리다. Elixir를 만든 Jose Valim이 발을 담갔으니 Elixir 언어의 표준처럼 될 것이다. Jose Valim이 발을 담그면 그렇게 된다. 하지만 PropEr과 달리 stateful 속성 기반 테스트를 지원하지 않는다. 그래서 나는 Elixir 속성 기반 테스트 프레임워크로 StreamData 보다는 PropEr을 더 선호한다. StreamData 라이브러리가 stateful 속성 기반 테스트를 지원하는 날에는 갈아탈지도 모르겠다.

속성(Property) 생각하기

속성 기반 테스트가 뭔가 멋진 건 알겠다. 이제 넘어야 할 산이 있다. 속성을 어떻게 만들 것인가? 말이 쉽지 예제 기반 테스트만 해오다가 속성을 추출하려니 막막하기만 하다. 다행히 ’Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)’ 책에서 네 가지 일반적인 방법을 소개한다.

Modeling

Modeling essentially requires you to write an indirect and very simple implementation of your code— often an algorithmically inefficient one— and pit it against the real implementation.

as long as both implementations behave the same way, there’s a good chance that the complex one is as good as the obviously correct one, but faster. So for code that does a conceptually simple thing, modeling is useful.

모델링을 위해서는 본질적으로 코드의 간접적이고 매우 간단한 구현(알고리즘적으로 비효율적인 경우가 많음)을 작성하고 이를 실제 구현과 비교해야 합니다.

두 구현이 모두 동일한 방식으로 동작하는 한 복잡한 구현이 분명히 올바른 구현만큼 좋지만 더 빠를 가능성이 높습니다. 따라서 개념적으로 단순한 작업을 수행하는 코드의 경우 모델링이 유용합니다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)

더 효율적으로 개선한 알고리즘이나 자료구조 테스트에 유용하다. 모델링이라고 하면 가장 먼저 떠오르는 예제다. 리스트에서 최대값을 구하는 함수에 대한 모델링을 하고 속성 기반 테스트를 하는 예제를 보자.

defmodule Pbt do
  def biggest([head | tail]) do
    biggest(tail, head)
  end

  defp biggest([], max) do
    max
  end

  defp biggest([head | tail], max) when head >= max do
    biggest(tail, head)
  end

  defp biggest([head | tail], max) when head < max do
    biggest(tail, max)
  end
end

인자 하나를 받는 함수가 진입점이다. 인자를 두 개 받는 함수를 호출하는데, 두번째 인자로 최대값을 넘긴다. 이 함수에 대한 모델은 어떻게 작성하면 될까?

def model_biggest(list) do
  List.last(Enum.sort(list))
end

리스트를 소팅하고 제일 마지막 요소(element)를 리턴하면 된다. 검증된 Elixir 표준 라이브러리를 사용해서 구현했다. 이제 속성 기반 테스트로 biggest 함수 동작을 테스트할 차례다.

property "finds biggest element" do
  forall x <- non_empty(list(integer())) do
    biggest(x) == model_biggest(x)
  end
end

비어있지 않은 정수(integer) 리스트를 무작위로 만든다. biggest 함수를 호출하고 model_biggest 와 같은 값을 리턴하는지 테스트한다.

모델링을 보면 이런 생각이 든다. 저렇게 테스트할 수 있는 대상이 얼마나 될까? 예제로 쓰기는 좋지만 유용한 방법일까?

Modeling is also useful for integration tests of stateful systems with lots of side effects or dependencies, where “how the system does something” is complex, but “what the user perceives” is simple. Real-world libraries or systems often hide such complexities from the user to appear useful at all. Databases, for example, can do a lot of fancy operations to maintain transactional semantics, avoid loss of data, and keep good performance, but a lot of these operations can be modeled with simple in-memory data structures accessed sequentially.

모델링은 부수효과나 종속성이 많은 stateful 시스템의 통합 테스트에도 유용합니다. 여기서 “시스템이 작업을 수행하는 방법”은 복잡하지만 “사용자가 인식하는 것”은 간단합니다. 실제 라이브러리나 시스템은 종종 사용자에게 이러한 복잡성을 숨겨 유용하게 보이도록 합니다. 예를 들어, 데이터베이스는 트랜잭션 의미를 유지하고, 데이터 손실을 방지하고, 우수한 성능을 유지하기 위해 많은 멋진 작업을 수행할 수 있지만 이러한 작업 중 상당수는 순차적으로 액세스되는 간단한 메모리 내 데이터 구조로 모델링될 수 있습니다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)

stateful 속성 기반 테스트에서 많이 쓰인다. 퍼포먼스를 올리려고 복잡하게 구현하지만 사용자가 인식하는 건 간단한 시스템이 많다. 사용자가 인식하는 걸 모델링하고 stateful 속성 기반 테스트를 효과적으로 할 수 있다.

Generalizing Example Tests

테스트도 짜면서 테스트 대상 코드에 대해 배운다. 문제 공간(problem space)에 대한 이해도 높인다.

A good trick to find a property is to just start by writing a regular unit test and then abstract it away. We can take the steps that are common to coming up with all individual examples and replace them with generators.

속성을 찾는 좋은 방법은 먼저 일반 단위 테스트를 작성하고 추상화하는 것입니다. 모든 개별 예제를 작성하는 데 공통적인 단계를 수행하고 이를 제네레이터로 대체할 수 있습니다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)

속성 기반 테스트가 익숙하지도 않은데, 머리를 쥐어짜서 속성을 생각하기보다 익숙한 예제 기반 테스트를 먼저 짜는 게 좋은 출발이다. 예제 기반 테스트를 짜다 보면 공통된 규칙이 보이기도 한다. 속성 기반 테스트로 변경할 좋은 타이밍이다.

리스트 마지막 요소를 리턴하는 List.last/1 함수 예다.

test "List.last/1" do
  assert -23 == List.last([-23])
  assert 5 == List.last([1, 2, 3, 4, 5])
  assert 3 == List.last([5, 4, 3])
end

앞에 요소가 뭐가 있건 제일 뒤에 요소만 신경 쓰면 된다. 이걸 속성 기반 테스트로 바꿔보자.

property "picks the last number" do
  forall {list, know_last} <- {list(number()), number()} do
    known_list = list ++ [know_last]
    know_last == List.last(known_list)
  end
end

무작위 리스트와 숫자를 생성한다. 생성한 숫자를 know_last 에 저장한다. 이 값이 List.last/1 함수 리턴 값인지를 검사하면 된다. know_last 도 생성해서 리스트를 만들고 검사하는 방법이 재미있다.

Invariants

Invariant는 프로그램에서 항상 참이어야 조건이다.

Some programs and functions are complex to describe and reason about. They could be needing a ton of small parts to all work right for the whole to be correct, or we may not be able to assert their quality because it is just hard to define. …

In a software system, we can identify similar conditions or facts that should always remain true. We call them invariants, and testing for them is a great way to get around the fact that things may just be ambiguous otherwise.

  • A store cannot sell more items than it has in stock.
  • In a binary search tree, the left child is smaller and the right child is greater than their parent’s value.
  • Once you insert a record in a database, you should be able to read it back and not see it as missing.

일부 프로그램과 기능은 설명하고 추론하기가 복잡합니다. 전체가 올바르게 작동하려면 수많은 작은 부품이 필요할 수도 있고 정의하기 어렵기 때문에 품질을 단언(assert)할 수 없을 수도 있습니다. …

소프트웨어 시스템에서는 항상 사실이어야 하는 유사한 조건이나 사실을 식별할 수 있습니다. 우리는 이를 불변성(invariant)라고 부르며, 이를 테스트하는 것은 상황이 모호할 수 있다는 사실을 피할 수 있는 좋은 방법입니다.

  • 상점에서는 재고보다 더 많은 품목을 판매할 수 없습니다.
  • 이진 검색 트리에서 왼쪽 자식은 부모 값보다 작고 오른쪽 자식은 더 큽니다.
  • 데이터베이스에 레코드를 삽입하면 해당 레코드를 다시 읽을 수 있고 누락된 것으로 표시되지 않아야 합니다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)

불변성을 이용해 속성 기반 테스트를 짜는 건 좋은 출발이다. 간단한 불변성을 추가해 가며 문제 공간에 대한 이해와 소스 코드에 대한 이해도 높일 수 있다.

시시해 보이는 불변성을 속성으로 잡아서 테스트를 짜다 보면 이게 의미가 있나 싶은 순간도 있다. 하지만 불변성이 쌓이면 확신을 얻을 수 있다. 튼튼한 로프는 작은 실을 모아서 만든다. 책의 저자처럼 많이 해 본 사람의 말을 믿고 해보는 것도 괜찮다.

A single invariant on its own is usually not enough to show a piece of code is working as expected. But if we can come up with many invariants and small things to validate, and if they all always remain true, we can gain a lot more confidence in the ability of our code base to work well. Strong ropes are built from smaller threads put together. In papers or proofs about why a given data structure works, you’ll find that almost all aspects of its success comes from ensuring a few invariants are respected.

단일 불변성만으로는 일반적으로 코드 조각이 예상대로 작동하고 있음을 보여주기에 충분하지 않습니다. 그러나 우리가 검증할 많은 불변성과 작은 것들을 생각해 낼 수 있고 그것들이 모두 항상 사실로 유지된다면 우리는 코드 기반이 잘 작동하는 능력에 대해 훨씬 더 많은 확신을 얻을 수 있습니다. 튼튼한 로프는 더 작은 실을 모아서 만들어집니다. 특정 데이터 구조가 작동하는 이유에 대한 논문이나 증명에서 성공의 거의 모든 측면은 몇 가지 불변성을 준수하는 데서 비롯된다는 것을 알 수 있습니다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)

List.sort/1 함수를 예로 들어보자. 속성을 오름차순으로 정렬된 리스트를 리턴해야 한다로 잡으면 어떻게 될까? 우리는 테스트하기 위해 오름차순으로 정렬하는 함수가 필요하게 된다. 똑같은 동작을 하는 테스트를 위한 함수가 필요하다. 기존의 정렬 함수를 뛰어넘는 속도를 가진 정렬 함수를 만들었다면 Modeling 방법을 사용해야 한다. 검증된 정렬 함수와 똑같은 결과가 나오는 식으로 검사한다. 그게 아니라면 전체가 아닌 부분에 적용할 수 있는 불변성을 찾아야 한다.

모든 요소는 뒤에 오는 요소보다 작아야 한다로 속성을 정의할 수 있다. 어떠한 쌍(pair)에도 적용되는 불변성이다. 이걸 테스트로 작성한다면 다음과 같다.

property "a sorted list has ordered pairs" do
  forall list <- list(term()) do
    is_ordered(Enum.sort(list)
    end

      def is_ordered([a, b | t]) do
        a <= b and is_ordered([b | t])
      end

      def is_ordered(_) do
        true
      end
    end

여기에 속성을 몇가지 더 추가할 수 있다. 어쩌면 당연하다고 생각해서 따로 테스트를 작성할 생각을 안 한 속성일수도 있다. 몇가지 예를 들면 다음과 같다.

  • The sorted and unsorted lists should both have the same size.
  • Any element in the sorted list has to have its equivalent in the unsorted list (no element added).
  • Any element in the unsorted list has to have its equivalent in the sorted list (no element dropped).

  • 정렬된 목록과 정렬되지 않은 목록의 크기는 모두 동일해야 합니다.
  • 정렬된 목록의 모든 요소는 정렬되지 않은 목록에도 해당 요소가 있어야 합니다(요소가 추가되지 않음).
  • 정렬되지 않은 목록의 모든 요소는 정렬된 목록에 해당 요소가 있어야 합니다(삭제된 요소 없음).

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)

이런 속성을 테스트로 구현하면 다음과 같다.

property "a sorted list keeps its size" do
  forall l <- list(number()) do
    length(l) == length(Enum.sort(l))
  end
end

property "no element added" do
  forall l <- list(number()) do
    sorted = Enum.sort(l)
    Enum.all?(sorted, fn element -> element in l end)
  end
end

property "no element deleted" do
  forall l <- list(number()) do
    sorted = Enum.sort(l)
    Enum.all?(l, fn element -> element in sorted end)
  end
end

속성 기반 테스트를 짤 때, 불변성을 테스트하는 건 좋은 출발이다. 문제 공간과 소스 코드에 대한 이해를 높여가면서 다른 속성을 생각하는데 도움을 준다. 불변성을 생각하는 건 속성 기반 테스트를 짤 때만 도움이 되는 건 아니다. C++ 과 같은 언어에서 assert로 코드를 검증할 때도 사용할 수 있다.

Symmetric Properties

대칭 속성을 사용해 테스트하는 방법이다. data를 encoding 하고 decoding 한다면 원래 data와 같아야 한다. 반대 동작을 하는 함수 쌍이 있을 때, 생각할 수 있는 속성이다.

property "symmetric encoding/decoding" do
  forall data <- list({atom(), any()}) do
    encoded = encode(data)
    is_binary(encoded) and data == decode(encoded)
  end
end

def encode(t), do: :erlang.term_to_binary(t)
  def decode(t), do: :erlang.binary_to_term(t)

마치며

테스트하면 떠오르는 코드는 주로 예제 기반 테스트(Example-based Testing)다. 예제 기반 테스트는 프로그래머가 입력을 잘 정해서 테스트해야 한다. edge case를 빠짐없이 잘 넣어야 한다는 말이다. 반면 속성 기반 테스트(Property-based Testing)는 일종의 메타 테스트에 가깝다. 예제 기반 테스트처럼 테스트할 값을 정하는 게 아니라 입력값의 속성을 정하고 그런 값들이 들어왔을 때, 어떤 동작을 만족시키는지를 테스트로 작성한다. 문제 공간에 대한 이해와 테스트 대상 코드에 대한 이해가 더 필요하다.

속성 기반 테스트가 좋은 것 같으니 시도해 보려고 하면 막막하다. 이때 속성을 생각할 수 있는 Modeling, Generalizing Example Tests, Invariants, Symmetric Properties 패턴을 알아두는 게 도움이 된다.

링크