9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ElixirAdvent Calendar 2021

Day 18

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

Posted at

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

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

問題の説明

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

例外発生の LiveViewTest

mount, handle_params におけるテスト

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

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 を用いて以下のように書けます。

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

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

$ 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 のテスト

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

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

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

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

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

$ 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 が発生してもテストプロセスをクラッシュさせないことです。
終わったら副作用発生させないために戻しておく必要があることに気を付けましょう。

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

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

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 とほぼ同じようにテストが書けますね。

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
$ 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

9
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?