Phoenix LiveViewTest で handle_event 中の例外をテストする

@koyo-miyamura です！
最近は本業の傍ら、Elixirコミュニティの方で副業しており、実務に基づく知見が色々溜まってきたので公開したいと思います。

https://zenn.dev/koyo/articles/c00527aef96c9f

Phoenix LiveView には LiveViewTest という E2E テストの仕組みがあります。
今回はこちらについて書きたいと思います。
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html

# 問題の説明
前回の記事をベースに LiveView のひな型コードを生成します。

https://qiita.com/koyo-miyamura/items/663230bf4bc61e80a711

## 例外発生の LiveViewTest
### mount, handle_params におけるテスト

生成されたコードのうち、詳細画面のコードは以下です。

```elixir:lib/paginate_sample_web/live/user_live/show.ex
defmodule PaginateSampleWeb.UserLive.Show do
  use PaginateSampleWeb, :live_view

  alias PaginateSample.Users

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  @impl true
  def handle_params(%{"id" => id}, _, socket) do
    {:noreply,
     socket
     |> assign(:page_title, page_title(socket.assigns.live_action))
     |> assign(:user, Users.get_user!(id))}
  end

  defp page_title(:show), do: "Show User"
  defp page_title(:edit), do: "Edit User"
end
```

指定した id を基に Users テーブルから引いています。
この時 **「指定した id の User がいない場合に例外が出ないこと」** をテストしたいと思います。
これは `assert_raise/2` を用いて以下のように書けます。

https://hexdocs.pm/ex_unit/1.12.3/ExUnit.Assertions.html#assert_raise/2

```elixir:test/paginate_sample_web/live/user_live_test.exs
  describe "Show" do
    setup [:create_user]

...

    test "not found", %{conn: conn, user: user} do
      assert_raise(Ecto.NoResultsError, fn ->
        {:ok, _show_live, _html} = live(conn, Routes.user_show_path(conn, :show, user.id + 1))
      end)
    end
```

テストを実行してみると通りますね！

```bash
$ mix test
Compiling 1 file (.ex)
...................

Finished in 0.3 seconds (0.1s async, 0.2s sync)
19 tests, 0 failures

Randomized with seed 786103
```

## handle_event のテスト

では同じノリでクリックした時にユーザを削除するコードのテストをしてみましょう。
対象は以下です。

```elixir:lib/paginate_sample_web/live/user_live/index.ex
  def handle_event("delete", %{"id" => id}, socket) do
    user = Users.get_user!(id)
    {:ok, _} = Users.delete_user(user)

    {:noreply, assign(socket, :users, list_users())}
  end
```

先ほどと同じようにテストを書いてみます。

```elixir:test/paginate_sample_web/live/user_live_test.exs
  describe "Index" do
    setup [:create_user]
...
    test "deletes not existing user in listing", %{conn: conn, user: user} do
      {:ok, index_live, _html} = live(conn, Routes.user_index_path(conn, :index))

      user |> PaginateSample.Repo.delete()

      assert_raise(Ecto.NoResultsError, fn ->
        index_live |> element("#user-#{user.id} a", "Delete") |> render_click()
      end)
    end
```

実行してみると...エラーが！！

```bash
$ mix test
..22:36:43.535 [error] GenServer #PID<0.529.0> terminating
** (Ecto.NoResultsError) expected at least one result but got none in query:

from u0 in PaginateSample.Users.User,
  where: u0.id == ^"231"

    (ecto 3.7.2) lib/ecto/repo/queryable.ex:156: Ecto.Repo.Queryable.one!/3
    (paginate_sample 0.1.0) lib/paginate_sample_web/live/user_live/index.ex:41: PaginateSampleWeb.UserLive.Index.handle_event/3
    (phoenix_live_view 0.17.9) lib/phoenix_live_view/channel.ex:382: anonymous fn/3 in Phoenix.LiveView.Channel.view_handle_event/3
    (telemetry 1.1.0) /home/koyo/src/paginate_sample/deps/telemetry/src/telemetry.erl:320: :telemetry.span/3
    (phoenix_live_view 0.17.9) lib/phoenix_live_view/channel.ex:215: Phoenix.LiveView.Channel.handle_info/2
    (stdlib 3.15.2) gen_server.erl:695: :gen_server.try_dispatch/4
    (stdlib 3.15.2) gen_server.erl:771: :gen_server.handle_msg/6
    (stdlib 3.15.2) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Last message: %Phoenix.Socket.Message{event: "event", join_ref: 0, payload: %{"cid" => nil, "event" => "delete", "type" => "click", "value" => %{"id" => "231"}}, ref: "1", topic: "lv:phx-FuphfSZQV6wF_ADL"}
22:36:43.544 [error] GenServer #PID<0.527.0> terminating
** (Ecto.NoResultsError) expected at least one result but got none in query:

from u0 in PaginateSample.Users.User,
  where: u0.id == ^"231"

    (ecto 3.7.2) lib/ecto/repo/queryable.ex:156: Ecto.Repo.Queryable.one!/3
    (paginate_sample 0.1.0) lib/paginate_sample_web/live/user_live/index.ex:41: PaginateSampleWeb.UserLive.Index.handle_event/3
    (phoenix_live_view 0.17.9) lib/phoenix_live_view/channel.ex:382: anonymous fn/3 in Phoenix.LiveView.Channel.view_handle_event/3
    (telemetry 1.1.0) /home/koyo/src/paginate_sample/deps/telemetry/src/telemetry.erl:320: :telemetry.span/3
    (phoenix_live_view 0.17.9) lib/phoenix_live_view/channel.ex:215: Phoenix.LiveView.Channel.handle_info/2
    (stdlib 3.15.2) gen_server.erl:695: :gen_server.try_dispatch/4
    (stdlib 3.15.2) gen_server.erl:771: :gen_server.handle_msg/6
    (stdlib 3.15.2) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Last message: {:EXIT, #PID<0.524.0>, {%Ecto.NoResultsError{message: "expected at least one result but got none in query:\n\nfrom u0 in PaginateSample.Users.User,\n  where: u0.id == ^\"231\"\n"}, [{Ecto.Repo.Queryable, :one!, 3, [file: 'lib/ecto/repo/queryable.ex', line: 156]}, {PaginateSampleWeb.UserLive.Index, :handle_event, 3, [file: 'lib/paginate_sample_web/live/user_live/index.ex', line: 41]}, {Phoenix.LiveView.Channel, :"-view_handle_event/3-fun-0-", 3, [file: 'lib/phoenix_live_view/channel.ex', line: 382]}, {:telemetry, :span, 3, [file: '/home/koyo/src/paginate_sample/deps/telemetry/src/telemetry.erl', line: 320]}, {Phoenix.LiveView.Channel, :handle_info, 2, [file: 'lib/phoenix_live_view/channel.ex', line: 215]}, {:gen_server, :try_dispatch, 4, [file: 'gen_server.erl', line: 695]}, {:gen_server, :handle_msg, 6, [file: 'gen_server.erl', line: 771]}, {:proc_lib, :init_p_do_apply, 3, [file: 'proc_lib.erl', line: 226]}]}}


  1) test Index deletes not existing user in listing (PaginateSampleWeb.UserLiveTest)
     test/paginate_sample_web/live/user_live_test.exs:80
     ** (EXIT from #PID<0.524.0>) an exception was raised:
         ** (Ecto.NoResultsError) expected at least one result but got none in query:
     
     from u0 in PaginateSample.Users.User,
       where: u0.id == ^"231"
     
             (ecto 3.7.2) lib/ecto/repo/queryable.ex:156: Ecto.Repo.Queryable.one!/3
             (paginate_sample 0.1.0) lib/paginate_sample_web/live/user_live/index.ex:41: PaginateSampleWeb.UserLive.Index.handle_event/3
             (phoenix_live_view 0.17.9) lib/phoenix_live_view/channel.ex:382: anonymous fn/3 in Phoenix.LiveView.Channel.view_handle_event/3
             (telemetry 1.1.0) /home/koyo/src/paginate_sample/deps/telemetry/src/telemetry.erl:320: :telemetry.span/3
             (phoenix_live_view 0.17.9) lib/phoenix_live_view/channel.ex:215: Phoenix.LiveView.Channel.handle_info/2
             (stdlib 3.15.2) gen_server.erl:695: :gen_server.try_dispatch/4
             (stdlib 3.15.2) gen_server.erl:771: :gen_server.handle_msg/6
             (stdlib 3.15.2) proc_lib.erl:226: :proc_lib.init_p_do_apply/3

..............

Finished in 0.3 seconds (0.1s async, 0.2s sync)
19 tests, 1 failure

Randomized with seed 263356
```

見てみると、 `..22:36:43.535 [error] GenServer #PID<0.529.0> terminating` と出ており GenServer のプロセスが落ちているように見えます。 `Ecto.NoResultsError` は出ているのですが...。

調べてみると、どうも handle_event 中に例外が起きると `:exit` シグナルが発生してテストプロセスごとクラッシュしてしまうらしく、 `assert_raise` ではテストできないようです。

## 解決方法
これを解決するモジュールを作成し `assert_error_inside_process` というアサーション関数を実装してみます。
ポイントとしては `Process.flag(:trap_exit, true)` とすることで `:exit` が発生してもテストプロセスをクラッシュさせないことです。
終わったら副作用発生させないために戻しておく必要があることに気を付けましょう。

https://hexdocs.pm/elixir/1.13/Process.html#flag/2

https://www.erlang.org/doc/man/erlang.html#process_flag-2

そこまでいけば、あとは `catch_exit/1` することで発生したエラーを補足できます。

https://hexdocs.pm/ex_unit/1.13/ExUnit.Assertions.html#catch_exit/1

なお `capture_log/1` をしておかないとテスト実行時に、プロセスが exit したことのログが出てしまうので、デフォルトで出さないようにしておきます。

https://hexdocs.pm/ex_unit/ExUnit.CaptureLog.html#capture_log/2

```elixir
defmodule PaginateSampleWeb.LiveViewTestHelpers do
  @moduledoc """
  Helper for LiveViewTest
  """

  import ExUnit.Assertions
  import ExUnit.CaptureLog

  @doc """
  Assert if target error occurs inside process, e.g. inside handle_event.

      assert_error_inside_process(Ecto.NoResultsError, fn ->
        live
        |> form("#form", valid_form_data)
        |> render_submit()
      end)
  """
  def assert_error_inside_process(exception, fun, capture_log \\ true) when is_function(fun) do
    assert_func = fn ->
      # Note:
      # When error is raised inside live process which is linked to test process, :exit signal is arrived to test process then crashes it.
      # To avoid that, trap :exit signal.
      prev_flag = Process.flag(:trap_exit, true)

      try do
        {{%exit_error_module{}, _stacktrace}, _} = catch_exit(fun.())
        assert exception == exit_error_module
      after
        Process.flag(:trap_exit, prev_flag)
      end
    end

    exec_function(assert_func, capture_log)
  end

  defp exec_function(fun, _capture_log = true) do
    capture_log(fn -> fun.() end)
  end

  defp exec_function(fun, _capture_log = false) do
    fun.()
  end
end
```

このモジュールを import してテストを書いてみます。
assert_raise とほぼ同じようにテストが書けますね。

```elixir:test/paginate_sample_web/live/user_live_test.exs
  describe "Index" do
    setup [:create_user]
...
    import PaginateSampleWeb.LiveViewTestHelpers

    test "deletes not existing user in listing", %{conn: conn, user: user} do
      {:ok, index_live, _html} = live(conn, Routes.user_index_path(conn, :index))

      user |> PaginateSample.Repo.delete()

      assert_error_inside_process(Ecto.NoResultsError, fn ->
        index_live |> element("#user-#{user.id} a", "Delete") |> render_click()
      end)
    end
```

```bash
$ mix test
...................

Finished in 0.3 seconds (0.1s async, 0.2s sync)
19 tests, 0 failures

Randomized with seed 35107
```

通るようになりましたb

# まとめ
Phoenix LiveViewTest で handle_event 中の例外をテストする方法を紹介しました。
handle_event だけではなくプロセス中で例外が発生するケースのテスト全般に使えると思うのでぜひ使ってみてください。

前回のページネーションと一緒に Github にコードを置いておいたのでぜひ触ってみてください:thumbsup:
https://github.com/koyo-miyamura/paginate_sample_ex