@koyo-miyamura です!
最近は本業の傍ら、Elixirコミュニティの方で副業しており、実務に基づく知見が色々溜まってきたので公開したいと思います。
Phoenix LiveView には LiveViewTest という E2E テストの仕組みがあります。
今回はこちらについて書きたいと思います。
https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html
問題の説明
前回の記事をベースに LiveView のひな型コードを生成します。
例外発生の LiveViewTest
mount, handle_params におけるテスト
生成されたコードのうち、詳細画面のコードは以下です。
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
を用いて以下のように書けます。
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 のテスト
では同じノリでクリックした時にユーザを削除するコードのテストをしてみましょう。
対象は以下です。
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
先ほどと同じようにテストを書いてみます。
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 とほぼ同じようにテストが書けますね。
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 にコードを置いておいたのでぜひ触ってみてください
https://github.com/koyo-miyamura/paginate_sample_ex