1 minute read

같은 테스트를 다른 파라미터로 실행하고 싶은 상황이 있다. 예를 들어 key-value 저장소를 구현하는데, 똑같은 인터페이스를 제공하는 다른 두 구현체가 같은 동작을 하는지를 검증하고 싶다.

함수 안에서 루프를 돌며 각 구현체에 대해 테스트하면 되지 않을까? 그렇게 할 수도 있다. 다만 테스트 함수 전에 호출하는 setup 함수를 활용하지 못한다. 테스트 구조화를 도와줘서 실제 테스트 코드를 간결하게 만드는 setup 함수도 살리고 싶다.

이런 경우 사용할 수 있는 게 파라미터화 테스트(parameterized test)다. elixir 1.18에 표준 테스트 라이브러리인 ExUnit에 추가됐다.

테스트 대상

key-value 저장소 구현체가 두 개 있다면? 파라미터화 테스트하기 좋은 대상이다. Property-Based Testing(속성 기반 테스트)가 더 좋긴 하지만 Example-based Testing(예제 기반 테스트)으로 범위를 좁혀서 생각하자.

defmodule Store do
  @callback start_link() :: {:ok, any()}
  @callback put(any(), any(), any()) :: any()
  @callback get(any(), any()) :: any()
end

defmodule AgentStore do
  @behaviour Store
  use Agent

  def start_link do
    Agent.start_link(fn -> %{} end)
  end

  def put(pid, key, value) do
    Agent.update(pid, &Map.put(&1, key, value))
  end

  def get(pid, key) do
    Agent.get(pid, &Map.get(&1, key))
  end
end

defmodule EtsStore do
  @behaviour Store

  def start_link do
    table = :ets.new(__MODULE__, [:set, :public])
    {:ok, table}
  end

  def put(table, key, value) do
    :ets.insert(table, {key, value})
  end

  def get(table, key) do
    case :ets.lookup(table, key) do
      [{^key, value}] -> value
      [] -> nil
    end
  end
end

key-value 저장소를 agent로도 구현했고 ets로도 구현했다.

파라미터화 테스트가 있기 전

매크로를 사용해서 테스트 코드를 여러 벌 만드는 방식을 사용했다.

defmodule StoreTest do
  use ExUnit.Case

  for store <- [AgentStore, EtsStore] do
    test "#{inspect(store)} read after write" do
      {:ok, table} = unquote(store).start_link()

      unquote(store).put(table, :user_id, 42)

      assert unquote(store).get(table, :user_id) == 42
    end
  end
end

아니 for 가 왜 저기에 있어? elixir는 런타임 뿐만 아니라 매크로를 사용해 코드를 생성할 때도 사용할 수 있다. storeAgentStore, EtsStore 가 들어가는 테스트 코드가 각각 만들어진다.

setup을 사용하지 못한다. 테스트 환경 세팅 코드와 테스트코드가 같이 있다.

파라미터화 테스트 사용

parameterize 리스트 요소가 setup 인자로 들어간다. setup을 살릴 수 있고 매크로를 사용하지 않아도 되니 unquote 매크로가 사라졌다.

defmodule StoreTest do
  use ExUnit.Case,
    async: true,
    parameterize: [
      %{store: AgentStore},
      %{store: EtsStore}
    ]

  setup %{store: store} do
    {:ok, table} = store.start_link()
    {:ok, store: store, table: table}
  end

  test "read after write", %{store: store, table: table} do
    store.put(table, :user_id, 42)
    assert store.get(table, :user_id) == 42
  end
end

마치며

파라미터화 테스트는 테스트 셋업 코드를 분리할 수 있는 setup 함수를 사용할 수 있어서 코드를 더 깔끔하게 구조화할 수 있다.

강력한 매크로를 사용해 다들 그럭저럭 극복하고 있어서일까? 추가된 시기가 늦은 감이 있다.