web-dev-qa-db-ja.com

ElixirのGenServerの慣用的なテスト戦略は何ですか?

オンラインの天気APIをクエリするモジュールを書いています。 GenServerを監視するアプリケーションとして実装することにしました。

コードは次のとおりです。

defmodule Weather do
  use GenServer

  def start_link() do
    GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
  end

  def weather_in(city, country) do
    GenServer.call(__MODULE__, {:weather_in, city, country_code})
  end

  def handle_call({:weather_in, city, country}) do
    # response = call remote api
    {:reply, response, nil}
  end
end

私のテストでは、setupコールバックを使用してサーバーを起動することにしました。

defmodule WeatherTest do
  use ExUnit.Case

  setup do
    {:ok, genserver_pid} = Weather.start_link
    {:ok, process: genserver_pid}
  end

  test "something" do
    # assert something using Weather.weather_in
  end

  test "something else" do
    # assert something else using Weather.weather_in
  end
end

いくつかの理由から、GenServerを特定の名前で登録することにしました。

  • 誰かが複数のインスタンスを必要とする可能性は低いです

  • Weatherモジュールで、基になるGenServerの存在を抽象化するパブリックAPIを定義できます。ユーザーは、基になるGenServerと通信するためにweather_in関数にPID /名前を提供する必要はありません

  • GenServerを監視ツリーの下に配置できます

テストを実行すると、同時に実行されるため、setupコールバックがテストごとに1回実行されます。したがって、サーバーを起動しようとする同時試行があり、{:error, {:already_started, #PID<0.133.0>}}で失敗します。

Slackで何かできることはないかと尋ねました。おそらく私が知らない慣用的な解決策があります...

説明したソリューションを要約すると、GenServerを実装およびテストする場合、次のオプションがあります。

  1. 各テストでGenServerの独自のインスタンスを開始できるように、サーバーを特定の名前で登録しない。サーバーのユーザーは手動で起動できますが、モジュールのパブリックAPIに提供する必要があります。サーバーは、名前があっても監視ツリーに配置することもできますが、モジュールのパブリックAPIは、通信するPIDを知る必要があります。名前がパラメーターとして渡された場合、関連するPIDを見つけることができると思います(OTPがそれを実行できると思います)。

  2. 特定の名前でサーバーを登録します(サンプルで行ったように)。これで、GenServerインスタンスは1つだけになり、テストは順番に実行する必要があり(async: false)、各テストを開始する必要がありますおよびサーバーを終了します。

  3. サーバーを特定の名前で登録します。すべてが同じ一意のサーバーインスタンスに対して実行される場合、テストは同時に実行できます(setup_allを使用すると、インスタンスはテストケース全体で1回だけ開始できます)。しかし、これはテストに対する間違ったアプローチです。すべてのテストが同じサーバーに対して実行され、その状態が変化するため、互いに混乱します。

ユーザーがこのGenServerの複数のインスタンスを作成する必要がない可能性があることを考慮して、テストの同時実行性を単純化と交換し、ソリューション2を使用したいと思います。

[編集]ソリューション2を試してみましたが、同じ理由で失敗します:already_startedasync: falseに関するドキュメントをもう一度読んだところ、test caseが他のテストケースと並行して実行されないことがわかりました。私が思ったように、テストケースのテストを順番に実行しません。助けて!

24
svarlet

私が注意する重大な問題の1つは、_handle_call_の署名が間違っていることです。これはhandle_call(args, from, state)である必要があります(現在はhandle_call(args)だけです。

私はそれを使ったことがありませんが、私が尊敬している人々は、QuickCheckが実際にGenServerをテストするためのゴールドスタンダードであることを誓います。

ユニットテストレベルでは、GenServerの機能アーキテクチャのために別のオプションがあります。

予想される引数と状態の組み合わせで_handle_[call|cast|info]_メソッドをテストする場合、GenServerを起動する必要はありません*。テストライブラリを使用してOTPを置き換え、フラットライブラリであるかのようにモジュールコードを呼び出します。これはAPI関数呼び出しをテストしませんが、それらをシンパススルーメソッドとして保持すると、リスクを最小限に抑えることができます。

*遅延返信を使用している場合、このアプローチにはいくつかの問題がありますが、おそらく十分な作業を行うことでそれらを整理することができます。

GenServerにいくつかの変更を加えました。

  • モジュールはその状態を使用しないため、代替のプレミアムWebサービスを追加することで、テストの観点からモジュールをより面白くしました。
  • Handle_call署名を修正しました
  • 状態を追跡するために内部Stateモジュールを追加しました。状態のないGenServerでも、必然的に状態を追加するときに、後で使用するために常にこのモジュールを作成します。

新しいモジュール:

_defmodule Weather do
  use GenServer

  def start_link() do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def weather_in(city, country) do
    GenServer.call(__MODULE__, {:weather_in, city, country_code})
  end

  def upgrade, do: GenServer.cast(__MODULE__, :upgrade)

  def downgrade, do: GenServer.cast(__MODULE__, :downgrade)

  defmodule State do
    defstruct url: :regular
  end

  def init([]), do: {:ok, %State{}}

  def handle_cast(:upgrade, state) do
    {:noreply, %{state|url: :premium}}
  end
  def handle_cast(:downgrade, state) do
    {:noreply, %{state|url: :regular}}
  end

  # Note the proper signature for handle call:
  def handle_call({:weather_in, city, country}, _from, state) do
    response = case state.url do
      :regular ->
        #call remote api
      :premium ->
        #call premium api
    {:reply, response, state}
  end
end
_

そしてテストコード:

_# assumes you can mock away your actual remote api calls
defmodule WeatherStaticTest do
  use ExUnit.Case, async: true

  #these tests can run simultaneously
  test "upgrade changes state to premium" do
    {:noreply, new_state} = Weather.handle_cast(:upgrade, %Weather.State{url: :regular})
    assert new_state.url == :premium
  end
  test "upgrade works even when we are already premium" do
    {:noreply, new_state} = Weather.handle_cast(:upgrade, %Weather.State{url: :premium})
    assert new_state.url == :premium
  end
  # etc, etc, etc...
  # Probably something similar here for downgrade

  test "weather_in using regular" do
    state = %Weather.State{url: :regular}
    {:reply, response, newstate} = Weather.handle_call({:weather_in, "dallas", "US"}, nil, state)
    assert newstate == state   # we aren't expecting changes
    assert response == "sunny and hot"
  end
  test "weather_in using premium" do
    state = %Weather.State{url: :premium}
    {:reply, response, newstate} = Weather.handle_call({:weather_in, "dallas", "US"}, nil, state)
    assert newstate == state   # we aren't expecting changes
    assert response == "95F, 30% humidity, sunny and hot"
  end
  # etc, etc, etc...      
end
_
25
Chris Meyer

プロセスの非常に遅い段階でこの質問と応答に気付いたばかりです。与えられた回答は質の高いものだと思います。とは言うものの、ハーネスのテストを行う際に役立ついくつかのポイントを作成する必要があります。 ExUnit.Callbacksドキュメントからの最初のメモ

The setup_all callbacks are invoked once to setup the test 
case before any test is run and all setup callbacks are run 
before each test. No callback runs if the test case has no tests 
or all tests were filtered out.

基礎となるコードを確認しないと、これは、テストファイルでsetup do/endブロックを使用することは、各テストの前にそのビットのコードを実行することと同じであることを意味するようです。一度だけ書けば便利です。

まったく別の方法に移りますが、コードで「doctests」を使用して、コードとテストの両方を定義します。 python doctestsと同様に、モジュールのドキュメントにテストケースを含めることができます。これらのテストは、標準に従って「混合テスト」で実行されます。ただし、テストはドキュメント内に存在し、明示的に欠点があります。毎回サーバーを起動します(個別のテストファイルの場合のセットアップ/実行/終了の暗黙的な方法とは対照的です。

ドキュメントから、4つのスペースをインデントし、iex>コマンドを入力することで、ドキュメントブロックでドキュメントテストを開始できることがわかります。

@chrismeyerの作品が好きです。ここで私は彼の仕事を引き受けて、少し違うことをします。実際にハンドル関数の代わりにAPI関数をテストします。それは好みとスタイルの問題であり、私はクリスが何度もやったことを正確にやってきました。 doctestフォームも非常に一般的であり、単純なパススルーではない複雑なAPI関数の場合は、API関数自体をテストすることが重要であるため、doctestフォームを確認することは有益だと思います。それで、クリスのスニペットを使用して、これが私がすることです。

@doc """
Start our server.

### Example

We assert that start link gives :ok, pid

    iex> Weather.start_link
    {:ok, pid}
"""
def start_link() do
  GenServer.start_link(__MODULE__, [], name: __MODULE__)
end

@doc """
We get the weather with this funciton.

    iex> {:ok, pid} = Weather.start_link
    iex> Weather.in(pid, "some_city", "country_code")
    expected_response
    iex> Weather.in(pid, "some_other_city", "some_other_code")
    different_expected_response
"""
def weather_in(svr, city, country) doc
  GenServer.call(svr, {:weather_in, city, country_code})
end

上記の手法にはいくつかの利点があります。

  1. Elixirのドキュメントは、コンパイル時に自動的に生成されます
  2. ドキュメントは「mixtest」コマンドに合格するため、ドキュメントに表示される内容は機能します。
  3. あなたのミックスシマンティクスは「ミックステスト」と同じです

コードエディターでの書式設定で少し問題があったので、誰かがこれを少し編集したい場合は、そうしてください。

1
uDude

2番目のオプションがこのようにpidを再利用することであったのか、それとも特に順次実行に依存していたのかはわかりません。しかし、次のようにpidを再利用できるはずです。

setup do
  genserver_pid = case Progress.whereis(:weather) do
    nil -> 
      {:ok, pid} = Weather.start_link
      Progress.register(pid, :weather)
      pid
    pid -> pid
  end

  {:ok, process: genserver_pid}
end

これまでに行った正確なコードが見つからないため、これはメモリからの推定です。

0
behe