🌡️

ExUnit で GenServer をテストするときのテスト対象呼び出しパターン

2023/02/26に公開

一般に自動テストを書く場合、呼び出し方の実利用との近さとセットアップの簡単さのトレードオフがあって、組織や個人の方針に従って選択していくと思います。例えば単体テストと結合テストのそれぞれをどれだけ書くかの方針として表されるかもしれません。

ExUnit で GenServer コールバックを実装したモジュールをテストする場合、このトレードオフに対して、およそ以下の 3 つのパターンがとれます。

  1. Application の子孫として GenServer を起動
  2. ExUnit の子として GenServer を起動
  3. コールバックを直接呼ぶ

これらのパターンを覚えておくことで、トレードオフ選択方針に従ったテストが書けるようになると思います。それぞれ解説していきます。

1. Application の子孫として GenServer を起動

ExUnit を実行するとテスト対象の Application 本体やその依存 Application も起動しています。なので Application にぶらさがっている GenServer に対してテストすることができます。このパターンのバリエーションとして、テスト対象が DynamicSupervisor から起動されるなら、ExUnit から DynamicSupervisor.start_child できますし、テスト対象が HTTP サーバなら HTTP リクエストを送ることも可能です。図で表すと以下のようになります。

Application の子孫として GenServer を起動を表す図

このパターンで実装してよくはまるのが、テスト対象の終了処理が非同期で前のケースの終了処理の完了前に次のテストを実行してしまいテスト結果が意図しない値になることです。以下のシーケンス図はテストケース 1 の終了処理がテストケース 2 の実行までに完了せず意図しない値になる例を表しています。これを防ぐためにはテスト対象のプロセス数やプロセスの状態をポーリングするなどの手段で終了処理の完了をきっちり待つ必要があります。

テストケース 1 の終了処理がテストケース 2 の実行までに完了せず意図しない値になる例

2. ExUnit の子として GenServer を起動

テスト対象の GenServer を Application にぶらさげず直接 ExUnit の子プロセスとして起動することもできます。図で表すと以下のようになります。

ExUnit の子として GenServer を起動を表す図

いわゆるコンストラクタインジェクションでテスト用パラメータやモックを差し込みたい場合このパターンを使うことになります。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 に対してメッセージを転送します。

lib/chat_app/chat_room_worker.ex
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 で完結させています。

lib/chat_app/user_worker.ex
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 を起動します。

lib/chat_app/application.ex
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 を起動

以下が実装例です。

test/chat_app_test.exs
  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 を起動

以下が実装例です。

test/chat_app_test.exs
  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. コールバックを直接呼ぶ

以下が実装例です。

test/chat_app_test.exs
  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 から与えるようにして解消できます。

lib/chat_app/chat_room_worker.ex
@@ -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 のテストコードは以下のようになります。

test/chat_app_test.exs
  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