Skip to content

add helper to assert that a LiveView will receive a message - #4242

Closed
SteffenDE wants to merge 3 commits into
mainfrom
sd-assert-will-receive
Closed

add helper to assert that a LiveView will receive a message#4242
SteffenDE wants to merge 3 commits into
mainfrom
sd-assert-will-receive

Conversation

@SteffenDE

Copy link
Copy Markdown
Member

Uses OTP 27's trace sessions to trace messages received by the LiveView.

cc @josevalim

Since this is not really LiveView specific, maybe better-suited in ExUnit?

SteffenDE added 3 commits May 22, 2026 15:11
Uses OTP 27's trace sessions to trace messages received by the LiveView.
@josevalim

Copy link
Copy Markdown
Member

Yeah, I think it could be part of Elixir, but I am not sure I like the name "assert_will_receive". You may also want to receive multiple messages within a single trace/function. Maybe an API should be more like:

trace_messages(pid, fn ->
  do_something(...)
  assert_received {:trace, ^pid, :receive, {:msg}}
end)

We call it trace_messages if we want to allow opting into tracing send. Another option is to call it explicitly trace_receive (and we introduce trace_call and trace_send in the future). Thoughts?

@josevalim

Copy link
Copy Markdown
Member

Or perhaps trace_receive(GenServer.whereis(), fn pid -> ... end).

@josevalim

Copy link
Copy Markdown
Member

Another option is:

trace(pid, [:send, :receive, call: {m, f, a}], fn -> ... end)

But most usages would likely be:

trace(pid, [:receive], fn -> ... end)

That seems to be the one with most potential for growth.

@SteffenDE

Copy link
Copy Markdown
Member Author

I like it. The only concern I have in mind is in which process the function should run. Right now, the PR prefixes messages with a ref and sends them to the test process (and also flushes other messages with that ref). If we want to support calling regular assert_receive / assert_received in the fun, we'd either need to run it in a separate process, or we have to inject the raw messages into the test processes' mailbox. Both could be confusing?

@josevalim

Copy link
Copy Markdown
Member

I was thinking that we would use a separate process to proxy messages to the test inbox. The only issue is that messages will appear out of order, but that’s probably fine if documented.

@SteffenDE

Copy link
Copy Markdown
Member Author
diff --git a/lib/ex_unit/lib/ex_unit/assertions.ex b/lib/ex_unit/lib/ex_unit/assertions.ex
index 0f44e52f8..dcce18b53 100644
--- a/lib/ex_unit/lib/ex_unit/assertions.ex
+++ b/lib/ex_unit/lib/ex_unit/assertions.ex
@@ -1076,6 +1076,83 @@ defp refute_receive_clause(pattern, failure_message) do
     end
   end
 
+  @doc """
+  Traces `pid` while `fun` runs, delivering trace messages to the calling process.
+
+  This is a thin wrapper around Erlang's [trace sessions](`:trace`) that lets you
+  assert on the internal behaviour of a process — the messages it sends and
+  receives, the functions it calls, and so on — using the regular
+  `assert_receive/3` and `assert_received/2` assertions.
+
+  Tracing is enabled before `fun` is invoked and disabled once it returns. The
+  value returned by `fun` is returned.
+
+  `flags` is a list of trace flags. In addition to the process trace flags
+  documented in `:trace.process/4` (such as `:send`, `:receive`, and `:procs`),
+  it accepts `{:call, {module, function, arity}}` entries to trace calls to the
+  given function.
+
+  Trace messages are delivered to the calling process in the shapes documented
+  in `:trace.process/4`, for example `{:trace, pid, :receive, message}` or
+  `{:trace, pid, :call, {module, function, args}}`. Because they are delivered
+  asynchronously, prefer `assert_receive/3` over `assert_received/2` when the
+  traced activity is triggered in another process.
+
+  Trace messages left in the mailbox once `fun` returns are discarded, so
+  assertions should be made inside `fun`.
+
+  > #### Requirements {: .info}
+  >
+  > This function relies on trace sessions, which require Erlang/OTP 27 or later.
+
+  ## Examples
+
+  Asserting that a process receives a message:
+
+      trace(pid, [:receive], fn ->
+        send(pid, {:event, "click"})
+        assert_receive {:trace, ^pid, :receive, {:event, "click"}}
+      end)
+
+  Tracing function calls (`call: {module, function, arity}` is keyword syntax
+  for `{:call, {module, function, arity}}`):
+
+      trace(pid, [call: {Map, :get, 2}], fn ->
+        run_request()
+        assert_receive {:trace, ^pid, :call, {Map, :get, [_map, :key]}}
+      end)
+
+  """
+  @doc since: "1.21.0"
+  def trace(pid, flags, fun)
+      when is_pid(pid) and is_list(flags) and is_function(fun, 0) do
+    {call_patterns, process_flags} = Enum.split_with(flags, &match?({:call, _}, &1))
+    process_flags = if call_patterns == [], do: process_flags, else: [:call | process_flags]
+
+    session = :trace.session_create(:ex_unit_trace, self(), [])
+
+    try do
+      Enum.each(call_patterns, fn {:call, mfa} ->
+        :trace.function(session, mfa, true, [:local])
+      end)
+
+      :trace.process(session, pid, true, process_flags)
+      fun.()
+    after
+      :trace.session_destroy(session)
+      flush_trace(pid)
+    end
+  end
+
+  defp flush_trace(pid) do
+    receive do
+      {:trace, ^pid, _, _} -> flush_trace(pid)
+      {:trace, ^pid, _, _, _} -> flush_trace(pid)
+    after
+      0 -> :ok
+    end
+  end
+
   @doc """
   Asserts `value1` and `value2` are not within `delta`.
 
diff --git a/lib/ex_unit/test/ex_unit/assertions_test.exs b/lib/ex_unit/test/ex_unit/assertions_test.exs
index 52192206d..74dcc6880 100644
--- a/lib/ex_unit/test/ex_unit/assertions_test.exs
+++ b/lib/ex_unit/test/ex_unit/assertions_test.exs
@@ -615,6 +615,50 @@ test "assert received does not leak external variables used in guards" do
     :world = world
   end
 
+  describe "trace" do
+    test "delivers receive trace messages to the calling process" do
+      pid = spawn_link(fn -> receive(do: (_ -> :ok)) end)
+
+      trace(pid, [:receive], fn ->
+        send(pid, {:hello, :world})
+        assert_receive {:trace, ^pid, :receive, {:hello, :world}}
+      end)
+    end
+
+    test "delivers send trace messages to the calling process" do
+      parent = self()
+      pid = spawn_link(fn -> receive(do: (:go -> send(parent, :done))) end)
+
+      trace(pid, [:send], fn ->
+        send(pid, :go)
+        assert_receive {:trace, ^pid, :send, :done, ^parent}
+      end)
+    end
+
+    test "traces function calls" do
+      pid = spawn_link(fn -> receive(do: (:go -> Map.get(%{a: 1}, :a))) end)
+
+      trace(pid, [call: {Map, :get, 2}], fn ->
+        send(pid, :go)
+        assert_receive {:trace, ^pid, :call, {Map, :get, [%{a: 1}, :a]}}
+      end)
+    end
+
+    test "returns the value of the function" do
+      pid = spawn_link(fn -> receive(do: (_ -> :ok)) end)
+      :result = trace(pid, [:receive], fn -> :result end)
+    end
+
+    test "stops tracing and flushes messages once the function returns" do
+      pid = spawn_link(fn -> receive(do: (_ -> :ok)) end)
+
+      trace(pid, [:receive], fn -> send(pid, :during) end)
+
+      send(pid, :after)
+      refute_received {:trace, ^pid, :receive, _}
+    end
+  end
+
   test "refute received does not wait" do
     false = refute_received :hello
   end

Could be quite straightforward. Not 100% sure about function tracing. Receive is probably the most useful. We definitely need to find a good balance when to send people to just use :trace themselves. Maybe function tracing is already out of scope for the helper.

@josevalim

Copy link
Copy Markdown
Member

@SteffenDE please send a PR for Elixir. I am not sure if we should flush the trace: we can delete messages that were not from our trace. So unless we can uniquely tag them, I wouldn't remove them. And I would keep call tracing because it can help as an alternative to mocks. Thank you! ❤️

@SteffenDE

Copy link
Copy Markdown
Member Author

Closing in favor of elixir-lang/elixir#15432.

@SteffenDE SteffenDE closed this Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants