Packages

Trace function calls in concurrent Elixir processes.

Current section

Files

Jump to
tracee lib tracee.ex
Raw

lib/tracee.ex

defmodule Tracee do
@moduledoc """
This Elixir library offers functionality to trace and assert expected function
calls within concurrent Elixir processes.
This allows you to ensure that destructive and/or expensive functions are only
called the expected number of times. For more information, see [the Elixir forum
post](https://elixirforum.com/t/tracing-and-asserting-function-calls/63035) that
motivated the development of this library.
## Usage
defmodule ModuleTest do
use ExUnit.Case, async: true
import Tracee
setup :verify_on_exit!
describe "fun/0" do
test "calls expensive function only once" do
expect(AnotherModule, :expensive_fun, 1)
assert Module.fun()
end
test "calls expensive function only once from another process" do
expect(AnotherModule, :expensive_fun, 1)
assert fn -> Module.fun() end
|> Task.async()
|> Task.await()
end
end
end
"""
import ExUnit.Assertions
alias Tracee.Handler
@doc false
def child_spec([]) do
%{id: __MODULE__, type: :worker, start: {__MODULE__, :start_link, []}}
end
@doc """
Starts the tracer.
"""
@spec start_link() :: {:ok, pid()} | {:error, :already_started}
def start_link do
case :dbg.tracer(:process, {&Handler.trace/2, :unused}) do
{:ok, server} ->
:dbg.p(:all, :c)
:dbg.tp(:erlang, :spawn, [{:_, [], [{:return_trace}]}])
:dbg.tp(:erlang, :spawn_link, [{:_, [], [{:return_trace}]}])
:dbg.tp(:erlang, :spawn_monitor, [{:_, [], [{:return_trace}]}])
:dbg.tp(:erlang, :spawn_opt, [{:_, [], [{:return_trace}]}])
{:ok, server}
{:error, :already_started} ->
{:error, :already_started}
end
end
@doc """
Sets an expectation for a function call with a specific arity and optional
count.
## Examples
- Expect `AnotherModule.expensive_fun/0` to be called once:
Tracee.expect(AnotherModule, :expensive_fun, 0)
- Expect `AnotherModule.expensive_fun/1` to be called twice:
Tracee.expect(AnotherModule, :expensive_fun, 1, 2)
- Expect `AnotherModule.expensive_fun/1` to be nerver called:
Tracee.expect(AnotherModule, :expensive_fun, 1, 0)
"""
@spec expect(module(), atom(), pos_integer(), non_neg_integer()) :: :ok
def expect(module, function, arity, count \\ 1) when is_integer(count) and count >= 0 do
GenServer.cast(Handler, {:expect, self(), {module, function, arity}, count})
:dbg.tp(module, function, arity, [])
:ok
end
@doc """
Registers a verification check to be performed when the current test process
exits.
## Examples
defmodule ModuleTest do
use ExUnit.Case, async: true
import Tracee
setup :verify_on_exit!
end
"""
@spec verify_on_exit!(map()) :: :ok
def verify_on_exit!(_context \\ %{}) do
test = self()
ExUnit.Callbacks.on_exit(Tracee, fn ->
verify(test)
end)
end
@assert_receive_timeout Application.compile_env(
:tracee,
:assert_receive_timeout,
Application.compile_env!(:ex_unit, :assert_receive_timeout)
)
@refute_receive_timeout Application.compile_env(
:tracee,
:refute_receive_timeout,
Application.compile_env!(:ex_unit, :refute_receive_timeout)
)
@doc """
Verifies that all expected function calls have been received and nothing else.
"""
@spec verify(pid()) :: :ok
def verify(test \\ self()) do
case GenServer.call(Handler, {:verify, test, self()}) do
[] ->
nil
expectations ->
Enum.each(expectations, fn
{_, mfa, 0} ->
refute_receive {Tracee, ^test, ^mfa},
@refute_receive_timeout,
"Expected #{format_mfa(mfa)} NOT to be called in #{inspect(test)}"
{_, mfa, count} ->
for _ <- 1..count do
assert_receive {Tracee, ^test, ^mfa},
@assert_receive_timeout,
"Expected #{format_mfa(mfa)} to be called in #{inspect(test)}"
end
end)
refute_receive {Tracee, _, mfa},
@refute_receive_timeout,
"No (more) expectations defined for #{format_mfa(mfa)} in #{inspect(test)}"
GenServer.cast(Handler, {:remove, test})
end
:ok
end
defp format_mfa({module, function, arity}) do
"#{inspect(module)}.#{function}/#{arity}"
end
end