オンラインの天気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
を実装およびテストする場合、次のオプションがあります。
各テストでGenServerの独自のインスタンスを開始できるように、サーバーを特定の名前で登録しない。サーバーのユーザーは手動で起動できますが、モジュールのパブリックAPIに提供する必要があります。サーバーは、名前があっても監視ツリーに配置することもできますが、モジュールのパブリックAPIは、通信するPIDを知る必要があります。名前がパラメーターとして渡された場合、関連するPIDを見つけることができると思います(OTPがそれを実行できると思います)。
特定の名前でサーバーを登録します(サンプルで行ったように)。これで、GenServerインスタンスは1つだけになり、テストは順番に実行する必要があり(async: false
)、各テストを開始する必要がありますおよびサーバーを終了します。
サーバーを特定の名前で登録します。すべてが同じ一意のサーバーインスタンスに対して実行される場合、テストは同時に実行できます(setup_all
を使用すると、インスタンスはテストケース全体で1回だけ開始できます)。しかし、これはテストに対する間違ったアプローチです。すべてのテストが同じサーバーに対して実行され、その状態が変化するため、互いに混乱します。
ユーザーがこのGenServerの複数のインスタンスを作成する必要がない可能性があることを考慮して、テストの同時実行性を単純化と交換し、ソリューション2を使用したいと思います。
[編集]ソリューション2を試してみましたが、同じ理由で失敗します:already_started
。 async: false
に関するドキュメントをもう一度読んだところ、test caseが他のテストケースと並行して実行されないことがわかりました。私が思ったように、テストケースのテストを順番に実行しません。助けて!
私が注意する重大な問題の1つは、_handle_call
_の署名が間違っていることです。これはhandle_call(args, from, state)
である必要があります(現在はhandle_call(args)
だけです。
私はそれを使ったことがありませんが、私が尊敬している人々は、QuickCheckが実際にGenServerをテストするためのゴールドスタンダードであることを誓います。
ユニットテストレベルでは、GenServerの機能アーキテクチャのために別のオプションがあります。
予想される引数と状態の組み合わせで_handle_[call|cast|info]
_メソッドをテストする場合、GenServerを起動する必要はありません*。テストライブラリを使用してOTPを置き換え、フラットライブラリであるかのようにモジュールコードを呼び出します。これはAPI関数呼び出しをテストしませんが、それらをシンパススルーメソッドとして保持すると、リスクを最小限に抑えることができます。
*遅延返信を使用している場合、このアプローチにはいくつかの問題がありますが、おそらく十分な作業を行うことでそれらを整理することができます。
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
_
プロセスの非常に遅い段階でこの質問と応答に気付いたばかりです。与えられた回答は質の高いものだと思います。とは言うものの、ハーネスのテストを行う際に役立ついくつかのポイントを作成する必要があります。 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
上記の手法にはいくつかの利点があります。
コードエディターでの書式設定で少し問題があったので、誰かがこれを少し編集したい場合は、そうしてください。
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
これまでに行った正確なコードが見つからないため、これはメモリからの推定です。