4 minute read

속성 기반 테스트 프레임워크인 PropEr, PropCheck를 사용하면 ’Cache 예제코드를 테스트하며 맛보는 Stateful Property-Based Testing’ 예제를 손쉽게 병렬 테스트로 바꿀 수 있다.

병렬 테스트로 변경

사용 함수만 바꿔주면 된다. 간단하다. commands/1 대신에 parallel_commands/1 사용하고 run_commands/2 대신에 run_parallel_commands/2 함수를 사용하면 된다.

property "parallel stateful property" do
  forall cmds <- parallel_commands(__MODULE__) do #<-- 1
    Cache.start_link(@cache_size)
    {history, state, result} = run_parallel_commands(__MODULE__, cmds) #<-- 2
    Cache.stop()

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

1번 코드와 2번 코드만 바꿔주면 된다. 테스트를 실행해 보자.

$ mix test
.fff......................................................................................................

간혹 f가 보이는데, 테스트는 통과했다고 나온다. 여기서 f는 테스트가 실패했다는 뜻이 아니다. 테스트할 커맨드를 만드는 게 실패했다는 표시다.

호출하는 함수 두 개를 바꿔서 병렬 테스트로 변경했다.

Erlang VM 타이밍값 조정

BEAM(Erlang virtual machine)에 타이밍값을 변경할 수 있는 디버깅 플래그가 있다. 이걸 사용하면 타이밍 이슈를 찾아낼 수 있을까?

The best you can do to help from the outside is pass some flags to the Erlang VM that will force it to preempt processes more often, even if that has no guarantee of finding anything. You can do this through the +T0 to +T9 emulator flags, which allow you to play with timing values such as how long a process takes to spawn, the amount of work it can do before being scheduled out, or the perceived cost of IO operations. Those are intended for testing only and can be enabled by setting them like this: ERL_ZARGS =“+T4” rebar3 proper -n 10000.

외부에서 도움을 주기 위해 할 수 있는 최선의 방법은 Erlang VM에 일부 플래그를 전달하여 프로세스를 더 자주 선점하도록 하는 것입니다. 비록 아무것도 찾을 수 없다는 보장이 없더라도 말이죠. +T0 ~ +T9 에뮬레이터 플래그를 통해 이 작업을 수행할 수 있습니다. 이 플래그를 사용하면 프로세스가 생성되는 데 걸리는 시간, 예약되기 전에 수행할 수 있는 작업량 또는 인지된 IO 비용과 같은 타이밍 값을 가지고 놀 수 있습니다. 이는 테스트 전용이며 ERL_ZARGS =“+T4” rebar3 proper -n 10000과 같이 설정하여 활성화할 수 있습니다.

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

현재 Cache 구현은 Race Condition이 발생한다. +T 옵션을 사용해서 재연되는지 테스트해 보자.

$ elixir --erl "+T9" -S mix test

Elixir에서는 다음과 같은 커맨드라인 명령으로 BEAM 플래그를 설정해 테스트를 실행할 수 있다. sleep과 양보를 최대한 하는 +T9 옵션을 붙였는데도 테스트 실패는 잘 발생하지 않는다. ’Property-Based Testing with PropEr, Erlang, and Elixir (Fred Hebert, 2019)’ 책에서 소개한 것처럼 나오지 않아서 아쉽다. 공짜 점심 같았는데 말이다.

Race Condition 발생 유도 및 해결

빨라서 다른 Erlang 프로세스의 접근 타이밍 문제가 발생하지 않는다면 느리게 만들면 된다. 혹은 다른 Erlang 프로세스에 양보하면 된다. :erlang.yield/0 함수를 Race Condition이 발생할 수 있는 위치에 추가한다.

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

        case :ets.lookup(cache, :count) do
          [{:count, max, max}] ->
            :ets.insert(:cache, [{1, {key, val}}, {:count, 1, max}])
          [{:count, current, max}] ->
            :ets.insert(:cache, [{current + 1, {key, val}}, {:count, current + 1, max}])
        end
    end
  end

  def flush() do
    [{:count, _, max}] = :ets.lookup(:cache, :count)
    :ets.delete_all_objects(:cache)
    :erlang.yield() # <-- 2

    :ets.insert(:cache, {:count, 0, max})
  end
end

1번 코드는 똑같은 키가 여러 번 삽입되는 걸 유도한다. 2번 코드는 count 값이 올바르지 않게 0으로 덮어써지게 유도한다.

$ mix test

  1) property parallel stateful property (PbtTest)
     test/cache_test.exs:28
     Property Elixir.PbtTest.property parallel stateful property() failed. Counter-Example is:
     [
       {[],
        [
          [
            {:set, {:var, 1}, {:call, Cache, :cache, [1, 0]}},
            {:set, {:var, 2}, {:call, Cache, :cache, [2, 0]}},
            {:set, {:var, 3}, {:call, Cache, :find, [6]}},
            {:set, {:var, 4}, {:call, Cache, :cache, [-1, -2]}}
          ],
          [
            {:set, {:var, 5}, {:call, Cache, :cache, [1, -3]}},
            {:set, {:var, 6}, {:call, Cache, :cache, [-1, 1]}},
            {:set, {:var, 7}, {:call, Cache, :find, [1]}},
            {:set, {:var, 8}, {:call, Cache, :flush, []}}
          ]
        ]}
     ]

     Counter example stored.

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

병렬로 어떤 함수를 호출했을 때, 실패했는지 예제를 보여준다. 감동이다.

발견했으니 고칠 차례다. Message Queue를 사용하게 변경해서 Race Condition을 해결해 보자.

defmodule Cache do
  use GenServer

  def cache(key, val) do #<-- 1
    GenServer.call(__MODULE__, {:cache, key, val})
  end

  def flush() do #<-- 2
    GenServer.call(__MODULE__, :flush)
  end

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

      [] ->
        :erlang.yield()

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

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

    {:reply, :ok, state}
  end

  def handle_call(:flush, _from, state) do
    [{:count, _, max}] = :ets.lookup(:cache, :count)
    :ets.delete_all_objects(:cache)
    :erlang.yield()
    :ets.insert(:cache, {:count, 0, max})
    {:reply, :ok, state}
  end

  # ...
end

1번 코드와 2번 코드처럼 바로 호출하지 않고 GenServer.call/2 를 호출한다. 내부적으로 Message Queue를 사용한다. 즉, cache/2, flush/0 함수 호출은 여러 Erlang 프로세스에서 동시에 할 수 있다. 하지만 cache, flush 에 대한 handle_call 함수는 호출한 순서대로 순차적으로 실행되게 된다.

$ mix test
.ff......................................................................................................

:erlang.yield/0 함수가 그대로 있는데도 테스트가 성공한다. 커밋하기 전에는 호출한 :erlang.yield/0 코드를 제거하도록 하자.

마치며

PropEr, PropCheck 속성 기반 테스트 프레임워크를 사용한다면 함수 호출 두 개만 바꾸면 병렬 테스트를 바꿀 수 있다. 호출 타이밍 이슈를 찾는 환경을 쉽게 만들 수 있다는 얘기다. BEAM(Erlang virtual machine) 타이밍값을 조정해서 Race Condition 발생을 확인하지 못해서 아쉽다. 이게 잘 통하면 써먹을 곳이 많을 텐데 말이다. 그래도 :erlang.yield/0 함수가 있어서 의심 가는 곳에 넣으면 Race Condition을 쉽게 유도할 수 있다. 병렬 테스트도 가능하다니 정말 잘 만든 속성 기반 테스트 프레임워크다.

링크