├── .formatter.exs ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .tool-versions ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── tracee.ex └── tracee │ ├── application.ex │ └── handler.ex ├── mix.exs ├── mix.lock └── test ├── test_helper.exs └── tracee_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | plugins: [MarkdownFormatter, Styler], 4 | markdown: [ 5 | line_length: 80 6 | ], 7 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "README.md"] 8 | ] 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | repository_dispatch: 9 | 10 | jobs: 11 | compile: 12 | runs-on: ubuntu-latest 13 | 14 | env: 15 | MIX_ENV: test 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: erlef/setup-elixir@v1 20 | with: 21 | version-file: .tool-versions 22 | version-type: strict 23 | - uses: actions/cache/restore@v4 24 | with: 25 | path: | 26 | deps 27 | _build 28 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-mix- 31 | - run: mix do deps.get, deps.compile 32 | - run: mix compile --warnings-as-errors 33 | - uses: actions/cache/save@v4 34 | with: 35 | path: | 36 | deps 37 | _build 38 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 39 | 40 | 41 | formatter: 42 | runs-on: ubuntu-latest 43 | needs: compile 44 | 45 | env: 46 | MIX_ENV: test 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: erlef/setup-elixir@v1 51 | with: 52 | version-file: .tool-versions 53 | version-type: strict 54 | - uses: actions/cache/restore@v4 55 | with: 56 | path: | 57 | deps 58 | _build 59 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 60 | restore-keys: | 61 | ${{ runner.os }}-mix- 62 | - run: mix format --check-formatted 63 | 64 | 65 | test: 66 | runs-on: ubuntu-latest 67 | needs: compile 68 | 69 | env: 70 | MIX_ENV: test 71 | 72 | steps: 73 | - uses: actions/checkout@v4 74 | - uses: erlef/setup-elixir@v1 75 | with: 76 | version-file: .tool-versions 77 | version-type: strict 78 | - uses: actions/cache/restore@v4 79 | with: 80 | path: | 81 | deps 82 | _build 83 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 84 | restore-keys: | 85 | ${{ runner.os }}-mix- 86 | - run: mix test --warnings-as-errors --max-failures 1 --exclude property 87 | 88 | 89 | unused-deps: 90 | runs-on: ubuntu-latest 91 | needs: compile 92 | 93 | env: 94 | MIX_ENV: test 95 | 96 | steps: 97 | - uses: actions/checkout@v4 98 | - uses: erlef/setup-elixir@v1 99 | with: 100 | version-file: .tool-versions 101 | version-type: strict 102 | - uses: actions/cache/restore@v4 103 | with: 104 | path: | 105 | deps 106 | _build 107 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 108 | restore-keys: | 109 | ${{ runner.os }}-mix- 110 | - run: mix deps.unlock --check-unused 111 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | tracee-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 26.2.5 2 | elixir 1.16.2-otp-26 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Mario Uher 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tracee 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/tracee.svg?style=flat-square)](https://hex.pm/packages/tracee) 4 | ![CI Status](https://img.shields.io/github/actions/workflow/status/tagbase-io/tracee/test.yml?branch=main&style=flat-square) 5 | 6 | This Elixir library offers functionality to trace and assert expected function 7 | calls within concurrent Elixir processes. 8 | 9 | This allows you to ensure that destructive and/or expensive functions are only 10 | called the expected number of times. For more information, see [the Elixir forum 11 | post](https://elixirforum.com/t/tracing-and-asserting-function-calls/63035) that 12 | motivated the development of this library. 13 | 14 | ## Installation 15 | 16 | Just add `tracee` to your list of dependencies in `mix.exs`: 17 | 18 | ```elixir 19 | def deps do 20 | [ 21 | {:tracee, "~> 0.2.0", only: :test} 22 | ] 23 | end 24 | ``` 25 | 26 | ## Usage 27 | 28 | ```elixir 29 | defmodule ModuleTest do 30 | use ExUnit.Case 31 | 32 | import Tracee 33 | 34 | setup :verify_on_exit! 35 | 36 | describe "fun/0" do 37 | test "calls expensive function only once" do 38 | expect(AnotherModule, :expensive_fun, 1) 39 | 40 | assert Module.fun() 41 | end 42 | 43 | test "calls expensive function only once from another process" do 44 | expect(AnotherModule, :expensive_fun, 1) 45 | 46 | assert fn -> Module.fun() end 47 | |> Task.async() 48 | |> Task.await() 49 | end 50 | 51 | test "never calls expensive function" do 52 | expect(AnotherModule, :expensive_fun, 1, 0) 53 | 54 | assert Module.fun() 55 | end 56 | end 57 | end 58 | ``` 59 | 60 | ## License 61 | 62 | [MIT](./LICENSE) 63 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if config_env() == :test do 4 | config :tracee, 5 | assert_receive_timeout: 10, 6 | refute_receive_timeout: 10 7 | end 8 | -------------------------------------------------------------------------------- /lib/tracee.ex: -------------------------------------------------------------------------------- 1 | defmodule Tracee do 2 | @moduledoc """ 3 | This Elixir library offers functionality to trace and assert expected function 4 | calls within concurrent Elixir processes. 5 | 6 | This allows you to ensure that destructive and/or expensive functions are only 7 | called the expected number of times. For more information, see [the Elixir forum 8 | post](https://elixirforum.com/t/tracing-and-asserting-function-calls/63035) that 9 | motivated the development of this library. 10 | 11 | ## Usage 12 | 13 | defmodule ModuleTest do 14 | use ExUnit.Case, async: true 15 | 16 | import Tracee 17 | 18 | setup :verify_on_exit! 19 | 20 | describe "fun/0" do 21 | test "calls expensive function only once" do 22 | expect(AnotherModule, :expensive_fun, 1) 23 | 24 | assert Module.fun() 25 | end 26 | 27 | test "calls expensive function only once from another process" do 28 | expect(AnotherModule, :expensive_fun, 1) 29 | 30 | assert fn -> Module.fun() end 31 | |> Task.async() 32 | |> Task.await() 33 | end 34 | end 35 | end 36 | """ 37 | 38 | import ExUnit.Assertions 39 | 40 | alias Tracee.Handler 41 | 42 | @doc false 43 | def child_spec([]) do 44 | %{id: __MODULE__, type: :worker, start: {__MODULE__, :start_link, []}} 45 | end 46 | 47 | @doc """ 48 | Starts the tracer. 49 | """ 50 | @spec start_link() :: {:ok, pid()} | {:error, :already_started} 51 | def start_link do 52 | case :dbg.tracer(:process, {&Handler.trace/2, :unused}) do 53 | {:ok, server} -> 54 | :dbg.p(:all, :c) 55 | 56 | :dbg.tp(:erlang, :spawn, [{:_, [], [{:return_trace}]}]) 57 | :dbg.tp(:erlang, :spawn_link, [{:_, [], [{:return_trace}]}]) 58 | :dbg.tp(:erlang, :spawn_monitor, [{:_, [], [{:return_trace}]}]) 59 | :dbg.tp(:erlang, :spawn_opt, [{:_, [], [{:return_trace}]}]) 60 | 61 | {:ok, server} 62 | 63 | {:error, :already_started} -> 64 | {:error, :already_started} 65 | end 66 | end 67 | 68 | @doc """ 69 | Sets an expectation for a function call with a specific arity and optional 70 | count. 71 | 72 | ## Examples 73 | 74 | - Expect `AnotherModule.expensive_fun/0` to be called once: 75 | 76 | Tracee.expect(AnotherModule, :expensive_fun, 0) 77 | 78 | - Expect `AnotherModule.expensive_fun/1` to be called twice: 79 | 80 | Tracee.expect(AnotherModule, :expensive_fun, 1, 2) 81 | 82 | - Expect `AnotherModule.expensive_fun/1` to be nerver called: 83 | 84 | Tracee.expect(AnotherModule, :expensive_fun, 1, 0) 85 | 86 | """ 87 | @spec expect(module(), atom(), pos_integer(), non_neg_integer()) :: :ok 88 | def expect(module, function, arity, count \\ 1) when is_integer(count) and count >= 0 do 89 | GenServer.cast(Handler, {:expect, self(), {module, function, arity}, count}) 90 | :dbg.tp(module, function, arity, []) 91 | 92 | :ok 93 | end 94 | 95 | @doc """ 96 | Registers a verification check to be performed when the current test process 97 | exits. 98 | 99 | ## Examples 100 | 101 | defmodule ModuleTest do 102 | use ExUnit.Case, async: true 103 | 104 | import Tracee 105 | 106 | setup :verify_on_exit! 107 | end 108 | """ 109 | @spec verify_on_exit!(map()) :: :ok 110 | def verify_on_exit!(_context \\ %{}) do 111 | test = self() 112 | 113 | ExUnit.Callbacks.on_exit(Tracee, fn -> 114 | verify(test) 115 | end) 116 | end 117 | 118 | @assert_receive_timeout Application.compile_env( 119 | :tracee, 120 | :assert_receive_timeout, 121 | Application.compile_env!(:ex_unit, :assert_receive_timeout) 122 | ) 123 | 124 | @refute_receive_timeout Application.compile_env( 125 | :tracee, 126 | :refute_receive_timeout, 127 | Application.compile_env!(:ex_unit, :refute_receive_timeout) 128 | ) 129 | 130 | @doc """ 131 | Verifies that all expected function calls have been received and nothing else. 132 | """ 133 | @spec verify(pid()) :: :ok 134 | def verify(test \\ self()) do 135 | case GenServer.call(Handler, {:verify, test, self()}) do 136 | [] -> 137 | nil 138 | 139 | expectations -> 140 | Enum.each(expectations, fn 141 | {_, mfa, 0} -> 142 | refute_receive {Tracee, ^test, ^mfa}, 143 | @refute_receive_timeout, 144 | "Expected #{format_mfa(mfa)} NOT to be called in #{inspect(test)}" 145 | 146 | {_, mfa, count} -> 147 | for _ <- 1..count do 148 | assert_receive {Tracee, ^test, ^mfa}, 149 | @assert_receive_timeout, 150 | "Expected #{format_mfa(mfa)} to be called in #{inspect(test)}" 151 | end 152 | end) 153 | 154 | refute_receive {Tracee, _, mfa}, 155 | @refute_receive_timeout, 156 | "No (more) expectations defined for #{format_mfa(mfa)} in #{inspect(test)}" 157 | 158 | GenServer.cast(Handler, {:remove, test}) 159 | end 160 | 161 | :ok 162 | end 163 | 164 | defp format_mfa({module, function, arity}) do 165 | "#{inspect(module)}.#{function}/#{arity}" 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/tracee/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Tracee.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @impl true 7 | def start(_type, _args) do 8 | children = [ 9 | Tracee, 10 | Tracee.Handler 11 | ] 12 | 13 | Supervisor.start_link(children, name: Tracee.Supervisor, strategy: :one_for_one) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/tracee/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Tracee.Handler do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | def start_link([]) do 7 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 8 | end 9 | 10 | # FIXME: This clause prevents `:erlang` calls from being traced. However, 11 | # disabling it would send numerous messages to the test process, causing 12 | # `verify/1` to fail. 13 | def trace({:trace, _pid, :call, {:erlang, _function, _args}}, unused), do: unused 14 | 15 | def trace({:trace, pid, :call, {module, function, args}}, unused) do 16 | GenServer.cast(__MODULE__, {:trace, pid, {module, function, length(args)}}) 17 | unused 18 | end 19 | 20 | def trace({:trace, parent, :return_from, {:erlang, _, _}, {child, _ref}}, unused) do 21 | GenServer.cast(__MODULE__, {:spawn, parent, child}) 22 | unused 23 | end 24 | 25 | def trace({:trace, parent, :return_from, {:erlang, _, _}, child}, unused) do 26 | GenServer.cast(__MODULE__, {:spawn, parent, child}) 27 | unused 28 | end 29 | 30 | def trace(_, unused), do: unused 31 | 32 | # GenServer API 33 | 34 | @impl true 35 | def init([]) do 36 | {:ok, %{expectations: [], traces: [], receivers: %{}, ancestors: %{}}} 37 | end 38 | 39 | @impl true 40 | def handle_cast({:expect, test, mfa, count}, state) do 41 | state = update_in(state, [:expectations], &(&1 ++ [{test, mfa, count}])) 42 | {:noreply, state} 43 | end 44 | 45 | @impl true 46 | def handle_cast({:trace, pid, mfa}, state) do 47 | state = update_in(state, [:traces], &(&1 ++ [{pid, mfa}])) 48 | {:noreply, state} 49 | end 50 | 51 | @impl true 52 | def handle_cast({:spawn, parent, child}, state) do 53 | state = put_in(state, [:ancestors, Access.key(child)], parent) 54 | {:noreply, state} 55 | end 56 | 57 | @impl true 58 | def handle_cast({:remove, test}, state) do 59 | {_, state} = pop_in(state, [:expectations, Access.filter(&match?(^test, elem(&1, 0)))]) 60 | {:noreply, state} 61 | end 62 | 63 | @impl true 64 | def handle_call({:verify, test, receiver}, _from, state) do 65 | expectations = get_in(state, [:expectations, Access.filter(&match?(^test, elem(&1, 0)))]) 66 | state = put_in(state, [:receivers, Access.key(test)], receiver) 67 | 68 | {:reply, expectations, state, {:continue, {:flush, test}}} 69 | end 70 | 71 | @impl true 72 | def handle_continue({:flush, test}, state) do 73 | send(self(), {:flush, test}) 74 | {:noreply, state} 75 | end 76 | 77 | @impl true 78 | def handle_info({:flush, test}, state) do 79 | case get_in(state, [:receivers, Access.key(test)]) do 80 | nil -> 81 | {:noreply, state} 82 | 83 | receiver -> 84 | # Find all traces called during the test process or any child processes. 85 | {traces, state} = 86 | pop_in(state, [ 87 | :traces, 88 | Access.filter(&match?(^test, find_ancestor(state.ancestors, elem(&1, 0), test))) 89 | ]) 90 | 91 | for {_, mfa} <- traces do 92 | send(receiver, {Tracee, test, mfa}) 93 | end 94 | 95 | {:noreply, state, {:continue, {:flush, test}}} 96 | end 97 | end 98 | 99 | defp find_ancestor(_map, pid, ancestor) when pid == ancestor, do: ancestor 100 | 101 | defp find_ancestor(map, pid, ancestor) when is_map_key(map, pid) do 102 | find_ancestor(map, map[pid], ancestor) 103 | end 104 | 105 | defp find_ancestor(_map, pid, _ancestor), do: pid 106 | end 107 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Tracee.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.2.0" 5 | 6 | def project do 7 | [ 8 | app: :tracee, 9 | version: @version, 10 | elixir: "~> 1.16", 11 | deps: deps(), 12 | name: "Tracee", 13 | description: "Trace function calls in concurrent Elixir processes.", 14 | 15 | # Package 16 | package: %{ 17 | licenses: ["MIT"], 18 | maintainers: ["Mario Uher"], 19 | links: %{"GitHub" => "https://github.com/tagbase-io/tracee"} 20 | }, 21 | 22 | # Docs 23 | docs: [ 24 | source_url: "https://github.com/tagbase-io/tracee", 25 | main: "Tracee" 26 | ] 27 | ] 28 | end 29 | 30 | # Run "mix help compile.app" to learn about applications. 31 | def application do 32 | [ 33 | extra_applications: [:logger, :runtime_tools], 34 | mod: {Tracee.Application, []} 35 | ] 36 | end 37 | 38 | # Run "mix help deps" to learn about dependencies. 39 | defp deps do 40 | [ 41 | {:ex_doc, "~> 0.32.1", only: [:dev, :test]}, 42 | {:markdown_formatter, "~> 0.6", only: [:dev, :test], runtime: false}, 43 | {:styler, "~> 0.11.9", only: [:dev, :test], runtime: false} 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 3 | "ex_doc": {:hex, :ex_doc, "0.32.1", "21e40f939515373bcdc9cffe65f3b3543f05015ac6c3d01d991874129d173420", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5142c9db521f106d61ff33250f779807ed2a88620e472ac95dc7d59c380113da"}, 4 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 6 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, 7 | "markdown_formatter": {:hex, :markdown_formatter, "0.6.0", "bc822cf262f74a70dd6c76d76407b94cd19e59ee6b83248c728d00f7e75f2bd4", [:mix], [{:earmark_parser, "~> 1.4", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "8181c6c1516061de482e24fa69e287878bab88249dae4ca489d89e914040da90"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 9 | "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, 10 | } 11 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/tracee_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TraceeTest do 2 | use ExUnit.Case 3 | 4 | import Tracee 5 | 6 | defmodule TestModule do 7 | @moduledoc false 8 | def fun, do: :ok 9 | def fun(_arg), do: :ok 10 | end 11 | 12 | describe "expect/3" do 13 | setup :verify_on_exit! 14 | 15 | test "ensures function is called once" do 16 | expect(TestModule, :fun, 0) 17 | expect(TestModule, :fun, 1) 18 | 19 | TestModule.fun() 20 | TestModule.fun(:ok) 21 | end 22 | 23 | test "ensures function is called n times" do 24 | expect(TestModule, :fun, 0, 2) 25 | expect(TestModule, :fun, 1, 2) 26 | 27 | TestModule.fun() 28 | TestModule.fun(:ok) 29 | TestModule.fun() 30 | TestModule.fun(:ok) 31 | end 32 | 33 | test "ensures function is not called" do 34 | expect(TestModule, :fun, 0, 0) 35 | expect(TestModule, :fun, 1, 0) 36 | end 37 | 38 | test "ensures function is called once from another task" do 39 | expect(TestModule, :fun, 0) 40 | 41 | fn -> TestModule.fun() end 42 | |> Task.async() 43 | |> Task.await() 44 | end 45 | 46 | test "ensures function is called n times from other tasks" do 47 | expect(TestModule, :fun, 0, 2) 48 | expect(TestModule, :fun, 1, 2) 49 | 50 | Task.await_many([ 51 | Task.async(fn -> 52 | TestModule.fun() 53 | TestModule.fun(:ok) 54 | end), 55 | Task.async(fn -> 56 | TestModule.fun(:ok) 57 | TestModule.fun() 58 | end) 59 | ]) 60 | end 61 | end 62 | 63 | describe "verify/1" do 64 | test "raises when function is not called when expected" do 65 | test = self() 66 | expect(TestModule, :fun, 0) 67 | 68 | assert_raise ExUnit.AssertionError, fn -> 69 | verify(test) 70 | end 71 | end 72 | 73 | test "raises when function is called when not expected" do 74 | test = self() 75 | expect(TestModule, :fun, 0, 0) 76 | 77 | assert_raise ExUnit.AssertionError, fn -> 78 | TestModule.fun() 79 | verify(test) 80 | end 81 | end 82 | 83 | test "raises when function is called too infrequently" do 84 | test = self() 85 | expect(TestModule, :fun, 0, 2) 86 | 87 | assert_raise ExUnit.AssertionError, fn -> 88 | TestModule.fun() 89 | verify(test) 90 | end 91 | end 92 | 93 | test "raises when function is called too often" do 94 | test = self() 95 | expect(TestModule, :fun, 0, 1) 96 | 97 | assert_raise ExUnit.AssertionError, fn -> 98 | TestModule.fun() 99 | TestModule.fun() 100 | 101 | verify(test) 102 | end 103 | end 104 | 105 | test "does not raise when no expectations are defined" do 106 | TestModule.fun() 107 | verify(self()) 108 | end 109 | end 110 | end 111 | --------------------------------------------------------------------------------