7 minute read

예제로 보는 Property-Based Testing (feat. Kata09: Back to the Checkout)’ 글에서 ’Kata09: Back to the Checkout’ 코드를 짜고 속성 기반 테스트(Property-Based Testing)를 했다. 짠 테스트를 보면 주요 흐름(Happy Path)에 대한 테스트만 있다.

Our tests are positive, happy-path tests, validating that everything is right and good things happen. Negative tests, by comparison, try to specifically exercise underspecified scenarios to find what happens in less happy paths.

우리의 테스트는 모든 것이 옳고 좋은 일이 일어남을 검증하는 주요 흐름(happy path) 테스트입니다. 이에 비해 부정적인 테스트(negative testing)는 불충분한 시나리오를 구체적으로 실행하여 주요 흐름이 아닌 곳에서 어떤 일이 일어나는지 찾으려고 합니다.

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

Example-based Testing에서는 주요 흐름을 벗어나는 예외 흐름(unhappy path) 케이스를 입력으로 잘 생각해야 한다. 속성 기반 테스트에서는 어떻게 하면 될까? 테스트 입력을 만드는 제네레이터(generator)가 도움이 된다. 속성(property)의 범위를 넓히거나 우리가 속성에 건 제약(contraint)을 풀어서 예외 흐름을 탈 수 있는 입력을 만들 수 있다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)’ 책 ’Negative Testing’ 챕터 내용을 정리했다.

속성의 범위를 넓히기

’예제로 보는 Property-Based Testing (feat. Kata09: Back to the Checkout)’ 글에서 속성 기반 테스트를 한 코드를 떠올려보자.

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

item_price_list/1 함수는 각종 제약 조건하에 상품 리스트, 총가격, 가격 리스트를 만들어낸다. 속성의 범위를 넓혀서 테스트를 한 번 돌려보면 어떨까?

property "negative testing for expected results" do
  forall {items, prices, specials} <- lax_lists() do
    try do
      is_integer(Checkout.total(items, prices, specials))
    rescue
      _ -> false
    end
  end
end

def lax_lists() do
  {
    list(utf8()),
    list({utf8(), integer()}),
    list({utf8(), integer(), integer()})
  }
end
1) property negative testing for expected results (CheckoutNegativeTest)
   test/checkout_negative_test.exs:5
   Property Elixir.CheckoutNegativeTest.property negative testing for expected results() failed. Counter-Example is:
   [{[""], [], []}]

   Counter example stored.

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

비어있는 리스트가 입력으로 들어갔을 때, 예외가 발생한다. 예외를 던지도록 하고 예외 흐름(unhappy) 테스트에서 예외가 발생했을 때를 정상 처리한다.

property "negative testing for expected results" do
  forall {items, prices, specials} <- lax_lists() do
    try do
      is_integer(Checkout.total(items, prices, specials))
    rescue
      e in [RuntimeError] ->
        String.starts_with?(e.message, "unknown item:")

      _ ->
        false
    end
  end
end

코드를 좀 더 살펴보면 리스트가 비어있어서 에러가 난 게 아니라 상품이 반드시 가격 리스트 안에 있다는 가정을 해서 에러가 발생한다.

defp apply_regular(items, price_list) do
  Enum.sum(
    for {name, count} <- items do
      count * cost_of_item(price_list, name)
    end
  )
end

defp cost_of_item(price_list, name) do
  case List.keyfind(price_list, name, 0) do
    nil -> raise RuntimeError, message: "unknown item: #{name}"
    {_, price} -> price
  end
end

가격 리스트에서 상품을 발견하지 못하면 에러를 내게 한다. edge 케이스를 생각보다 쉽게 테스트할 수 있다. 예제 기반 테스트처럼 edge 케이스 입력값을 고심하는 게 아니라 속성을 넓게 해서 테스트를 돌려보고 발견하면 된다.

반복적으로 계속 돌려도 되지만 좀 더 효율적으로 해보자.

통계를 보고 유효한 값의 비율을 높이기

속성의 범위를 넓혀서 테스트하니 궁금하다. 값이 어떤 비율로 들어가는 것일까? Checkout.total/3 함수 리턴값이 정수로 나오는 계산 가능한 값이 많을까? 아니면 에러가 나서 제대로 계산을 못 하는 값이 더 많을까?

속성을 잘 설계했는지는 통계를 수집해서 확인하면 된다. 속성 기반 테스트 프레임워크에는 이런 통계를 수집해서 보여주는 기능이 있다. 다 있는지는 모르겠다. 적어도 좋은 속성 기반 테스트 프레임워크에는 있다.

앞에서 테스트한 코드에서 속성 통계를 수집해 보자.

property "expected results", [:verbose] do
  forall {items, prices, specials} <- lax_lists() do
    collect(
      try do
        is_integer(Checkout.total(items, prices, specials))
      rescue
        e in [RuntimeError] ->
          String.starts_with?(e.message, "unknown item:")

        _ ->
          false
      end,
      item_list_type(items, prices)
    )
  end
end

defp item_list_type(items, prices) do
  if Enum.all?(items, &has_price(&1, prices)) do
    :valid
  else
    :prices_missing
  end
end

defp has_price(item, price_list) do
  case List.keyfind(price_list, item, 0) do
    nil -> false
    {_, _price} -> true
  end
end

통계를 수집하는 collect/2 함수를 호출하면 된다.

90.00% :prices_missing
10.00% :valid

유효한 입력이 10% 밖에 안 나온다. 유효한 입력의 비율을 높여보자.

One common trick to do this is to take our very lax generator and make it a bit stricter. We can do that with a kind of hybrid approach where we not only generate entirely random items, but also purposefully put in repeating predictible items:

이를 수행하는 일반적인 방법의 하나는 매우 느슨한 생성기를 좀 더 엄격하게 만드는 것입니다. 우리는 완전히 무작위 항목을 생성할 뿐만 아니라 의도적으로 반복되는 예측 가능한 항목을 넣는 일종의 하이브리드 접근 방식을 통해 이를 수행할 수 있습니다.

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

의도한 항목과 무작위 항목을 같이 사용해서 유효한 입력값의 비율을 높인다.

defp lax_lists() do
  known_items = ["A" , "B" , "C"]
  maybe_known_item_gen = elements(known_items ++ [utf8()])

  {
    list( maybe_known_item_gen), # <-- 1
    list({maybe_known_item_gen, integer()}), # <-- 2
    list({maybe_known_item_gen, integer(), integer()}) # <-- 3
  }
end

여기서 elements/1 함수는 실제 속성 기반 테스트가 실행될 때, 값을 뽑는 제네레이터를 리턴한다. 그래서 1, 2, 3번 코드에서 사용한 maybe_known_item_gen 이 같은 값이 아니다. 이미 알고 있는 아이템인 A, B, C를 후보에 넣어서 유효한 입력의 값의 비율을 높이는 시도다.

83.00% :prices_missing
17.00% :valid

그래도 좀 올라갔다. 드라마틱하게 올라가지는 않았다. 그래서 책에 결과를 첨부하지 않았을까? 직접 돌려보고 통계를 보니 기대보단 많이 올라가진 않는다.

1) property expected results (CheckoutNegativeTest)
   test/checkout_negative_test.exs:20
   Property Elixir.CheckoutNegativeTest.property expected results() failed. Counter-Example is:
   [{["A"], [], [{"A", 0, 0}]}]

   code: nil
   stacktrace:
     (propcheck 1.4.1) lib/properties.ex:260: PropCheck.Properties.handle_check_results/2
     test/checkout_negative_test.exs:20: (test)

0개일 때, 특별 가격이 0인 입력이 들어갈 때, 에러가 발생한다. 0으로 나누기 때문이다. 예를 들어 3개를 사면 특별 가격으로 제품을 파는데, 이걸 계산하려면 해당 제품 개수에서 3을 나눠서 적용하기 때문이다. 만약 0개일 때, 특별 가격을 적용한다는 입력이 들어오면 0으로 나누게 된다.

property "negative testing for expected results" do
  forall {items, prices, specials} <- lax_lists() do
    try do
      is_integer(Checkout.total(items, prices, specials))
    rescue
      e in [RuntimeError] ->
        e.message == "invalid list of specials" ||
        String.starts_with?(e.message, "unknown item:")

      _ ->
        false
    end
  end
end

특별 가격 리스트가 유효하지 않다는 에러를 예외 흐름 테스트에서 처리한다.

프로덕션 코드에는 특별 가격 리스트가 유효한지 검사하는 코드를 추가한다.

def total(item_list, price_list, specials) do
  if not valid_special_list(specials) do
    raise RuntimeError, message: "invalid list of specials"
  end

  counts = count_seen(item_list)
  {counts_left, prices} = apply_specials(counts, specials)
  prices + apply_regular(counts_left, price_list)
end

def valid_special_list(list) do
  Enum.all?(list, fn {_, x, _} -> x != 0 end)
end

이제 다른 방법도 살펴보자. 속성의 제약 조건을 완화해서 테스트 해보는 것이다.

속성의 제약을 풀기

코드를 살짝 수정해 제약 조건을 풀었을 때, 어떻게 되는지 테스트해 볼 수 있다. 그런 입력에 대해서도 올바른 대답을 내거나 에러를 일으켜야 한다.

price_list/0 함수에서 만들어낸 임의의 제품 가격 리스트에서 중복을 허용한다면 어떻게 될까?

defp price_list() do
  let price_list <- non_empty(list({non_empty(utf8()), integer()})) do
    Enum.sort(price_list)
  end
end
1) property sums without specials (but with metrics) (CheckoutTest)
   test/checkout_test.exs:11
   Property Elixir.CheckoutTest.property sums without specials (but with metrics)() failed. Counter-Example is:
   [
     {["??", "??", "??", "??", "??", "??", "??", "??", "??",
       "??", "??", "??", "??", "??", "??", "??", "??", "??",
       "??", "??", "??", "??"], -21, [{"??", -1}, {"??", 0}]}
   ]

테스트가 실패한다. 코드 내부에서만 제품 가격이 하나여야 한다를 암묵적으로 전제할 뿐 제대로 검사하고 있지 않다.

제품 가격 리스트가 올바른지 검사하는 함수인 Checkout.valid_price_list/1 함수를 추가하고 테스트한다.

property "list of items with duplicates" do
  forall price_list <- dupe_list() do
    false == Checkout.valid_price_list(price_list)
  end
end

defp dupe_list() do
  let items <- non_empty(list(utf8())) do
    vector(length(items) + 1, {elements(items), integer()})
  end
end

특별 가격 리스트도 중복 항목이 있으면 안 되므로 Checkout.valid_special_list/1 함수 테스트를 추가한다.

property "list of items with specials" do
  forall special_list <- dupe_special_list() do
    false == Checkout.valid_special_list(special_list)
  end
end

defp dupe_special_list() do
  let items <- non_empty(list(utf8())) do
    vector(length(items) + 1, {elements(items), integer(), integer()})
  end
end

프로덕션 코드를 구현한다.

defmodule Checkout do
  def valid_price_list(list) do
    sorted = Enum.sort(list)
    length(list) == length(Enum.dedup_by(sorted, fn {x, _} -> x end))
  end

  def valid_special_list(list) do
    sorted = Enum.sort(list)

    Enum.all?(list, fn {_, x, _} -> x != 0 end) &&
      length(list) == length(Enum.dedup_by(sorted, fn {x, _, _} -> x end))
  end
end

중복 없는 상품 리스트와 특별 가격 리스트를 컴파일 타임에 검사할 방법이 없다. 그래서 런타임 에러를 내게 한다.

defmodule Checkout do
  def total(item_list, price_list, specials) do
    if not valid_price_list(price_list) do
      raise RuntimeError, message: "invalid list of prices"
    end

    if not valid_special_list(specials) do
      raise RuntimeError, message: "invalid list of specials"
    end

    counts = count_seen(item_list)
    {counts_left, prices} = apply_specials(counts, specials)
    prices + apply_regular(counts_left, price_list)
  end
end

추가한 에러를 예외 흐름 테스트에서 정상 처리한다. 잘못된 입력이 들어갈 때, 일으키는 해당 에러는 기대되는 동작이기 때문이다.

property "negative testing for expected results" do
  forall {items, prices, specials} <- lax_lists() do
    try do
      is_integer(Checkout.total(items, prices, specials))
    rescue
      e in [RuntimeError] ->
        e.message == "invalid list of prices" ||
        e.message == "invalid list of specials" ||
        String.starts_with?(e.message, "unknown item:")

      _ ->
        false
    end
  end
end

중복 없는 상품 리스트를 인자로 넘기는 테스트를 중복 제거를 안 하게 해서 테스트했다. 중복된 항목이 들어갈 때, 생기는 문제를 발견하고 프로덕션 코드에 검사하는 코드를 넣었다. 예제 기반 테스트와 비교해 속성 기반 테스트에서는 속성의 제약을 비교적 간단하게 풀 수 있다.

마치며

예제로 보는 Property-Based Testing (feat. Kata09: Back to the Checkout)’ 글에서 짠 속성 기반 테스트의 속성 범위를 넓히고 제약을 풀어서 예외 흐름(unhappy path) 테스트를 짰다. 예외 흐름 테스트는 사용자의 입력이나 클라이언트 코드를 호출하는 프로그래머의 입력을 테스트할 수 있다.

예제 기반 테스트와 달리 속성 기반 테스트는 이런 예외 흐름을 테스트하기가 상대적으로 더 쉽다. 예제 기반 테스트는 edge case를 잘 발견하고 그걸 예제로 넣어야 한다. 반면 속성 기반 테스트는 속성의 범위를 넓히거나 속성의 제약을 풀어서 예상하지 못한 입력을 만들 수 있다. 속성 기반 테스트 자체가 생각하기 힘들고 짜기 힘들어서 그렇지 짜기만 해 놓으면 이렇게 유용하게 써먹을 수 있다.

링크