defmodule Mox do @moduledoc """ Mox is a library for defining mocks in Elixir. The library follows the principles outlined in ["Mocks and explicit contracts"](http://blog.plataformatec.com.br/2015/10/mocks-and-explicit-contracts/), summarized below: 1. No ad-hoc mocks. You can only create mocks based on behaviours. 2. No dynamic generation of modules during tests. Mocks defined by Mox are preferably defined in your `test_helper.exs` or in a `setup_all` block and not per test. 3. They support concurrency (tests can still use `async: true`) 4. Rely on pattern matching and function clauses for asserting on the input instead of complex mock rules ## Example As an example, imagine that your library defines a calculator behaviour: defmodule MyApp.Calculator do @callback add(integer(), integer()) :: integer() @callback mult(integer(), integer()) :: integer() end If you want to mock the calculator behaviour during tests, the first step is to define the mock, usually in your `test_helper.exs`: Mox.defmock(MyApp.CalcMock, for: MyApp.Calculator) Once the mock is defined, you can pass it to the system under the test. If the system under test relies on application configuration, you should also set it in your `test_helper.exs` before the tests starts to keep the asynchronous property: Applicartion.put_env(:my_app, :calculator, MyApp.CalcMock) Now in your tests, you can define expectations and verify them: use ExUnit.Case, async: true import Mox test "invokes add and mult" do MyApp.CalcMock |> expect(:add, fn x, y -> x + y end) |> expect(:mult, fn x, y -> x * y end) assert MyApp.CalcMock.add(2, 3) == 5 assert MyApp.CalcMock.mult(2, 3) == 6 after verify!() # or verify!(MyApp.CalcMock) end Notice Mox works based on the current process. Verification must be done in the test process and therefore cannot be done on `on_exit` callbacks. Similarly, if you define a expectation on the current process and invoke the mock on another process, the mock will not be available. """ @name __MODULE__ defmodule UnexpectedCallError do defexception [:message] end defmodule VerificationError do defexception [:message] end @doc """ Defines a mock with the given name `:for` the given behaviour. Mox.defmock MyMock, for: MyBehaviour """ def defmock(name, options) when is_atom(name) and is_list(options) do behaviour = options[:for] || raise ArgumentError, ":for option is required on defmock" validate_behaviour!(behaviour) define_mock_module(name, behaviour) name end defp validate_behaviour!(behaviour) do cond do not Code.ensure_loaded?(behaviour) -> raise ArgumentError, "module #{inspect behaviour} is not available, please pass an existing module to :for" not function_exported?(behaviour, :behaviour_info, 1) -> raise ArgumentError, "module #{inspect behaviour} is not a behaviour, please pass a behaviour to :for" true -> :ok end end defp define_mock_module(name, behaviour) do funs = for {fun, arity} <- behaviour.behaviour_info(:callbacks) do args = 0..arity |> Enum.to_list |> tl() |> Enum.map(&Macro.var(:"arg#{&1}", Elixir)) quote do def unquote(fun)(unquote_splicing(args)) do Mox.__dispatch__(__MODULE__, unquote(fun), unquote(arity), unquote(args)) end end end info = quote do def __mock_for__ do unquote(behaviour) end end Module.create(name, [info | funs], Macro.Env.location(__ENV__)) end @doc """ Defines that the `name` in `mock` with arity given by `code` will be invoked the `n` times. ## Examples To allow `MyMock.add/2` to be called once: expect(MyMock, :add, fn x, y -> x + y end) To allow `MyMock.add/2` to be called five times: expect(MyMock, :add, 5, fn x, y -> x + y end) `expect/4` can also be invoked multiple times for the same name/arity, allowing you to give different behaviours on each invocation. """ def expect(mock, name, n \\ 1, code) when is_atom(mock) and is_atom(name) and is_integer(n) and n >= 1 and is_function(code) do validate_mock!(mock) arity = :erlang.fun_info(code)[:arity] unless function_exported?(mock, name, arity) do raise ArgumentError, "unknown function #{name}/#{arity} for mock #{inspect mock}" end calls = List.duplicate(code, n) key = {self(), mock, name, arity} case Registry.register(@name, key, {n, calls}) do {:ok, _} -> mock {:error, {:already_registered, pid}} when pid == self() -> Registry.update_value(@name, key, fn {current_n, current_calls} -> {current_n + n, current_calls ++ calls} end) end mock end @doc """ Verifies that all expectations set by the current process have been called. """ def verify! do verify_mock_or_all!(:all) end @doc """ Verifies that all expectations in `mock` have been called. """ def verify!(mock) do validate_mock!(mock) verify_mock_or_all!(mock) end defp verify_mock_or_all!(mock) do failed = for {_, module, name, arity} = key <- Registry.keys(@name, self()), module == mock or mock == :all, value <- Registry.lookup(@name, key), {_pid, {count, calls}} = value, calls != [] do mfa = Exception.format_mfa(module, name, arity) pending = count - length(calls) " * expected #{mfa} to be invoked #{times(count)} but it was invoked #{times(pending)}" end if failed != [] do raise VerificationError, "error while verifying mocks:\n\n#{Enum.join(failed, "\n")}" end :ok end defp validate_mock!(mock) do cond do not Code.ensure_loaded?(mock) -> raise ArgumentError, "module #{inspect mock} is not available" not function_exported?(mock, :__mock_for__, 0) -> raise ArgumentError, "module #{inspect mock} is not a mock" true -> :ok end end @doc false def __dispatch__(mock, name, arity, args) do case Registry.update_value(@name, {self(), mock, name, arity}, &dispatch_update/1) do :error -> mfa = Exception.format_mfa(mock, name, arity) raise UnexpectedCallError, "no expectation defined for #{mfa} in process #{inspect(self())}" {_, {count, []}} -> mfa = Exception.format_mfa(mock, name, arity) raise UnexpectedCallError, "expected #{mfa} to be called #{times(count)} but it has been " <> "called #{times(count + 1)} in process #{inspect(self())}" {_, {_, [call | _]}} -> apply(call, args) end end defp times(1), do: "once" defp times(n), do: "#{n} times" defp dispatch_update({total, []}), do: {total, []} defp dispatch_update({total, [_ | tail]}), do: {total, tail} end