10 minute read

’속성 기반 테스트(Property-based Testing)와 속성 정의에 도움을 주는 네 가지 방법’ 글에서 속성 기반 테스트를 간략히 살펴봤다. 대략 감은 잡겠지만 속성 기반 테스트를 짜라고 하면 막막하다. 이럴 때는 좋은 예제가 도움을 줄 수 있다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)’ 책 Properties-Driven Development 챕터에서 좋은 예제를 발견했다. Test-Driven Development (TDD)은 예제 기반 테스트를 주로 사용하는데, 예제 대신 속성 기반 테스트를 먼저 짜면서 설명하는 챕터다. 굳이 속성 주도 개발을 하지 않더라도 속성 기반 테스트를 익히기에 이해하기 쉽고 퀄리티가 높은 코드라서 도움이 된다.

먼저 어떤 문제를 해결할 코드를 짤 것인지부터 살펴보자.

Kata09: Back to the Checkout 문제 설명

Back to the supermarket. This week, we’ll implement the code for a checkout system that handles pricing schemes such as “apples cost 50 cents, three apples cost $1.30.”

Kata09: Back to the Checkout

상품별 가격이 있다. 여기에 특별 가격을 추가한다. 예를 들어 사과 하나에 50 cent인데, 3개를 사면 20 cent를 할인한 130 cent에 살 수 있다.

Item   Unit      Special
       Price     Price
--------------------------
  A     50       3 for 130
  B     30       2 for 45
  C     20
  D     15

위와 같은 가격표가 있다고 하면 다음과 같이 가격이 매겨진다.

| 장바구니 | 가격 |
|---------+-----|
| A       |  50 |
| AA      | 100 |
| AAA     | 130 |
| AAAA    | 180 |
| AAAAA   | 230 |
| AAAAAA  | 260 |
| AABA    | 160 |
| AABBA   | 175 |

3개를 사면 130이므로 4개를 사면 3개는 130이고 나머지 1개는 정상 가격인 50으로 계산해야 한다. 좀 더 사면 싸게 살 수 있다. 친근한 문제다. 생활 밀착형 문제이다. 홈런볼을 묶음이 아니라 낱개로 사본 적이 언제인지 기억나지 않는다.

일반 가격 속성 기반 테스트

여러 개를 사면 깎아주는 특별 가격을 제외하고 일반 가격을 먼저 테스트하고 구현한다. 예제 기반 테스트라면 아래와 같은 테스트를 생각할 수 있다.

20 = Checkout.total(["A", "B", "A"], [{"A", 5}, {"B", 10}], [])
20 = Checkout.total(["A", "B", "A"], [{"A", 5}, {"B", 10}, {"C", 100}], [])
115 = Checkout.total(["F", "B", "C"], [{"F", 5}, {"B", 10}, {"C", 100}], [])
expected_price = Checkout.total(chosen_items, price_list, special_price_list)

첫 번째 인자는 고른 상품 리스트, 두 번째 인자는 가격 리스트, 세 번째 인자는 특별 가격 리스트다. 특별 가격 리스트는 다음에 구현한다. 그래서 비워놓았다.

속성 기반 테스트를 짜보자.

defmodule CheckoutTest do
  use ExUnit.Case
  use PropCheck

  property "sums without specials" do
    forall {item_list, expected_price, price_list} <- item_price_list() do
      expected_price == Checkout.total(item_list, price_list, [])
    end
  end
end

item_price_list/0 제네레이터(generator)가 핵심이다. 속성 기반 테스트 숙련도는 제네레이터를 어떻게 짜느냐에서 드러나는 것 같다. item_price_list/0 함수는 아이템 리스트, 계산 결과로 나와야 하는 가격, 가격 리스트를 리턴한다. 이 제네레이터가 만든 계산 결과가 프로덕션 코드인 Checkout.total/3 함수 결과와 같은지를 검사한다.

생각해 보면 예제 기반 테스트에서 함수를 호출할 때, 원하는 결과 값은 이미 계산된 값이다. 함수를 호출해서 결과값을 복사해서 비교하거나 손으로 계산해서 결과값을 확인 후에 함수 리턴 값과 비교한다. 예를 들어 Checkout.total(["A", "B", "A"], [{"A", 5}, {"B", 10}], []) 리턴 값을 계산해서 20 을 리턴해야 하는 것을 계산해 놓고 이걸 비교하는 식이다. 반면 속성 기반 테스트는 손으로 계산한 20 이 어떤 속성을 가지는지 규칙을 찾아서 프로그래밍한다.

여기서 의문을 가질 수 있다. item_price_list/0 제네레이터 구현에 Checkout.total/3 함수 구현의 핵심이 포함되는 것 아닐까? 왜냐하면 item_price_list/0 에서 답으로 사용할 총계산 금액도 같이 만들어내기 때문이다. 이런 테스트가 의미 있을까? ’속성 기반 테스트(Property-based Testing)와 속성 정의에 도움을 주는 네 가지 방법’ 글에서 설명한 Modeling 방식으로 속성을 만들어서 그렇다. Modeling은 테스트 대상 코드가 가진 속성을 최대한 단순하게 구현한다. 최적화가 들어가면 모양이 많이 달라지지만 최적화가 없는 코드이면 프로덕션 코드와 제네레이터 사이에 핵심 구현이 공유될 수 있다. 제네레이터는 입력을 만드는 코드이고 프로덕션 코드는 출력을 만드는 코드다. 비슷한 핵심 구현이 들어간다고 해도 목적이 다르기에 전체적인 구현은 달라질 수밖에 없다.

item_price_list 함수 구현

item_price_list/0 제네레이터는 {"A", 50} 과 같은 가격 정보가 담긴 Tuple 리스트를 만들고 이걸 사용해서 아이템 개수를 랜덤하게 생성해 가격 정보를 계산한다. 예를 들어 A 상품이 두 개이면 총가격은 100 을 만들어 낸다.

defp item_price_list() do
  let price_list <- price_list() do
    let {item_list, expected_price} <- item_list(price_list) do
      {item_list, expected_price, price_list}
    end
  end
end

price_list 함수 구현 및 확인

price_list/0 함수는 {"A", 50} 튜플 리스트를 랜덤하게 만든다. 하나의 아이템에 여러 가격이 붙을 수 없다. 그래서 중복 항목을 제거해 준다.

defp price_list() do
  let price_list <- non_empty(list({non_empty(utf8()), integer()})) do
    sorted = Enum.sort(price_list)
    Enum.dedup_by(sorted, fn {x, _} -> x end)
  end
end

생성하는 값을 확인해 보자. IEx(Interactive Elixir Shell)을 test 환경으로 연다. 테스트 코드에 있는 함수를 호출하기 때문이다.

$ MIX_ENV=test iex -S mix
Interactive Elixir (1.16.3) - press Ctrl+C to exit (type h() ENTER for help)
iex>

여기서 바로 CheckoutTest 모듈에 있는 함수를 호출할 수 없다. 기본 컴파일 대상 파일이 아니기 때문이다. mix test 커맨드를 입력하면 exs 확장자를 가지는 테스트 파일을 모두 컴파일하고 실행한다. 이렇게 별도로 해줘야 한다.

iex> c "apps/checkout/test/checkout_test.exs"
[CheckoutTest]

별로 복잡하지 않다. path를 확인하고 compile 함수 인자로 넘기면 끝이다. CheckoutTest.price_list/0 함수를 호출하면 어떤 값을 생성하는지 볼 수 있지 않을까?

iex> CheckoutTest.price_list
{:"$type",
 [
   parts_type: {:"$type",
    [
      generator: {:typed, #Function<18.50326710/2 in :proper_types.list_gen>},
      is_instance: {:typed,
       #Function<19.50326710/2 in :proper_types.list_is_instance>},
      internal_type: {:"$type",
       [
         env: [
           "$type": [
             parts_type: {:"$type",
              [
                parts_type: {:"$type",
                 [
                   env:
                   ...

제네레이터(generator)가 생성하는 값을 확인하고 싶다면 :proper_gen.pick/1 함수를 직접 호출하거나 PropCheck.produce/1 래핑 함수를 호출하면 된다.

iex> CheckoutTest.price_list |> :proper_gen.pick
{:ok,
 [
   {"V??", 5},
   {"?????", -9},
   {<<239, 180, 190, 203, 131, 109, 16, 200, 189, 195, 151, 240, 144, 128, 130,
      240, 144, 128, 133, 240, 144, 128, 140, 238, 185, 131, 238, 171, 135>>,
    1},
   {"??", 21},
   {"????", 4}
 ]}
iex> CheckoutTest.price_list |> PropCheck.produce
{:ok,
 [
   {<<18, 59, 45>>, -5},
   {"?h, ?/W겗刊", 13},
   {"?????n??", -3},
   {"???", -16},
   {"걎??雯_", -16},
   {"?", 11},
   {"??????\d?=???", -5},
   {"????쌸??\r?????????????", -13},
   {"飼?¸???", -6},
   {"?????˝??~", 6}
 ]}

문자열과 integer로 이뤄진 튜플(tuple) 리스트가 생성되는 걸 확인할 수 있다.

item_list 함수 구현

item_list/1 함수는 장바구니에 담긴 아이템과 총가격을 계산한다.

defp item_list(price_list) do
  sized(size, item_list(size, price_list, {[], 0}))
end

defp item_list(0, _, acc) do acc end
defp item_list(n, price_list, {item_acc, price_acc}) do
  let {item, price} <- elements(price_list) do # <-- 1
    item_list(n - 1, price_list, {[item | item_acc], price + price_acc}) # <-- 2
  end
end

1번 코드에서 price_list 중에 하나를 임의로 고른다. 2번 코드로 아이템을 추가하고 총가격을 업데이트한다.

Checkout.total 프로덕션 함수 구현

프로덕션 코드인 Checkout.total/3 은 간단하다. 아이템 리스트를 순회하며 가격을 더하면 된다. 특별 가격은 아직 구현하지 않아서 제외한다.

defmodule Checkout do
  def total(item_list, price_list, _specials) do
    Enum.sum(
      for item <- item_list do
        elem(List.keyfind(price_list, item, 0), 1)
      end
    )
  end
end

결과 - 속성 기반 테스트와 통계

이제 구현이 끝났다. 속성을 정의하면 제네레이터가 값을 만들어서 테스트한다. 값을 만들 때, 한쪽으로 치우치진 않을까? 속성 기반 테스트를 하면서 통계도 같이 보자.

property "sums without specials (but with metrics)", [:verbose] do
  forall {item_list, expected_price, price_list} <- item_price_list() do
    (expected_price == Checkout.total(item_list, price_list, []))
    |> collect(bucket(length(item_list), 10))
  end
end

defp bucket(n, unit) do
  div(n, unit) * unit
end

collect/2 함수는 두 번째 인자로 통계에 사용할 카테고리를 받는다. 십의 자리수 이상만 표시하면 대략적으로 파악이 된다.

$ mix test
==> checkout
OK: The input passed the test.
....................................................................................................
OK: Passed 100 test(s).

27.00% 0
27.00% 10
20.00% 20
20.00% 30
 6.00% 40
..
Finished in 0.1 seconds (0.00s async, 0.1s sync)
2 properties, 0 failures

Randomized with seed 934683

[0, 50) 범위로 잘 만들어내고 있다.

이제 다음은 여러 개를 사면 할인해 주는 특별 가격을 구현할 차례다.

여러 개를 사면 할인해주는 특별 가격 속성 기반 테스트

특별 가격 제네레이터는 앞에서 구현한 일반 가격 제네레이터보다 복잡하다. 간단히 생각할 수 있는 구현 방법의 하나는 일반 가격처럼 무작위로 생성한 다음 특별 가격 개수를 검사해서 총 제품 가격을 계산하는 것이다. 이것보다 좀 더 간단히 계산할 방법이 있을까?

’Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)’ 책에서는 특별 가격을 만족하는 제품 리스트를 구하고 특별 가격을 절대 만족하지 않는 리스트를 만들어서 더하는 방식으로 속성을 만든다. ’속성 기반 테스트(Property-based Testing)와 속성 정의에 도움을 주는 네 가지 방법’ 글에 있는 ’Generalizing Example Tests’ 방법 예제인 리스트 마지막 요소를 가져오는 함수에 대한 속성 기반 테스트 방법이 떠오른다. 직관적이고 단단하게 만들 수 있는 제네레이터 구현 방법이다.

item_price_special 함수는 특별 가격을 포함해서 아이템 목록과 총계산 금액을 만들어내는 함수다.

defp item_price_special() do
  let price_list <- price_list() do
    let special_list <- special_list(price_list) do # <-- 1
      let {
           {regular_items, regular_expected},
           {special_items, special_expected}
          } <-
      {regular_gen(price_list, special_list), # <-- 2
       special_gen(price_list, special_list)} do # <-- 3
        {Enum.shuffle(regular_items ++ special_items),
         regular_expected + special_expected,
         price_list,
         special_list}
      end
    end
  end
end

1번 코드에서 price_list 로 만든 제품과 가격 정보를 인자로 받는다. 개당 가격이 얼마인데, 여러 개를 사면 할인해 주는 게 특별 가격이다. 그래서 특별 가격을 만들 때, 일반 가격을 기반으로 만들어야 한다. 2번과 3번 코드가 핵심이다. regular_gen/2 함수는 특별 가격을 절대 만족하지 않게 제품을 고르고 special_gen/2 함수는 특별 가격을 만족하는 개수만큼 제품을 고른다. 그래서 둘 다 special_list 를 인자로 받는다.

special_list 함수 구현

일반 가격 리스트를 받아서 여러 개를 살 때 적용하는 특별 가격을 만든다.

defp special_list(price_list) do
  items = for {name, _} <- price_list do
    name
  end

  let specials <- list({elements(items), choose(2, 5), integer()}) do # <-- 1
    sorted = Enum.sort(specials)
    Enum.dedup_by(sorted, fn {x, _, _} -> x end)
  end
end

[2, 5] 범위로 제품을 선택할 때, 적용하는 특별 가격을 만든다. 이 코드에서 보완해야 할 게 보인다. 1번 코드에서 integer() 함수를 그냥 호출하는 게 아니라 일반 가격으로 제품을 고른 가격 이하로 가격을 만들어줘야 한다. 여러 개를 사면 더 비싼데 누가 여러 개를 사겠냐? 하지만 속성 기반 테스트를 설명하는 이 예제에서 중요하지 않으니 책에서 사용한 예제를 그대로 사용한다.

regular_gen 함수 구현

특별 가격 혜택을 받지 않게 구매할 상품 개수를 만들어내는 함수다

defp regular_gen(price_list, special_list) do
  regular_gen(price_list, special_list, [], 0)
end

defp regular_gen([], _, list, price) do
  {list, price}
end

defp regular_gen([{item, cost} | prices], specials, items, price) do
  count_gen = case List.keyfind(specials, item, 0) do
                {_, limit, _} -> choose(0, limit - 1) # <-- 1
                _ -> non_neg_integer()
              end

  let count <- count_gen do
    regular_gen(
      prices,
      specials,
      let(v <- vector(count, item), do: v ++ items),
      cost * count + price
    )
  end
end

1번 코드를 보면 왜 regular_gen 함수 인자로 특별 가격 리스트를 넘긴 줄 알 수 있다. 특별 가격 혜택을 받지 않는 범위에서 랜덤으로 구매하는 상품 개수를 만들어내기 위함이다.

special_gen 함수 구현

특별 가격 혜택을 받을 수 있게 구매할 상품 리스트를 만든다.

defp special_gen(_, special_list) do
  special_gen(special_list, [], 0)
end

defp special_gen([], items, price) do
  {items, price}
end

defp special_gen([{item, count, cost} | specials], items, price) do
  let multiplier <- non_neg_integer() do # <-- 1
    special_gen(
      specials,
      let(v <- vector(count * multiplier, item), do: v ++ items),
      cost * multiplier + price
    )
  end
end

1번 코드로 특별 가격 혜택을 받는 상품 개수를 몇 세트를 임의로 만들어내서 사용한다.

특별 가격 리스트를 반영하는 프로덕션 코드

특별 가격 리스트를 포함하는 속성 기반 테스트를 작성한다.

property "sums including specials" do
  forall {items, expected_price, prices, specials} <- item_price_special() do
    expected_price == Checkout.total(items, prices, specials)
  end
end

여기까지 구현하고 테스트를 돌리면 다음과 같이 테스트가 실패한다.

1) property sums including specials (CheckoutTest)
   apps/checkout/test/checkout_test.exs:18
   Property Elixir.CheckoutTest.property sums including specials() failed. Counter-Example is:
   [{[<<0>>, <<0>>], -1, [{<<0>>, 0}], [{<<0>>, 2, -1}]}]

   Counter example stored.

   code: nil
   stacktrace:
     (propcheck 1.4.1) lib/properties.ex:244: PropCheck.Properties.handle_check_results/2
     test/checkout_test.exs:18: (test)

특별 가격 리스트를 프로덕션 코드에 반영하지 않았기 때문이다.

def total(item_list, price_list, specials) do
  counts = count_seen(item_list) # <-- 1
  {counts_left, prices} = apply_specials(counts, specials) # <-- 2
  prices + apply_regular(counts_left, price_list) # <-- 3
end

1번 코드로 상품의 전체 개수를 카운팅한다. 2번 코드로 특별 가격을 적용하고 특별 가격 개수에 모자라는 상품 개수는 3번 코드로 처리한다.

구현 상세 코드는 다음과 같다.

defp count_seen(item_list) do
  count = fn x -> x + 1 end

  Map.to_list(
    Enum.reduce(item_list, Map.new(), fn item, m ->
      Map.update(m, item, 1, count)
    end)
  )
end

defp apply_specials(items, specials) do
  Enum.map_reduce(items, 0, fn {name, count}, price ->
    case List.keyfind(specials, name, 0) do
      nil ->
        {
          {name, count}, price
        }

      {_, needed, value} ->
        {
          {name, rem(count, needed)},
          value * div(count, needed) + price
        }
    end
  end)
end

def apply_regular(items, price_list) do
  Enum.sum(
    for {name, count} <- items do
      {_, price} = List.keyfind(price_list, name, 0)
      count * price
    end
  )
end

마치며

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)’ 책에서 본 ’Kata09: Back to the Checkout’ 문제를 풀고 속성 기반 테스트한 코드를 정리했다.

속성을 ’Modeling’ 방식으로 정의하고 테스트한다. 가장 마음에 드는 코드는 특별 가격 혜택을 보는 개수만큼 생성하는 코드와 가격 혜택을 보지 않는 개수를 생성해서 합치는 부분이다. 예제 기반 테스트에서 edge 케이스를 잘 테스트해야 하는 것과 비슷하게 속성 기반 테스트에서는 제네레이터를 잘 만들어야 테스트가 의미 있게 동작할 수 있겠다고 생각했다.

링크