ExUnit で GenServer をテストするときのテスト対象呼び出しパターン
一般に自動テストを書く場合、呼び出し方の実利用との近さとセットアップの簡単さのトレードオフがあって、組織や個人の方針に従って選択していくと思います。例えば単体テストと結合テストのそれぞれをどれだけ書くかの方針として表されるかもしれません。
ExUnit で GenServer コールバックを実装したモジュールをテストする場合、このトレードオフに対して、およそ以下の 3 つのパターンがとれます。
- Application の子孫として GenServer を起動
- ExUnit の子として GenServer を起動
- コールバックを直接呼ぶ
これらのパターンを覚えておくことで、トレードオフ選択方針に従ったテストが書けるようになると思います。それぞれ解説していきます。
1. Application の子孫として GenServer を起動
ExUnit を実行するとテスト対象の Application 本体やその依存 Application も起動しています。なので Application にぶらさがっている GenServer に対してテストすることができます。このパターンのバリエーションとして、テスト対象が DynamicSupervisor から起動されるなら、ExUnit から DynamicSupervisor.start_child できますし、テスト対象が HTTP サーバなら HTTP リクエストを送ることも可能です。図で表すと以下のようになります。
このパターンで実装してよくはまるのが、テスト対象の終了処理が非同期で前のケースの終了処理の完了前に次のテストを実行してしまいテスト結果が意図しない値になることです。以下のシーケンス図はテストケース 1 の終了処理がテストケース 2 の実行までに完了せず意図しない値になる例を表しています。これを防ぐためにはテスト対象のプロセス数やプロセスの状態をポーリングするなどの手段で終了処理の完了をきっちり待つ必要があります。
2. ExUnit の子として GenServer を起動
テスト対象の GenServer を Application にぶらさげず直接 ExUnit の子プロセスとして起動することもできます。図で表すと以下のようになります。
いわゆるコンストラクタインジェクションでテスト用パラメータやモックを差し込みたい場合このパターンを使うことになります。Elixir では Config でテスト全体にはテスト用パラメータを差し込めますが、特定のケースだけ別パラメータを差し込みたい場合に必要になります。
3. コールバックを直接呼ぶ
GenServer コールバックの handle_call や handle_cast などは純粋な関数です。よって、 GenServer を起動せずこれらの関数をテストケースから直接呼ぶことも可能です。
パターンとトレードオフの整理
以上のパターンとトレードオフを整理すると以下の関係になります。
パターン名 | 実利用との近さ | セットアップの簡単さ |
---|---|---|
Application の子孫として GenServer を起動 | ☆☆☆ | ☆ |
ExUnit の子として GenServer を起動 | ☆☆ | ☆☆ |
コールバックを直接呼ぶ | ☆ | ☆☆☆ |
実装サンプル
サンプルアプリ
簡単なチャットアプリを例にしてテストの実装例を見ていきます。チャットアプリはおよそ以下の図のようなシーケンスで動くとします。User1 と User2 が ChatRoom に join 済みで、User1 が ChatRoom に send_msg でメッセージを送ると User2 は ChatRoom から handle_msg で User1 が送ったメッセージを受信します。ソース全体は https://github.com/keshihoriuchi/samples/tree/master/elixir/exunit_gen_server/chat_app にあります。
ChatRoomWorker
以下が ChatRoom の実装です。今回直接テスト対象にするモジュールです。name: __MODULE__
で起動しているのでテスト対象アプリケーションでシングルトンということになります。send_msg を受信したら join 済みで送信元以外の user_id に対してメッセージを転送します。
defmodule ChatApp.ChatRoomWorker do
alias ChatApp.UserWorker
use GenServer
def start_link(_arg) do
{:ok, _pid} = GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def join(pid, user_id) do
GenServer.call(__MODULE__, {:join, pid, user_id})
end
def send_msg(from_id, msg) do
GenServer.cast(__MODULE__, {:send_msg, from_id, msg})
end
## 以下GenServerコールバック
@impl true
def init(_arg) do
{:ok, %{users: %{}}}
end
@impl true
def handle_call({:join, pid, user_id}, _from, %{users: users} = state) do
ref = Process.monitor(pid)
{:reply, :ok, %{state | users: Map.put(users, ref, {pid, user_id})}}
end
@impl true
def handle_cast({:send_msg, from_id, msg}, %{users: users} = state) do
Enum.each(users, fn {_ref, {pid, to_id}} ->
if from_id != to_id do
UserWorker.handle_msg(pid, from_id, msg)
end
end)
{:noreply, state}
end
@impl true
def handle_info({:DOWN, ref, :process, _pid, _msg}, %{users: users} = state) do
{:noreply, %{state | users: Map.delete(users, ref)}}
end
end
UserWorker
以下が User の実装です。今回は直接テスト対象のモジュールにはしませんが、ChatRoomWorker のテスト例を示すために必要なモジュールです。handle_msg でメッセージを受信したら init 時に渡された pid にメッセージを転送します。実際には WebSocket などで Elixir の外側にメッセージを転送すると思いますが例を簡単にするために Elixir で完結させています。
defmodule ChatApp.UserWorker do
use GenServer, restart: :temporary
alias ChatApp.ChatRoomWorker
def start_link(pid) do
GenServer.start_link(__MODULE__, %{pid: pid})
end
def join(pid, user_id) do
GenServer.call(pid, {:join, user_id})
end
def send_msg(pid, msg) do
GenServer.cast(pid, {:send_msg, msg})
end
def handle_msg(pid, from_id, msg) do
GenServer.cast(pid, {:handle_msg, from_id, msg})
end
## 以下GenServerコールバック
@impl true
def init(%{pid: pid}) do
{:ok, %{pid: pid}}
end
@impl true
def handle_call({:join, user_id}, _from, state) do
:ok = ChatRoomWorker.join(self(), user_id)
{:reply, :ok, Map.put(state, :user_id, user_id)}
end
@impl true
def handle_cast({:send_msg, msg}, %{user_id: user_id} = state) do
:ok = ChatRoomWorker.send_msg(user_id, msg)
{:noreply, state}
end
@impl true
def handle_cast({:handle_msg, from_id, msg}, %{pid: pid, user_id: user_id} = state) do
send(pid, {user_id, from_id, msg})
{:noreply, state}
end
end
Application
Application はシンプルに 1 つの ChatRoomWorker を起動します。
defmodule ChatApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
ChatApp.ChatRoomWorker
]
opts = [strategy: :one_for_one, name: ChatApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
テスト実装例
ChatRoomWorker を、"User1 が ChatRoom に send_msg でメッセージを送ると User2 は ChatRoom から handle_msg で User1 が送ったメッセージを受信する"というシナリオでテストしていきます。
1. Application の子孫として GenServer を起動
以下が実装例です。
describe "Applicationの子孫としてGenServerを起動パターン" do
test "User1 が ChatRoom に send_msg でメッセージを送ると User2 は ChatRoom から handle_msg で User1 が送ったメッセージを受信する" do
{:ok, pid1} = UserWorker.start_link(self())
:ok = UserWorker.join(pid1, "user1")
{:ok, pid2} = UserWorker.start_link(self())
:ok = UserWorker.join(pid2, "user2")
UserWorker.send_msg(pid1, "hello")
assert_receive({"user2", "user1", "hello"})
end
end
UserWorker の join と send_msg で間接的に ChatRoomWorker の機能をテストします。ChatRoomWorker がテスト中に出てこないテスト対象を大きめにとったテストになります。
2. ExUnit の子として GenServer を起動
以下が実装例です。
describe "ExUnitの子としてGenServerを起動パターン" do
test "User1 が ChatRoom に send_msg でメッセージを送ると User2 は ChatRoom から handle_msg で User1 が送ったメッセージを受信する" do
{:ok, room_pid} = GenServer.start_link(ChatRoomWorker, %{})
:ok = GenServer.call(room_pid, {:join, self(), "user1"})
:ok = GenServer.call(room_pid, {:join, self(), "user2"})
:ok = GenServer.cast(room_pid, {:send_msg, "user1", "hello"})
assert_receive({:"$gen_cast", {:handle_msg, "user1", "hello"}})
end
end
ChatRoomWorker が公開している関数を呼ぶと、name: __MODULE__
のプロセスに対する操作になってしまうので、GenServer の関数を直接呼んで name がついてないプロセスを生成して操作していきます。
assert_receive している{:"$gen_cast", {:handle_msg, "user1", "hello"}}
は、 ChatRoomWorker.send_msg が呼んでいる GenServer.cast が送ってくるメッセージです。
3. コールバックを直接呼ぶ
以下が実装例です。
describe "コールバックを直接呼ぶパターン" do
test "User1 が ChatRoom に send_msg でメッセージを送ると User2 は ChatRoom から handle_msg で User1 が送ったメッセージを受信する" do
assert {:noreply, %{users: %{}}} =
ChatRoomWorker.handle_cast(
{:send_msg, "user1", "hello"},
%{users: %{ref_dummy: {self(), "user2"}}}
)
assert_receive({:"$gen_cast", {:handle_msg, "user1", "hello"}})
end
end
handle_cast を純粋に関数としてテストしています。関数の副作用の handle_msg の確認は上のパターンと同様に assert_receive します。
モックを注入する
以上の"2. ExUnit の子として GenServer を起動"パターンと、"3. コールバックを直接呼ぶ"パターンの実装例は以下の問題があるといえます。
- ChatRoomWorker のテストなのに UserWorker.handle_msg の実装や GenServer.cast の実装に依存してしまっている
- そもそもテスト対象の ChatRoomWorker と UserWorker が相互依存関係になってしまっている
これらの問題を ChatRoomWorker の実装を変更して、起動時の引数として handle_msg を呼び出すモジュール名を与えることで解決できます。ただし、ChatRoomWorker の実装がテストのために複雑になってしまうということなので、解決した問題より重い問題を抱えさせている、と見做されることもよくあると思います。
以下が ChatRoomWorker の変更例です。start_link->init とモジュールを渡していって state の user_mod に記憶させておき、send_msg の handle_cast で user_mod.handle_msg を呼びだします。細かいことをいうと UserWorker の定数がまだ start_link の中に含まれているので相互依存は解消できていませんが、徹底したければこれも start_link から与えるようにして解消できます。
@@ -1,45 +1,45 @@
defmodule ChatApp.ChatRoomWorker do
alias ChatApp.UserWorker
use GenServer
def start_link(_arg) do
- {:ok, _pid} = GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
+ {:ok, _pid} = GenServer.start_link(__MODULE__, %{user_mod: UserWorker}, name: __MODULE__)
end
def join(pid, user_id) do
GenServer.call(__MODULE__, {:join, pid, user_id})
end
def send_msg(from_id, msg) do
GenServer.cast(__MODULE__, {:send_msg, from_id, msg})
end
## 以下GenServerコールバック
@impl true
- def init(_arg) do
- {:ok, %{users: %{}}}
+ def init(%{user_mod: user_mod}) do
+ {:ok, %{users: %{}, user_mod: user_mod}}
end
@impl true
def handle_call({:join, pid, user_id}, _from, %{users: users} = state) do
ref = Process.monitor(pid)
{:reply, :ok, %{state | users: Map.put(users, ref, {pid, user_id})}}
end
@impl true
- def handle_cast({:send_msg, from_id, msg}, %{users: users} = state) do
+ def handle_cast({:send_msg, from_id, msg}, %{users: users, user_mod: user_mod} = state) do
Enum.each(users, fn {_ref, {pid, to_id}} ->
if from_id != to_id do
- UserWorker.handle_msg(pid, from_id, msg)
+ user_mod.handle_msg(pid, from_id, msg)
end
end)
{:noreply, state}
end
@impl true
def handle_info({:DOWN, ref, :process, _pid, _msg}, %{users: users} = state) do
{:noreply, %{state | users: Map.delete(users, ref)}}
end
end
モックを差し込めるようになった ChatRoomWorker のテストコードは以下のようになります。
defmodule UserMock do
def handle_msg(pid, from_id, msg) do
send(pid, {:mock, from_id, msg})
end
end
describe "ExUnitの子としてGenServerを起動パターン (モック差込版)" do
test "User1 が ChatRoom に send_msg でメッセージを送ると User2 は ChatRoom から handle_msg で User1 が送ったメッセージを受信する" do
{:ok, room_pid} = GenServer.start_link(ChatRoomWorker, %{user_mod: UserMock})
:ok = GenServer.call(room_pid, {:join, self(), "user1"})
:ok = GenServer.call(room_pid, {:join, self(), "user2"})
:ok = GenServer.cast(room_pid, {:send_msg, "user1", "hello"})
assert_receive({:mock, "user1", "hello"})
end
end
describe "コールバックを直接呼ぶパターン (モック差込版)" do
test "User1 が ChatRoom に send_msg でメッセージを送ると User2 は ChatRoom から handle_msg で User1 が送ったメッセージを受信する" do
assert {:noreply, %{users: %{}}} =
ChatRoomWorker.handle_cast(
{:send_msg, "user1", "hello"},
%{users: %{ref_dummy: {self(), "user2"}}, user_mod: UserMock}
)
assert_receive({:mock, "user1", "hello"})
end
end
Discussion