9 minute read

속성 기반 테스트(Property-Based Testing)라 하면 Stateless Property를 사용한 테스트를 연상한다. 상태가 없는(stateless) 속성이다. 즉 입력에 대한 결과가 항상 같아야 한다.

상태가 없는 속성만 정의할 수 있는 게 아니라 상태가 있는(stateful) 속성도 정의할 수 있다. How보다는 What에 집중한 모델(model)을 만든다. 모델은 상태를 유지한다. 프로덕션 코드의 결과와 모델의 결과를 비교하는 방법이다.

Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)’ 책을 참고했다.

Stateful Property 구성 요소

These are the three major components:

  1. A model, which represents what the system should do at a high level.
  2. A generator for commands, which represent the execution flow of the program.
  3. An actual system, which is validated against our model.

다음은 세 가지 주요 구성 요소입니다

  1. 상위 수준에서 시스템이 수행해야 하는 작업을 나타내는 모델.
  2. 프로그램의 실행 흐름을 나타내는 명령 생성기.
  3. 모델에 의해 검증된 실제 시스템(프로덕션 코드).

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

1번 모델을 만들어야 한다. How가 아니라 What에 집중한다. 이 글에서 예로 드는 Cache 모듈은 key-value 저장소에 불과하다. what은 이렇게 간단하다. 하지만 how는 복잡할 수 있다. Concurrency를 지원해야 할 수 있고 전역적으로 사용할 수 있게 디자인해서 여러 node에 걸쳐 실행해야 할 수도 있다. 하지만 what은 간단하다. 추상화 아래에서 구현하는 입장이 아니라 추상화 위에서 보는 what을 모델로 만들어야 한다.

2번 명령 생성기가 필요하다. 모델과 프로덕션 코드에 적용할 명령이다. 유효한 명령을 만들어서 테스트 효율을 높이는 게 목적이다. Cache를 비우는 flush 함수는 하나라도 있을 때, 효과를 볼 수 있다. 그래서 하나도 저장이 안 됐을 때는 호출 하지 않고 하나라도 있을 때, 명령을 만들어내는 식으로 최적화할 수 있다.

Stateful Property 실행

두 단계로 나눠진다. 각각 추상 단계(abstract phase)와 실제 단계(real phase)로 부른다. PropEr에서 이렇게 구현한 거라 다른 속성 기반 테스트 프레임워크에서는 다를 수 있다.

먼저 추상 단계에서는 테스트 시나리오를 만든다.

flowchart LR A[init] --> B[command] B --> C[precondition] C -- "if false" --> B C --> D[next_state] D --> B

프로덕션 코드 실행이 없다. precondition 검사와 next_state 로 모델의 상태 변경을 하며 유효한 명령을 만들어낸다.

다음은 실제 단계다.

flowchart LR A[init] --> B[command] B --> C[precondition] C -- "if false" --> D((FAIL)) C --> E([call]) E -- "on error" --> D E --> F[postcondition] F --> D F --> G[next_state] G --> B

프로덕션 코드 호출이 있다. precondition과 postcondition 검사가 프로덕션 코드 호출 전후에 위치한다. command는 추상 단계에서 만들어 놓은 명령들이다.

Stateful Property 스켈레톤(skeleton) 코드

PropEr 라이브러리는 스켈레톤 코드를 만들어내는 rebar3 테스크가 있다. Elixir로 포팅한 PropCheck 라이브러리에 있나 살펴봤는데, 아쉽게도 찾지 못했다.

$ rebar3 new proper_statem base

위처럼 proper_statem 템플릿으로 생성한 스켈레톤 코드는 다음과 같다. Erlang 코드를 Elixir로 변환했다

defmodule PbtTest do
  use ExUnit.Case
  use PropCheck
  use PropCheck.StateM

  property "stateful property" do
    forall cmds <- commands(__MODULE__) do # <-- 1
      ActualSystem.start_link() # <-- 2
      {history, state, result} = run_commands(__MODULE__, cmds) # <-- 3
      ActualSystem.stop() # <-- 4

      (result == :ok)
      |> aggregate(command_names(cmds))
      |> when_fail(
        IO.puts("""
        History: #{inspect(history)}
        State: #{inspect(state)}
        Result: #{inspect(result)}
        """)
      )
    end
  end
end

앞에서 추상 단계(abstract phase)와 실제 단계(real phase)를 알아봤다. 1번 코드에서 추상 단계가 하는 명령어들을 만들어낸다. 2번과 4번에서 프로덕션 코드 준비 및 정리를 한다. 실제 단계에 해당하는 건 3번 코드이다. 속성 기반 테스트 프레임워크에서 호출하는 콜백을 구현하면 된다.

defmodule PbtTest do
  # initial model value at system start. should be deterministic.
  def initial_state() do
    %{}
  end

  # List of possible commands to run against the system
  def command(_state) do
    oneof([
      {:call, ActualSystem, :some_call, [term(), term()]}
    ])
  end

  # determines whether a command should be valid under the current state
  def precondition(_state, {:call, _mod, _fun, _args}) do
    true
  end

  # given that state prior to the call `{:call, mod, fun, args}`,
  # determine whether the result (res) coming from the actual system
  # makes sense according to the model
  def postcondition(_state, {:call, _mod, _fun, _args}, _res) do
    true
  end

  # assuming the postcondition for a call was true, update the model
  # accordingly for the test to proceed
  def next_state(state, _res, {:call, _mod, _fun, _args}) do
    newstate = state
    newstate
  end
end

이제 예제 코드를 보며 콜백을 어떻게 구현하는지 살펴보자.

Cache 프로덕션 코드

Cache 모듈은 cache/2, find/1 함수로 저장하고 찾을 수 있다.

defmodule Cache do
  use GenServer

  def start_link(n) do #<-- 1
    GenServer.start_link(__MODULE__, n, name: __MODULE__)
  end

  def stop() do
    GenServer.stop(__MODULE__)
  end

  def init(n) do
    :ets.new(:cache, [:public, :named_table])
    :ets.insert(:cache, {:count, 0, n})
    {:ok, :nostate}
  end

  def find(key) do
    case :ets.match(:cache, {:_, {key, :"$1"}}) do
      [[val]] -> {:ok, val}
      [] -> {:error, :not_found}
    end
  end

  def cache(key, val) do
    case :ets.match(:cache, {:"$1", {key, :_}}) do
      [[n]] ->
        :ets.insert(:cache, {n, {key, val}})

      [] ->
        case :ets.lookup(:cache, :count) do
          [{:count, max, max}] ->
            :ets.insert(:cache, [{1, {key, val}}, {:count, 1, max}]) #<-- 2

          [{:count, current, max}] ->
            :ets.insert(:cache, [{current + 1, {key, val}}, {:count, current + 1, max}]) #<-- 3
        end
    end
  end

  def flush() do
    [{:count, _, max}] = :ets.lookup(:cache, :count)
    :ets.delete_all_objects(:cache)
    :ets.insert(:cache, {:count, 0, max})
  end

  def handle_call(_call, _from, state) do
    {:noreply, state}
  end

  def handle_cast(_cast, state) do
    {:noreply, state}
  end

  def handle_info(_msg, state) do
    {:noreply, state}
  end
end

ETS(Erlang Term Storage) 테이블을 사용해서 저장한다.

1번 코드에서 인자로 넘긴 n 만큼 캐시 개수를 관리한다.

$ iex -S mix
iex> Cache.start_link(2)
{:ok, #PID<0.201.0>}
iex> Cache.cache(:key1, :val1)
true
iex> Cache.find(:key1)
{:ok, :val1}
iex> Cache.cache(:key2, :val2)
true
iex> Cache.find(:key2)
{:ok, :val2}
iex> Cache.find(:key1)
{:ok, :val1}
iex> Cache.cache(:key3, :val3)
true
iex> Cache.find(:key1)
{:error, :not_found}
iex> Cache.find(:key2)
{:ok, :val2}

위 테스트 코드에서는 캐시 개수를 2개로 설정했다. 2개가 넘어가면 먼저 삽입된 :key1 에 해당하는 캐시부터 삭제된다. 먼저 삽입된 건 어떻게 먼저 제거할까?

case :ets.lookup(:cache, :count) do
  [{:count, max, max}] ->
    :ets.insert(:cache, [{1, {key, val}}, {:count, 1, max}]) #<-- 2

  [{:count, current, max}] ->
    :ets.insert(:cache, [{current + 1, {key, val}}, {:count, current + 1, max}]) #<-- 3
end

위 예제 코드에서 ETS에 새로 요소를 삽입하는 코드 일부분을 가져왔다. 캐시 저장 개수를 초과했을 때, 제거를 FIFO로 구현하려고 index를 사용한다. 2번 코드에서는 index가 max를 넘어가지 않게 1로 초기화하고 3번 코드에서는 하나씩 증가시켜서 새로운 key, value로 교체한다.

$ iex -S mix
iex> Cache.start_link(2)
{:ok, #PID<0.188.0>}
iex> Cache.cache(:key1, :val1)
true
iex> :ets.tab2list(:cache)
[{1, {:key1, :val1}}, {:count, 1, 2}]
iex> Cache.cache(:key2, :val2)
true
iex> :ets.tab2list(:cache)
[{1, {:key1, :val1}}, {:count, 2, 2}, {2, {:key2, :val2}}]
iex> Cache.cache(:key3, :val3)
true
iex> :ets.tab2list(:cache)
[{1, {:key3, :val3}}, {:count, 1, 2}, {2, {:key2, :val2}}]
iex> Cache.cache(:key4, :val4)
true
iex> :ets.tab2list(:cache)
[{1, {:key3, :val3}}, {:count, 2, 2}, {2, {:key4, :val4}}]

:ets.tab2list/1 함수로 ETS 테이블을 출력해서 확인했다. 마지막으로 삽입한 index를 current로 저장하고 새로운 요소를 삽입할 때, current가 max와 같아지면 1로 초기화해서 새로운 요소로 교체한다.

Stateful Property 짜기

stateful property를 실행하는 코드는 앞에서 본 스켈레톤 코드와 거의 동일하다.

defmodule CacheTest do
  use ExUnit.Case
  use PropCheck
  use PropCheck.StateM
  doctest Cache

  @moduletag timeout: :infinity # <-- 1
  @cache_size 10 # <-- 2

  property "stateful property", [:verbose] do
    forall cmds <- commands(__MODULE__) do
      Cache.start_link(@cache_size)
      {history, state, result} = run_commands(__MODULE__, cmds)
      Cache.stop()

      (result == :ok)
      |> aggregate(command_names(cmds))
      |> when_fail(
        IO.puts("""
        History: #{inspect(history)}
        State: #{inspect(state)}
        Result: #{inspect(result)}
        """)
      )
    end
  end
end

1번 코드는 테스트 프레임워크 디폴트 타임아웃 시간을 늘려주고 2번 코드는 대충 캐시 사이즈를 정한다. 이제 핵심이라고 할 수 있는 콜백 구현 코드를 살펴볼 차례다.

defmodule CacheTest do
  # ...
  defmodule State do
    @cache_size 10
    defstruct max: @cache_size, count: 0, entries: []
  end

  def initial_state() do
    %State{}
  end

  def command(_state) do
    frequency([
      {1, {:call, Cache, :find, [key()]}},
      {3, {:call, Cache, :cache, [key(), val()]}},
      {1, {:call, Cache, :flush, []}}
    ])
  end

  def precondition(%State{count: 0}, {:call, Cache, :flush, []}) do
    false
  end

  def precondition(%State{}, {:call, _mod, _fun, _args}) do
    true
  end

  def key() do
    oneof([range(1, @cache_size), integer()])
  end

  def val() do
    integer()
  end
end

command/1 함수에서는 호출할 명령을 리턴한다. cache 함수 비중을 3으로 뒀다. 비중을 1로 둔 find, flush 함수 보다는 더 자주 출현한다.

precondition/2 함수에서는 캐시 카운트가 0일 때, flush 함수를 호출하는 명령을 생성하지 않게 한다. 나머지 함수는 별다른 검사를 하지 않는다. 난 flush 함수도 아무 조건 없이 두는 걸 선호한다. 비어 있을 때, 호출해도 Invariant 속성을 잘 지키는지 확인할 수 있기 때문이다.

key/0 함수는 [1, @cache_size] 사이 숫자들과 임의의 숫자 중에 하나를 고른다. 그냥 integer/0 함수를 사용하면 캐시를 찾는 find/1 함수가 번번이 실패할 것이다. 그래서 랜덤성을 약간 가미해 일정 범위의 숫자와 아주 임의의 숫자를 후보군으로 두고 하나를 뽑는다. val/0 함수는 찾는 것도 없으니 integer/0 함수를 사용해 완전 임의로 뽑는다.

상태를 변경하는 next_state/3 함수 구현은 아래와 같다.

defmodule CacheTest do
  def next_state(state, _res, {:call, Cache, :flush, _}) do
    %{state | count: 0, entries: []}
  end

  def next_state(
    s = %State{entries: l, count: n, max: m},
    _res,
    {:call, Cache, :cache, [k, v]}) do
    case List.keyfind(l, k, 0) do
      nil when n == m ->
        %{s | entries: tl(l) ++ [{k, v}]}

      nil when n < m ->
        %{s | entries: l ++ [{k, v}], count: n + 1}

      {^k, _} ->
        %{s | entries: List.keyreplace(l, k, 0, {k, v})}
    end
  end

  def next_state(state, _res, {:call, _mod, _fun, _args}) do
    state
  end
end

flush/0, cache/2 함수는 state를 변경한다. 추가하거나 수정하거나 비우는 함수다. find/1 함수는 state 변경에 영향을 주지 않는 CQS(Command Query Seperation)로 따지면 query에 해당하는 함수이기에 state 변경을 처리하는 next_state/3 함수에서는 구현할 게 없다.

어떤 커맨드를 만들어내는지 샘플을 보고 싶다면 :proper_statem.commands/1 함수와 :proper_gen.sample/1 함수를 사용하면 된다. PropCheck가 래핑한 함수가 없어서 PropEr 함수를 직접 호출해야 한다.

$ MIX_ENV=test iex -S mix
iex> c "test/cache_test.exs"
[PbtTest, PbtTest.State]
iex> :proper_gen.sample(:proper_statem.commands(PbtTest))
[{set,{var,1},{call,'Elixir.Cache',find,[1]}},
 {set,{var,2},{call,'Elixir.Cache',cache,[3,4]}},
 {set,{var,3},{call,'Elixir.Cache',flush,[]}},
 {set,{var,4},{call,'Elixir.Cache',cache,[9,-12]}},
 {set,{var,5},{call,'Elixir.Cache',cache,[2,8]}},
 {set,{var,6},{call,'Elixir.Cache',cache,[7,9]}},
 {set,{var,7},{call,'Elixir.Cache',flush,[]}}]
 ...
:ok

어떤 함수를 어떤 인자로 호출하는지 샘플을 볼 수 있다.

precondition/2 함수와 next_state/3 함수를 구현했다. 이제 남은 건 postcondition/3 함수이다. 아래 코드는 리턴 값을 확인해야 하는 find/1 함수의 postcondition/2 함수 구현이다.

defmodule CacheTest do
  def postcondition(%State{entries: l}, {:call, _, :find, [key]}, res) do
    case List.keyfind(l, key, 0) do
      nil ->
        res == {:error, :not_found}

      {^key, val} ->
        res == {:ok, val}
    end
  end

  def postcondition(_state, {:call, _mod, _fun, _args}, _res) do
    true
  end
end

모델에서 캐시를 찾는다.

$ mix test
.....................................................................................................
OK: Passed 100 test(s).

65.63% {Cache, :cache, 2}
19.96% {Cache, :find, 1}
14.40% {Cache, :flush, 0}

첫 stateful 속성 기반 테스트다. 테스트를 실행하면 커맨드 비율을 같이 표시해 준다.

마치며

강력하다. 감동이다. 내가 직접 짜보면 한동안 막막함을 겪겠지만 언젠가는 이 강력함을 나도 맛보고 싶다. Elixir 언어를 사용한다면 stateful 속성을 지원하는 PropCheck 라이브러리를 사용하는 게 낫다. StreamData 라이브러리는 Elixir를 만든 Jose Valim이 발을 담갔지만 stateful 속성을 지원하지 않는다.

모델을 짜다 보면 이런 생각이 들 것 같다. 프로덕션 코드의 모델과 거의 같은데, 이걸 사용해서 속성 기반 테스트하는 게 의미가 있을까? 프로덕션 코드의 모델과 거의 같은 걸로 프로덕션 코드를 테스트하는 거잖아. 프로덕션 코드가 거기에서 하나도 바뀌지 않고 그대로 끝까지 유지된다면 의미가 없을 수도 있겠다. 하지만 갖가지 요구사항들이 좀비처럼 달려든다. 그러면 프로덕션 코드는 더 빨라져야 하고 더 많은 요구사항을 수용할 수 있어야 한다. 처음에는 테스트 모델과 비슷할지 몰라도 나중에는 시작점이 비슷했는지 의심이 들 정도로 달라졌을 것이다.

속성 기반 테스트에 사용하는 모델은 구현 코드를 블랙박스처럼 생각하고 what에 집중해야 한다. how에는 의도적으로 관심을 멀리해야 한다. 비단 속성 기반 테스트 모델이 아니라 일반 구현에도 이렇게 해야 하는 게 아닐까 하는 생각이 들었다. what에 집중해서 요구사항을 만족시키고 그걸 최적화하는 방향으로 가야 하지 않을까?

링크