defmodule Hammox do @moduledoc """ Hammox is a library for rigorous unit testing using mocks, explicit behaviours and contract tests. See the [README](readme.html) page for usage guide and examples. Most of the functions in this module come from [Mox](https://hexdocs.pm/mox/Mox.html) for backwards compatibility. As of v0.1.0, the only Hammox-specific functions are `protect/2` and `protect/3`. """ alias Hammox.Cache alias Hammox.Telemetry alias Hammox.TypeEngine alias Hammox.TypeMatchError alias Hammox.Utils @type function_arity_pair :: {atom(), arity() | [arity()]} defmodule TypespecNotFoundError do @moduledoc false defexception [:message] end @doc """ See [Mox.allow/3](https://hexdocs.pm/mox/Mox.html#allow/3). """ def allow(mock, owner_pid, allowed_via) do Telemetry.span( [:hammox, :allow], %{mock: mock, owner_pid: owner_pid, allowed_via: allowed_via}, fn -> result = Mox.allow(mock, owner_pid, allowed_via) {result, %{}} end ) end @doc """ See [Mox.defmock/2](https://hexdocs.pm/mox/Mox.html#defmock/2). """ defdelegate defmock(name, options), to: Mox @doc """ See [Mox.expect/4](https://hexdocs.pm/mox/Mox.html#expect/4). """ def expect(mock, function_name, n \\ 1, code) do Telemetry.span( [:hammox, :expect], %{mock: mock, function_name: function_name, expect_count: n}, fn -> hammox_code = wrap(mock, function_name, code) result = Mox.expect(mock, function_name, n, hammox_code) {result, %{}} end ) end @doc """ See [Mox.stub/3](https://hexdocs.pm/mox/Mox.html#stub/3). """ def stub(mock, function_name, code) do Telemetry.span([:hammox, :stub], %{mock: mock, function_name: function_name}, fn -> hammox_code = wrap(mock, function_name, code) result = Mox.stub(mock, function_name, hammox_code) {result, %{}} end) end @doc """ See [Mox.set_mox_from_context/1](https://hexdocs.pm/mox/Mox.html#set_mox_from_context/1). """ defdelegate set_mox_from_context(context), to: Mox @doc """ See [Mox.set_mox_global/1](https://hexdocs.pm/mox/Mox.html#set_mox_global/1). """ defdelegate set_mox_global(context \\ %{}), to: Mox @doc """ See [Mox.set_mox_private/1](https://hexdocs.pm/mox/Mox.html#set_mox_private/1). """ defdelegate set_mox_private(context \\ %{}), to: Mox @doc """ See [Mox.stub_with/2](https://hexdocs.pm/mox/Mox.html#stub_with/2). """ defdelegate stub_with(mock, module), to: Mox @doc """ See [Mox.verify!/0](https://hexdocs.pm/mox/Mox.html#verify!/0). """ defdelegate verify!(), to: Mox @doc """ See [Mox.verify!/1](https://hexdocs.pm/mox/Mox.html#verify!/1). """ defdelegate verify!(mock), to: Mox @doc """ See [Mox.verify_on_exit!/1](https://hexdocs.pm/mox/Mox.html#verify_on_exit!/1). """ def verify_on_exit!(context \\ %{}) do Telemetry.span([:hammox, :verify_on_exit!], %{context: context}, fn -> result = Mox.verify_on_exit!(context) {result, %{}} end) end @doc since: "0.1.0" @doc """ See `protect/3`. """ def protect(module) @spec protect(module :: module()) :: %{atom() => fun()} def protect(module) when is_atom(module) do funs = get_funcs!(module) protect(module, module, funs) end @spec protect(mfa :: mfa()) :: fun() def protect({module, function_name, arity}) when is_atom(module) and is_atom(function_name) and is_integer(arity) do protect({module, function_name, arity}, module) end @doc since: "0.1.0" @doc """ See `protect/3`. """ def protect(mfa, behaviour_name) @spec protect(module :: module(), funs :: [function_arity_pair()]) :: %{atom() => fun()} def protect(module, [{function, arity} | _] = funs) when is_atom(module) and is_atom(function) and (is_integer(arity) or is_list(arity)), do: protect(module, module, funs) def protect(module, [behaviour | _] = behaviour_names) when is_atom(module) and is_atom(behaviour) do Enum.reduce(behaviour_names, %{}, fn behaviour_name, acc -> Map.merge(acc, protect(module, behaviour_name)) end) end @spec protect(mfa :: mfa(), behaviour_name :: module()) :: fun() def protect({module, function_name, arity} = mfa, behaviour_name) when is_atom(module) and is_atom(function_name) and is_integer(arity) and is_atom(behaviour_name) do Utils.check_module_exists(module) Utils.check_module_exists(behaviour_name) mfa_exist?(mfa) code = {module, function_name} typespecs = fetch_typespecs!(behaviour_name, function_name, arity) protected(code, typespecs, arity) end @spec protect(implementation_name :: module(), behaviour_name :: module()) :: %{atom() => fun()} def protect(implementation_name, behaviour_name) when is_atom(implementation_name) and is_atom(behaviour_name) do funs = get_funcs!(behaviour_name) protect(implementation_name, behaviour_name, funs) end @doc since: "0.1.0" @doc """ Decorates functions with Hammox checks based on given behaviour. ## Basic usage When passed an MFA tuple representing the function you'd like to protect, and a behaviour containing a callback for the function, it returns a new anonymous function that raises `Hammox.TypeMatchError` when called incorrectly or when it returns an incorrect value. Example: ```elixir defmodule Calculator do @callback add(integer(), integer()) :: integer() end defmodule TestCalculator do def add(a, b), do: a + b end add_2 = Hammox.protect({TestCalculator, :add, 2}, Calculator) add_2.(1.5, 2.5) # throws Hammox.TypeMatchError ``` ## Batch usage You can decorate all functions defined by a given behaviour by passing an implementation module and a behaviour module. Optionally, you can pass an explicit list of functions as the third argument. The returned map is useful as the return value for a test setup callback to set test context for all tests to use. Example: ```elixir defmodule Calculator do @callback add(integer(), integer()) :: integer() @callback add(integer(), integer(), integer()) :: integer() @callback add(integer(), integer(), integer(), integer()) :: integer() @callback multiply(integer(), integer()) :: integer() end defmodule TestCalculator do def add(a, b), do: a + b def add(a, b, c), do: a + b + c def add(a, b, c, d), do: a + b + c + d def multiply(a, b), do: a * b end %{ add_2: add_2, add_3: add_3, add_4: add_4 multiply_2: multiply_2 } = Hammox.protect(TestCalculator, Calculator) # optionally %{ add_2: add_2, add_3: add_3, multiply_2: multiply_2 } = Hammox.protect(TestCalculator, Calculator, add: [2, 3], multiply: 2) ``` ## Batch usage for multiple behviours You can decorate all functions defined by any number of behaviours by passing an implementation module and a list of behaviour modules. The returned map is useful as the return value for a test setup callback to set test context for all tests to use. Example: ```elixir defmodule Calculator do @callback add(integer(), integer()) :: integer() @callback multiply(integer(), integer()) :: integer() end defmodule AdditionalCalculator do @callback subtract(integer(), integer()) :: integer() end defmodule TestCalculator do def add(a, b), do: a + b def multiply(a, b), do: a * b def subtract(a, b), do: a - b end %{ add_2: add_2, multiply_2: multiply_2 subtract_2: subtract_2 } = Hammox.protect(TestCalculator, [Calculator, AdditionalCalculator]) ``` ## Behaviour-implementation shortcuts Often, there exists one "default" implementation for a behaviour. A common practice is then to define both the callbacks and the implementations in one module. For these behaviour-implementation modules, Hammox provides shortucts that only require one module. Example: ```elixir defmodule Calculator do @callback add(integer(), integer()) :: integer() def add(a, b), do: a + b end Hammox.protect({Calculator, :add, 2}) # is equivalent to Hammox.protect({Calculator, :add, 2}, Calculator) Hammox.protect(Calculator, add: 2) # is equivalent to Hammox.protect(Calculator, Calculator, add: 2) Hammox.protect(Calculator) # is equivalent to Hammox.protect(Calculator, Calculator) ``` """ @spec protect( module :: module(), behaviour_name :: module(), funs :: [function_arity_pair()] ) :: %{atom() => fun()} def protect(module, behaviour_name, funs) when is_atom(module) and is_atom(behaviour_name) and is_list(funs) do funs |> Enum.flat_map(fn {function_name, arity_or_arities} -> arity_or_arities |> List.wrap() |> Enum.map(fn arity -> key = function_name |> Atom.to_string() |> Kernel.<>("_#{arity}") |> String.to_atom() value = protect({module, function_name, arity}, behaviour_name) {key, value} end) end) |> Enum.into(%{}) end defp wrap(mock, name, code) do arity = :erlang.fun_info(code)[:arity] case fetch_typespecs_for_mock(mock, name, arity) do # This is really an error case where we're trying to mock a function # that does not exist in the behaviour. Mox will flag it better though # so just let it pass through. [] -> code typespecs -> protected(code, typespecs, arity) end end defp protected(code, typespecs, 0) do fn -> protected_code(code, typespecs, []) end end defp protected(code, typespecs, 1) do fn arg1 -> protected_code(code, typespecs, [arg1]) end end defp protected(code, typespecs, 2) do fn arg1, arg2 -> protected_code(code, typespecs, [arg1, arg2]) end end defp protected(code, typespecs, 3) do fn arg1, arg2, arg3 -> protected_code(code, typespecs, [arg1, arg2, arg3]) end end defp protected(code, typespecs, 4) do fn arg1, arg2, arg3, arg4 -> protected_code(code, typespecs, [arg1, arg2, arg3, arg4]) end end defp protected(code, typespecs, 5) do fn arg1, arg2, arg3, arg4, arg5 -> protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5]) end end defp protected(code, typespecs, 6) do fn arg1, arg2, arg3, arg4, arg5, arg6 -> protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6]) end end defp protected(code, typespecs, 7) do fn arg1, arg2, arg3, arg4, arg5, arg6, arg7 -> protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6, arg7]) end end defp protected(code, typespecs, 8) do fn arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 -> protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8]) end end defp protected(code, typespecs, 9) do fn arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9 -> protected_code(code, typespecs, [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9]) end end defp protected(_code, _typespec, arity) when arity > 9 do raise "Hammox only supports protecting functions with arity up to 9. Why do you need over 9 parameters anyway?" end defp protected_code(code, typespecs, args) do return_value = Telemetry.span([:hammox, :run_expect], %{}, fn -> return_value = case code do {module, function_name} -> apply(module, function_name, args) anonymous when is_function(anonymous) -> apply(anonymous, args) end {return_value, %{}} end) check_call(args, return_value, typespecs) return_value end # credo:disable-for-lines:30 Credo.Check.Refactor.Nesting defp check_call(args, return_value, typespecs) when is_list(typespecs) do match_call_result = Telemetry.span([:hammox, :check_call], %{}, fn -> {result, check_call_count} = typespecs |> Enum.reduce_while({{:error, []}, 0}, fn typespec, {{:error, reasons} = result, counter} -> counter = counter + 1 case match_call(args, return_value, typespec) do :ok -> {:halt, {:ok, counter}} {:error, new_reasons} = new_result -> {:cont, if(length(reasons) >= length(new_reasons), do: {result, counter}, else: {new_result, counter} )} end end) {result, %{total_specs_checked: check_call_count}} end) case match_call_result do {:error, _} = error -> raise TypeMatchError, error :ok -> :ok end end defp match_call(args, return_value, typespec) do # Even though the last clause is redundant, it reads better this way. # credo:disable-for-next-line Credo.Check.Refactor.RedundantWithClauseResult with :ok <- match_args(args, typespec), :ok <- match_return_value(return_value, typespec) do :ok end end defp match_args([], _typespec) do :ok end # credo:disable-for-lines:24 Credo.Check.Refactor.Nesting defp match_args(args, typespec) do Telemetry.span([:hammox, :match_args], %{}, fn -> result = args |> Enum.zip(0..(length(args) - 1)) |> Enum.map(fn {arg, index} -> arg_type = arg_typespec(typespec, index) case TypeEngine.match_type(arg, arg_type) do {:error, reasons} -> {:error, [{:arg_type_mismatch, index, arg, arg_type} | reasons]} :ok -> :ok end end) |> Enum.max_by(fn {:error, reasons} -> length(reasons) :ok -> 0 end) {result, %{}} end) end defp match_return_value(return_value, typespec) do Telemetry.span([:hammox, :match_return_value], %{}, fn -> {:type, _, :fun, [_, return_type]} = typespec result = case TypeEngine.match_type(return_value, return_type) do {:error, reasons} -> {:error, [{:return_type_mismatch, return_value, return_type} | reasons]} :ok -> :ok end {result, %{}} end) end defp fetch_typespecs!(behaviour_name, function_name, arity) do case fetch_typespecs(behaviour_name, function_name, arity) do [] -> raise TypespecNotFoundError, message: "Could not find typespec for #{Utils.module_to_string(behaviour_name)}.#{function_name}/#{arity}." typespecs -> typespecs end end defp fetch_typespecs(behaviour_name, function_name, arity) do Telemetry.span( [:hammox, :fetch_typespecs], %{behaviour_name: behaviour_name, function_name: function_name, arity: arity}, fn -> cache_key = {:typespecs, {behaviour_name, function_name, arity}} result = case Cache.get(cache_key) do nil -> typespecs = do_fetch_typespecs(behaviour_name, function_name, arity) Cache.put(cache_key, typespecs) typespecs typespecs -> typespecs end {result, %{}} end ) end defp do_fetch_typespecs(behaviour_module, function_name, arity) do callbacks = fetch_callbacks(behaviour_module) callbacks |> Enum.find_value([], fn {{^function_name, ^arity}, typespecs} -> typespecs _ -> false end) |> Enum.map(&guards_to_annotated_types(&1)) |> Enum.map(&Utils.replace_user_types(&1, behaviour_module)) end defp guards_to_annotated_types({:type, _, :fun, _} = typespec), do: typespec defp guards_to_annotated_types( {:type, _, :bounded_fun, [{:type, _, :fun, [{:type, _, :product, args}, return_value]}, constraints]} ) do type_lookup_map = constraints |> Enum.map(fn {:type, _, :constraint, [{:atom, _, :is_subtype}, [{:var, _, var_name}, type]]} -> {var_name, type} end) |> Enum.into(%{}) new_args = Enum.map( args, &annotate_vars(&1, type_lookup_map) ) new_return_value = annotate_vars(return_value, type_lookup_map) {:type, 0, :fun, [{:type, 0, :product, new_args}, new_return_value]} end defp annotate_vars(type, type_lookup_map) do Utils.type_map(type, fn {:var, _, var_name} -> type_for_var = Map.fetch!(type_lookup_map, var_name) {:ann_type, 0, [{:var, 0, var_name}, type_for_var]} other -> other end) end defp fetch_callbacks(behaviour_module) do case Cache.get({:callbacks, behaviour_module}) do nil -> {:ok, callbacks} = Code.Typespec.fetch_callbacks(behaviour_module) Cache.put({:callbacks, behaviour_module}, callbacks) callbacks callbacks -> callbacks end end defp fetch_typespecs_for_mock(mock_name, function_name, arity) when is_atom(mock_name) and is_atom(function_name) and is_integer(arity) do mock_name.__mock_for__() |> Enum.map(fn behaviour -> fetch_typespecs(behaviour, function_name, arity) end) |> List.flatten() end defp arg_typespec(function_typespec, arg_index) do {:type, _, :fun, [{:type, _, :product, arg_typespecs}, _]} = function_typespec Enum.at(arg_typespecs, arg_index) end defp mfa_exist?({module, function_name, arity}) do case function_exported?(module, function_name, arity) do true -> true _ -> raise(ArgumentError, message: "Could not find function #{Utils.module_to_string(module)}.#{function_name}/#{arity}." ) end end defp get_funcs!(module) do Utils.check_module_exists(module) module |> fetch_callbacks() |> Enum.map(fn {callback, _typespecs} -> callback end) end end