From eed1219b9027886610083d063e650d836cbb62ed Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Tue, 2 Jun 2026 16:54:18 +0200 Subject: [PATCH 1/7] Add trace helper to ExUnit.Assertions --- lib/ex_unit/lib/ex_unit/assertions.ex | 98 ++++++++++++++++++++ lib/ex_unit/test/ex_unit/assertions_test.exs | 70 ++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/lib/ex_unit/lib/ex_unit/assertions.ex b/lib/ex_unit/lib/ex_unit/assertions.ex index 0f44e52f88d..4386a5a326d 100644 --- a/lib/ex_unit/lib/ex_unit/assertions.ex +++ b/lib/ex_unit/lib/ex_unit/assertions.ex @@ -1076,6 +1076,104 @@ defmodule ExUnit.Assertions 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. See the documentation for `:trace.function/4` on the shape of + the `mfa` tuple for details. + + 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`. + + Trace messages left in the mailbox once `fun` returns are not flushed. + + ## 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 to `Map.get/2`: + + 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 + do_trace(pid, flags, fn _session -> :ok end, fun) + end + + @doc """ + Traces `pid` while `fun` runs with custom trace session initialization. + + This is a more advanced version of `trace/3` that allows you to customize the `:trace` session + before attaching it to the process. + + ## Examples + + Only trace received messages of the shape `{:reply, _}`: + + trace( + pid, + [:receive], + fn session -> + :trace.recv(session, [{[:_, :_, {:reply, :_}], [], []}], []) + end, + fn -> + send(pid, {:reply, :foo}) + send(pid, {:other, :bar}) + assert_receive {:trace, ^pid, :receive, {:reply, :foo}} + refute_receive {:trace, ^pid, :receive, {:other, :bar}} + end + ) + + """ + @doc since: "1.21.0" + def trace(pid, flags, init, fun) + when is_pid(pid) and is_list(flags) and is_function(init, 1) and is_function(fun, 0) do + do_trace(pid, flags, init, fun) + end + + defp do_trace(pid, flags, init, fun) 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) + + init.(session) + + :trace.process(session, pid, true, process_flags) + fun.() + after + :trace.session_destroy(session) + 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 52192206df1..51411ebd12f 100644 --- a/lib/ex_unit/test/ex_unit/assertions_test.exs +++ b/lib/ex_unit/test/ex_unit/assertions_test.exs @@ -615,6 +615,76 @@ defmodule ExUnit.AssertionsTest 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 + + test "custom setup" do + pid = + spawn_link(fn -> + receive do + _ -> + receive do + _ -> :ok + end + end + end) + + trace( + pid, + [:receive], + fn session -> + :trace.recv(session, [{[:_, :_, {:reply, :_}], [], []}], []) + end, + fn -> + send(pid, {:reply, :foo}) + send(pid, {:other, :bar}) + assert_receive {:trace, ^pid, :receive, {:reply, :foo}} + refute_receive {:trace, ^pid, :receive, {:other, :bar}} + end + ) + end + end + test "refute received does not wait" do false = refute_received :hello end From f57d24f9c32b90e112ff7f94acb35c24dd852fa5 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Tue, 2 Jun 2026 16:58:02 +0200 Subject: [PATCH 2/7] Adjust IEx.Server test to use `ExUnit.Assertions.trace/4` --- lib/iex/test/iex/server_test.exs | 39 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/lib/iex/test/iex/server_test.exs b/lib/iex/test/iex/server_test.exs index d55f0206490..44644f22a44 100644 --- a/lib/iex/test/iex/server_test.exs +++ b/lib/iex/test/iex/server_test.exs @@ -201,22 +201,27 @@ defmodule IEx.ServerTest do end defp pry_request(sessions) do - :erlang.trace(Process.whereis(IEx.Broker), true, [:receive, tracer: self()]) - patterns = for %{pid: pid} <- sessions, do: {[:_, pid, :_], [], []} - :erlang.trace_pattern(:receive, patterns, []) - - task = - Task.async(fn -> - iex_context = :inside_pry - IEx.pry() - end) - - for _ <- sessions do - assert_receive {:trace, _, :receive, {_, _, call}} when elem(call, 0) in [:accept, :refuse] - end - - task - after - :erlang.trace(Process.whereis(IEx.Broker), false, [:receive, tracer: self()]) + trace( + Process.whereis(IEx.Broker), + [:receive], + fn trace_session -> + patterns = for %{pid: pid} <- sessions, do: {[:_, pid, :_], [], []} + :trace.recv(trace_session, patterns, []) + end, + fn -> + task = + Task.async(fn -> + iex_context = :inside_pry + IEx.pry() + end) + + for _ <- sessions do + assert_receive {:trace, _, :receive, {_, _, call}} + when elem(call, 0) in [:accept, :refuse] + end + + task + end + ) end end From 95173f421bbd4d7f176a0684ef1e990b59119733 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Mon, 8 Jun 2026 10:33:44 +0200 Subject: [PATCH 3/7] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/ex_unit/lib/ex_unit/assertions.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ex_unit/lib/ex_unit/assertions.ex b/lib/ex_unit/lib/ex_unit/assertions.ex index 4386a5a326d..598a03712b1 100644 --- a/lib/ex_unit/lib/ex_unit/assertions.ex +++ b/lib/ex_unit/lib/ex_unit/assertions.ex @@ -1080,9 +1080,9 @@ defmodule ExUnit.Assertions do 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. + assert on the internal behaviour of a process, the messages it sends and + receives and the functions it calls, 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. From 62206dac9b5dd3b683b471ed9b3116b44a8baa5d Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Mon, 8 Jun 2026 11:07:22 +0200 Subject: [PATCH 4/7] document messages, remove trace/4 --- lib/ex_unit/lib/ex_unit/assertions.ex | 67 +++++++------------- lib/ex_unit/test/ex_unit/assertions_test.exs | 7 +- lib/iex/test/iex/server_test.exs | 8 +-- 3 files changed, 27 insertions(+), 55 deletions(-) diff --git a/lib/ex_unit/lib/ex_unit/assertions.ex b/lib/ex_unit/lib/ex_unit/assertions.ex index 598a03712b1..af80be3361d 100644 --- a/lib/ex_unit/lib/ex_unit/assertions.ex +++ b/lib/ex_unit/lib/ex_unit/assertions.ex @@ -1087,16 +1087,24 @@ defmodule ExUnit.Assertions do 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. See the documentation for `:trace.function/4` on the shape of - the `mfa` tuple for details. + `flags` is a list of trace flags. The following flags are supported: - 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`. + * `:receive` - trace received messages + * `{:receive, match_specification}` - trace only received messages matching the given match specification. + See `:trace.recv/3` for more information. + * `:send` - trace sent messages + * `{:call, {Module, function, arity}}` - trace calls to the given MFA + + Trace messages are delivered to the calling process in different shapes, + depending on the used flags: + + * `{:trace, pid, :receive, message}` for `:receive` + * `{:trace, pid, :send, message, to}` for `:send` + * `{:trace, pid, :send_to_non_existing_process, message, to}` for `:send` when the destination process does not exist + * `{:trace, pid, :call, {Module, function, args}}` when tracing function calls + + Because messages are delivered asynchronously, prefer `assert_receive/3` over + `assert_received/2`. Trace messages left in the mailbox once `fun` returns are not flushed. @@ -1120,43 +1128,10 @@ defmodule ExUnit.Assertions do @doc since: "1.21.0" def trace(pid, flags, fun) when is_pid(pid) and is_list(flags) and is_function(fun, 0) do - do_trace(pid, flags, fn _session -> :ok end, fun) - end - - @doc """ - Traces `pid` while `fun` runs with custom trace session initialization. - - This is a more advanced version of `trace/3` that allows you to customize the `:trace` session - before attaching it to the process. - - ## Examples - - Only trace received messages of the shape `{:reply, _}`: - - trace( - pid, - [:receive], - fn session -> - :trace.recv(session, [{[:_, :_, {:reply, :_}], [], []}], []) - end, - fn -> - send(pid, {:reply, :foo}) - send(pid, {:other, :bar}) - assert_receive {:trace, ^pid, :receive, {:reply, :foo}} - refute_receive {:trace, ^pid, :receive, {:other, :bar}} - end - ) - - """ - @doc since: "1.21.0" - def trace(pid, flags, init, fun) - when is_pid(pid) and is_list(flags) and is_function(init, 1) and is_function(fun, 0) do - do_trace(pid, flags, init, fun) - end - - defp do_trace(pid, flags, init, fun) do {call_patterns, process_flags} = Enum.split_with(flags, &match?({:call, _}, &1)) + {receive_specs, process_flags} = Enum.split_with(process_flags, &match?({:receive, _}, &1)) process_flags = if call_patterns == [], do: process_flags, else: [:call | process_flags] + process_flags = if receive_specs == [], do: process_flags, else: [:receive | process_flags] session = :trace.session_create(:ex_unit_trace, self(), []) @@ -1165,7 +1140,9 @@ defmodule ExUnit.Assertions do :trace.function(session, mfa, true, [:local]) end) - init.(session) + Enum.each(receive_specs, fn {:receive, pattern} -> + :trace.recv(session, [pattern], []) + end) :trace.process(session, pid, true, process_flags) fun.() diff --git a/lib/ex_unit/test/ex_unit/assertions_test.exs b/lib/ex_unit/test/ex_unit/assertions_test.exs index 51411ebd12f..e805f03961b 100644 --- a/lib/ex_unit/test/ex_unit/assertions_test.exs +++ b/lib/ex_unit/test/ex_unit/assertions_test.exs @@ -658,7 +658,7 @@ defmodule ExUnit.AssertionsTest do refute_received {:trace, ^pid, :receive, _} end - test "custom setup" do + test "receive match spec" do pid = spawn_link(fn -> receive do @@ -671,10 +671,7 @@ defmodule ExUnit.AssertionsTest do trace( pid, - [:receive], - fn session -> - :trace.recv(session, [{[:_, :_, {:reply, :_}], [], []}], []) - end, + [receive: {[:_, :_, {:reply, :_}], [], []}], fn -> send(pid, {:reply, :foo}) send(pid, {:other, :bar}) diff --git a/lib/iex/test/iex/server_test.exs b/lib/iex/test/iex/server_test.exs index 44644f22a44..630ab309590 100644 --- a/lib/iex/test/iex/server_test.exs +++ b/lib/iex/test/iex/server_test.exs @@ -201,13 +201,11 @@ defmodule IEx.ServerTest do end defp pry_request(sessions) do + flags = for %{pid: pid} <- sessions, do: {:receive, {[:_, pid, :_], [], []}} + trace( Process.whereis(IEx.Broker), - [:receive], - fn trace_session -> - patterns = for %{pid: pid} <- sessions, do: {[:_, pid, :_], [], []} - :trace.recv(trace_session, patterns, []) - end, + flags, fn -> task = Task.async(fn -> From b9dccedccecec648762e0bd9a6ee153ff38d0746 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Mon, 8 Jun 2026 11:37:45 +0200 Subject: [PATCH 5/7] multiple match specs --- lib/ex_unit/lib/ex_unit/assertions.ex | 10 +++++++--- lib/ex_unit/test/ex_unit/assertions_test.exs | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/ex_unit/lib/ex_unit/assertions.ex b/lib/ex_unit/lib/ex_unit/assertions.ex index af80be3361d..560ef4f828e 100644 --- a/lib/ex_unit/lib/ex_unit/assertions.ex +++ b/lib/ex_unit/lib/ex_unit/assertions.ex @@ -1140,9 +1140,13 @@ defmodule ExUnit.Assertions do :trace.function(session, mfa, true, [:local]) end) - Enum.each(receive_specs, fn {:receive, pattern} -> - :trace.recv(session, [pattern], []) - end) + case Enum.map(receive_specs, fn {:receive, pattern} -> pattern end) do + [] -> + :ok + + patterns -> + :trace.recv(session, patterns, []) + end :trace.process(session, pid, true, process_flags) fun.() diff --git a/lib/ex_unit/test/ex_unit/assertions_test.exs b/lib/ex_unit/test/ex_unit/assertions_test.exs index e805f03961b..ef2bacd4875 100644 --- a/lib/ex_unit/test/ex_unit/assertions_test.exs +++ b/lib/ex_unit/test/ex_unit/assertions_test.exs @@ -658,7 +658,7 @@ defmodule ExUnit.AssertionsTest do refute_received {:trace, ^pid, :receive, _} end - test "receive match spec" do + test "receive match specs" do pid = spawn_link(fn -> receive do @@ -671,11 +671,13 @@ defmodule ExUnit.AssertionsTest do trace( pid, - [receive: {[:_, :_, {:reply, :_}], [], []}], + [receive: {[:_, :_, {:reply, :_}], [], []}, receive: {[:_, :_, {:another, :_}], [], []}], fn -> send(pid, {:reply, :foo}) + send(pid, {:another, :foo}) send(pid, {:other, :bar}) assert_receive {:trace, ^pid, :receive, {:reply, :foo}} + assert_receive {:trace, ^pid, :receive, {:another, :foo}} refute_receive {:trace, ^pid, :receive, {:other, :bar}} end ) From e3a68706755f082fe276b7502b57894effd56364 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Mon, 8 Jun 2026 13:27:41 +0200 Subject: [PATCH 6/7] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/ex_unit/lib/ex_unit/assertions.ex | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/lib/ex_unit/lib/ex_unit/assertions.ex b/lib/ex_unit/lib/ex_unit/assertions.ex index 560ef4f828e..0959b8ad5fd 100644 --- a/lib/ex_unit/lib/ex_unit/assertions.ex +++ b/lib/ex_unit/lib/ex_unit/assertions.ex @@ -1126,26 +1126,24 @@ defmodule ExUnit.Assertions do """ @doc since: "1.21.0" + @flags [:receive, :send] 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)) - {receive_specs, process_flags} = Enum.split_with(process_flags, &match?({:receive, _}, &1)) - process_flags = if call_patterns == [], do: process_flags, else: [:call | process_flags] - process_flags = if receive_specs == [], do: process_flags, else: [:receive | process_flags] + {receive_specs, call_patterns, process_flags} = + Enum.reduce(flags, {[], [], []}, fn + {:receive, spec}, {receive, call, process} -> {[spec | receive], call, process} + {:call, mfa}, {receive, call, process} -> {receive, [mfa | call], process} + flag, {receive, call, process} when flag in @flags -> {receive, call, [flag | process]} + other, _acc -> raise ArgumentError, "unknown trace flag: #{inspect(other)}" + end) session = :trace.session_create(:ex_unit_trace, self(), []) try do - Enum.each(call_patterns, fn {:call, mfa} -> - :trace.function(session, mfa, true, [:local]) - end) - - case Enum.map(receive_specs, fn {:receive, pattern} -> pattern end) do - [] -> - :ok + Enum.each(call_patterns, &:trace.function(session, &1, true, [:local])) - patterns -> - :trace.recv(session, patterns, []) + if receive_specs != [] do + :trace.recv(session, receive_specs, []) end :trace.process(session, pid, true, process_flags) From 3f9cd6ee573bf526cb081fc95af2dd1974934945 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Mon, 8 Jun 2026 13:36:15 +0200 Subject: [PATCH 7/7] fix flags --- lib/ex_unit/lib/ex_unit/assertions.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/ex_unit/lib/ex_unit/assertions.ex b/lib/ex_unit/lib/ex_unit/assertions.ex index 0959b8ad5fd..d6dc5ee7fda 100644 --- a/lib/ex_unit/lib/ex_unit/assertions.ex +++ b/lib/ex_unit/lib/ex_unit/assertions.ex @@ -1139,6 +1139,9 @@ defmodule ExUnit.Assertions do session = :trace.session_create(:ex_unit_trace, self(), []) + process_flags = if call_patterns == [], do: process_flags, else: [:call | process_flags] + process_flags = if receive_specs == [], do: process_flags, else: [:receive | process_flags] + try do Enum.each(call_patterns, &:trace.function(session, &1, true, [:local]))