From aff3f66ed48d8ae6ff4e4685e4185227f93abecc Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 22 May 2026 15:11:07 +0200 Subject: [PATCH 1/3] add assert_will_receive test helper Uses OTP 27's trace sessions to trace messages received by the LiveView. --- lib/phoenix_live_view/test/live_view_test.ex | 144 ++++++++++++++++++ .../integrations/assert_will_receive_test.exs | 90 +++++++++++ 2 files changed, 234 insertions(+) create mode 100644 test/phoenix_live_view/integrations/assert_will_receive_test.exs diff --git a/lib/phoenix_live_view/test/live_view_test.ex b/lib/phoenix_live_view/test/live_view_test.ex index 236c3675a7..1a8b5f205d 100644 --- a/lib/phoenix_live_view/test/live_view_test.ex +++ b/lib/phoenix_live_view/test/live_view_test.ex @@ -1817,6 +1817,150 @@ defmodule Phoenix.LiveViewTest do end end + @doc """ + Asserts the LiveView process will receive a message within `timeout`. + The default `timeout` is [ExUnit](https://hexdocs.pm/ex_unit/ExUnit.html#configure/1)'s + `assert_receive_timeout` (100 ms). + + This requires Erlang/OTP 27 or later. + + A zero-arity setup function may be passed as the third argument. It is + invoked after tracing is enabled and before waiting for the message. To + customize the timeout, pass it as the fourth argument. + + ## Examples + + assert_will_receive view, {:test_message, num} + assert num == 1 + + assert_will_receive view, {:test_message, num}, fn -> + send(view.pid, {:test_message, 1}) + end + + assert_will_receive view, {:test_message, num}, fn -> + send(view.pid, {:test_message, 1}) + end, 1000 + """ + defmacro assert_will_receive( + view, + pattern, + fun \\ quote(do: fn -> nil end), + timeout \\ Application.fetch_env!(:ex_unit, :assert_receive_timeout) + ) do + {pattern, guard} = extract_guard(pattern) + ref = Macro.unique_var(:ref, __MODULE__) + tracer = Macro.unique_var(:tracer, __MODULE__) + received = Macro.unique_var(:received, __MODULE__) + message = Macro.unique_var(:message, __MODULE__) + generated_pattern = mark_vars_as_generated(pattern) + generated_guard = guard && mark_vars_as_generated(guard) + + trace_pattern = + quote do + {^unquote(ref), unquote(generated_pattern)} + end + |> apply_guard(generated_guard) + + quote do + {unquote(ref), unquote(tracer)} = + Phoenix.LiveViewTest.__start_assert_will_receive__(unquote(view)) + + unquote(received) = + try do + unquote(fun).() + assert_receive unquote(trace_pattern), unquote(timeout) + after + Phoenix.LiveViewTest.__stop_assert_will_receive__(unquote(ref), unquote(tracer)) + end + + {^unquote(ref), unquote(message)} = unquote(received) + unquote(pattern) = unquote(message) + unquote(message) + end + end + + defp extract_guard({:when, _, [pattern, guard]}), do: {pattern, guard} + defp extract_guard(pattern), do: {pattern, nil} + + defp apply_guard(pattern, nil), do: pattern + defp apply_guard(pattern, guard), do: {:when, [], [pattern, guard]} + + defp mark_vars_as_generated(pattern) do + Macro.prewalk(pattern, fn + {name, meta, context} when is_atom(name) and is_atom(context) -> + {name, [generated: true] ++ meta, context} + + other -> + other + end) + end + + @doc false + def __start_assert_will_receive__(%View{pid: pid}) do + if not (Code.ensure_loaded?(:trace) and function_exported?(:trace, :session_create, 3)) do + raise "assert_will_receive requires Erlang/OTP 27 or later" + end + + ref = make_ref() + parent = self() + tracer = spawn(fn -> assert_will_receive_loop(parent, ref) end) + session = :trace.session_create(:phoenix_live_view_test, tracer, []) + + try do + :trace.process(session, pid, true, [:receive]) + {ref, {tracer, session}} + rescue + exception -> + __stop_assert_will_receive__(ref, {tracer, session}) + reraise exception, __STACKTRACE__ + catch + kind, reason -> + __stop_assert_will_receive__(ref, {tracer, session}) + :erlang.raise(kind, reason, __STACKTRACE__) + end + end + + @doc false + def __stop_assert_will_receive__(ref, {tracer, session}) do + :trace.session_destroy(session) + + monitor_ref = Process.monitor(tracer) + send(tracer, {ref, :stop}) + + receive do + {:DOWN, ^monitor_ref, :process, ^tracer, _reason} -> + :ok + after + 5_000 -> + Process.exit(tracer, :kill) + + receive do + {:DOWN, ^monitor_ref, :process, ^tracer, _reason} -> :ok + end + end + + flush_assert_will_receive(ref) + end + + defp assert_will_receive_loop(parent, ref) do + receive do + {:trace, _pid, :receive, message} -> + send(parent, {ref, message}) + assert_will_receive_loop(parent, ref) + + {^ref, :stop} -> + :ok + end + end + + defp flush_assert_will_receive(ref) do + receive do + {^ref, _message} -> flush_assert_will_receive(ref) + after + 0 -> :ok + end + end + @doc """ Follows the redirect from a `render_*` action or an `{:error, redirect}` tuple. diff --git a/test/phoenix_live_view/integrations/assert_will_receive_test.exs b/test/phoenix_live_view/integrations/assert_will_receive_test.exs new file mode 100644 index 0000000000..d998b9415d --- /dev/null +++ b/test/phoenix_live_view/integrations/assert_will_receive_test.exs @@ -0,0 +1,90 @@ +defmodule Phoenix.LiveView.AssertWillReceiveTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + alias Phoenix.LiveViewTest.Support.Endpoint + + @endpoint Endpoint + + setup do + {:ok, conn: Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{})} + end + + defmodule AssertWillReceiveLive do + use Phoenix.LiveView + + def mount(_params, _session, socket) do + {:ok, socket} + end + + def handle_info({:test_message, _num}, socket) do + {:noreply, socket} + end + + def render(assigns) do + ~H"" + end + end + + describe "assert_will_receive" do + test "asserts the LiveView process will receive a message", %{conn: conn} do + {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) + + Task.start(fn -> + Process.sleep(25) + send(view.pid, {:test_message, 1}) + end) + + assert_will_receive(view, {:test_message, num}) + assert num == 1 + end + + test "runs setup function after tracing is set up", %{conn: conn} do + {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) + + assert_will_receive(view, {:test_message, num}, fn -> + send(view.pid, {:test_message, 1}) + end) + + assert num == 1 + end + + test "supports guards", %{conn: conn} do + {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) + + assert_will_receive( + view, + {:test_message, num} when num > 1, + fn -> + send(view.pid, {:test_message, 1}) + send(view.pid, {:test_message, 2}) + end, + 1000 + ) + + assert num == 2 + end + + test "stops tracing after the assertion", %{conn: conn} do + {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) + + assert_will_receive( + view, + {:test_message, :during}, + fn -> + send(view.pid, {:test_message, :during}) + end, + 1000 + ) + + send(view.pid, {:test_message, :after}) + + receive do + {ref, {:test_message, :after}} when is_reference(ref) -> + flunk("expected assert_will_receive to stop tracing") + after + 50 -> :ok + end + end + end +end From eb03e9d7e61190fcad290903efe9bebccca49f9c Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 22 May 2026 15:16:10 +0200 Subject: [PATCH 2/3] compile time check --- lib/phoenix_live_view/test/live_view_test.ex | 110 ++++++++++--------- 1 file changed, 58 insertions(+), 52 deletions(-) diff --git a/lib/phoenix_live_view/test/live_view_test.ex b/lib/phoenix_live_view/test/live_view_test.ex index 1a8b5f205d..a6f37023db 100644 --- a/lib/phoenix_live_view/test/live_view_test.ex +++ b/lib/phoenix_live_view/test/live_view_test.ex @@ -1895,70 +1895,76 @@ defmodule Phoenix.LiveViewTest do end) end - @doc false - def __start_assert_will_receive__(%View{pid: pid}) do - if not (Code.ensure_loaded?(:trace) and function_exported?(:trace, :session_create, 3)) do - raise "assert_will_receive requires Erlang/OTP 27 or later" - end - - ref = make_ref() - parent = self() - tracer = spawn(fn -> assert_will_receive_loop(parent, ref) end) - session = :trace.session_create(:phoenix_live_view_test, tracer, []) + if Code.ensure_loaded?(:trace) and function_exported?(:trace, :session_create, 3) do + @doc false + def __start_assert_will_receive__(%View{pid: pid}) do + ref = make_ref() + parent = self() + tracer = spawn(fn -> assert_will_receive_loop(parent, ref) end) + session = :trace.session_create(:phoenix_live_view_test, tracer, []) - try do - :trace.process(session, pid, true, [:receive]) - {ref, {tracer, session}} - rescue - exception -> - __stop_assert_will_receive__(ref, {tracer, session}) - reraise exception, __STACKTRACE__ - catch - kind, reason -> - __stop_assert_will_receive__(ref, {tracer, session}) - :erlang.raise(kind, reason, __STACKTRACE__) + try do + :trace.process(session, pid, true, [:receive]) + {ref, {tracer, session}} + rescue + exception -> + __stop_assert_will_receive__(ref, {tracer, session}) + reraise exception, __STACKTRACE__ + catch + kind, reason -> + __stop_assert_will_receive__(ref, {tracer, session}) + :erlang.raise(kind, reason, __STACKTRACE__) + end end - end - @doc false - def __stop_assert_will_receive__(ref, {tracer, session}) do - :trace.session_destroy(session) + @doc false + def __stop_assert_will_receive__(ref, {tracer, session}) do + :trace.session_destroy(session) - monitor_ref = Process.monitor(tracer) - send(tracer, {ref, :stop}) + monitor_ref = Process.monitor(tracer) + send(tracer, {ref, :stop}) - receive do - {:DOWN, ^monitor_ref, :process, ^tracer, _reason} -> - :ok - after - 5_000 -> - Process.exit(tracer, :kill) + receive do + {:DOWN, ^monitor_ref, :process, ^tracer, _reason} -> + :ok + after + 5_000 -> + Process.exit(tracer, :kill) - receive do - {:DOWN, ^monitor_ref, :process, ^tracer, _reason} -> :ok - end - end + receive do + {:DOWN, ^monitor_ref, :process, ^tracer, _reason} -> :ok + end + end - flush_assert_will_receive(ref) - end + flush_assert_will_receive(ref) + end - defp assert_will_receive_loop(parent, ref) do - receive do - {:trace, _pid, :receive, message} -> - send(parent, {ref, message}) - assert_will_receive_loop(parent, ref) + defp assert_will_receive_loop(parent, ref) do + receive do + {:trace, _pid, :receive, message} -> + send(parent, {ref, message}) + assert_will_receive_loop(parent, ref) - {^ref, :stop} -> - :ok + {^ref, :stop} -> + :ok + end end - end - defp flush_assert_will_receive(ref) do - receive do - {^ref, _message} -> flush_assert_will_receive(ref) - after - 0 -> :ok + defp flush_assert_will_receive(ref) do + receive do + {^ref, _message} -> flush_assert_will_receive(ref) + after + 0 -> :ok + end + end + else + @doc false + def __start_assert_will_receive__(%View{}) do + raise "assert_will_receive requires Erlang/OTP 27 or later" end + + @doc false + def __stop_assert_will_receive__(_ref, _tracer), do: :ok end @doc """ From 92d9c3b98b6cfec61ea2210b7e7faa645e0af59c Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 22 May 2026 15:23:12 +0200 Subject: [PATCH 3/3] guard tests --- .../integrations/assert_will_receive_test.exs | 138 +++++++++--------- 1 file changed, 70 insertions(+), 68 deletions(-) diff --git a/test/phoenix_live_view/integrations/assert_will_receive_test.exs b/test/phoenix_live_view/integrations/assert_will_receive_test.exs index d998b9415d..ae3752636d 100644 --- a/test/phoenix_live_view/integrations/assert_will_receive_test.exs +++ b/test/phoenix_live_view/integrations/assert_will_receive_test.exs @@ -1,89 +1,91 @@ -defmodule Phoenix.LiveView.AssertWillReceiveTest do - use ExUnit.Case, async: true +if Code.ensure_loaded?(:trace) and function_exported?(:trace, :session_create, 3) do + defmodule Phoenix.LiveView.AssertWillReceiveTest do + use ExUnit.Case, async: true - import Phoenix.LiveViewTest - alias Phoenix.LiveViewTest.Support.Endpoint + import Phoenix.LiveViewTest + alias Phoenix.LiveViewTest.Support.Endpoint - @endpoint Endpoint + @endpoint Endpoint - setup do - {:ok, conn: Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{})} - end + setup do + {:ok, conn: Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{})} + end - defmodule AssertWillReceiveLive do - use Phoenix.LiveView + defmodule AssertWillReceiveLive do + use Phoenix.LiveView - def mount(_params, _session, socket) do - {:ok, socket} - end + def mount(_params, _session, socket) do + {:ok, socket} + end - def handle_info({:test_message, _num}, socket) do - {:noreply, socket} - end + def handle_info({:test_message, _num}, socket) do + {:noreply, socket} + end - def render(assigns) do - ~H"" + def render(assigns) do + ~H"" + end end - end - describe "assert_will_receive" do - test "asserts the LiveView process will receive a message", %{conn: conn} do - {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) + describe "assert_will_receive" do + test "asserts the LiveView process will receive a message", %{conn: conn} do + {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) - Task.start(fn -> - Process.sleep(25) - send(view.pid, {:test_message, 1}) - end) + Task.start(fn -> + Process.sleep(25) + send(view.pid, {:test_message, 1}) + end) - assert_will_receive(view, {:test_message, num}) - assert num == 1 - end + assert_will_receive(view, {:test_message, num}) + assert num == 1 + end - test "runs setup function after tracing is set up", %{conn: conn} do - {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) + test "runs setup function after tracing is set up", %{conn: conn} do + {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) - assert_will_receive(view, {:test_message, num}, fn -> - send(view.pid, {:test_message, 1}) - end) + assert_will_receive(view, {:test_message, num}, fn -> + send(view.pid, {:test_message, 1}) + end) - assert num == 1 - end + assert num == 1 + end - test "supports guards", %{conn: conn} do - {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) + test "supports guards", %{conn: conn} do + {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) - assert_will_receive( - view, - {:test_message, num} when num > 1, - fn -> - send(view.pid, {:test_message, 1}) - send(view.pid, {:test_message, 2}) - end, - 1000 - ) + assert_will_receive( + view, + {:test_message, num} when num > 1, + fn -> + send(view.pid, {:test_message, 1}) + send(view.pid, {:test_message, 2}) + end, + 1000 + ) - assert num == 2 - end + assert num == 2 + end - test "stops tracing after the assertion", %{conn: conn} do - {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) - - assert_will_receive( - view, - {:test_message, :during}, - fn -> - send(view.pid, {:test_message, :during}) - end, - 1000 - ) - - send(view.pid, {:test_message, :after}) - - receive do - {ref, {:test_message, :after}} when is_reference(ref) -> - flunk("expected assert_will_receive to stop tracing") - after - 50 -> :ok + test "stops tracing after the assertion", %{conn: conn} do + {:ok, view, _} = live_isolated(conn, AssertWillReceiveLive) + + assert_will_receive( + view, + {:test_message, :during}, + fn -> + send(view.pid, {:test_message, :during}) + end, + 1000 + ) + + send(view.pid, {:test_message, :after}) + + receive do + {ref, {:test_message, :after}} when is_reference(ref) -> + flunk("expected assert_will_receive to stop tracing") + after + 50 -> :ok + end end end end