Current section

Files

Jump to
patch lib patch.ex
Raw

lib/patch.ex

defmodule Patch do
@moduledoc """
Patch - Ergonomic Mocking for Elixir
Patch makes it easy to mock one or more functions in a module returning a value or executing
custom logic. Patches and Spies allow tests to assert or refute that function calls have been
made.
Using Patch is as easy as adding a single line to your test case.
```elixir
use Patch
```
After this all the patch functions will be available, see the README and function documentation
for more details.
"""
defmodule MissingCall do
defexception [:message]
end
defmodule UnexpectedCall do
defexception [:message]
end
defmacro __using__(_) do
quote do
require unquote(__MODULE__)
import unquote(__MODULE__)
setup do
on_exit(fn ->
:meck.unload()
end)
end
end
end
defmacro assert_called({{:., _, [module, function]}, _, args}) do
quote do
value = :meck.called(unquote(module), unquote(function), unquote(args))
unless value do
calls =
unquote(module)
|> :meck.history()
|> Enum.with_index(1)
|> Enum.map(fn {{_, {m, f, a}, ret}, i} ->
"#{i}. #{inspect(m)}.#{f}(#{a |> Enum.map(&Kernel.inspect/1) |> Enum.join(", ")}) -> #{
inspect(ret)
}"
end)
calls =
case calls do
[] ->
" [No Calls Received]"
_ ->
Enum.join(calls, "\n")
end
call_args = unquote(args) |> Enum.map(&Kernel.inspect/1) |> Enum.join(", ")
message = """
\n
Expected but did not receive the following call:
#{inspect(unquote(module))}.#{to_string(unquote(function))}(#{call_args})
Calls which were received:
#{calls}
"""
raise MissingCall, message: message
end
end
end
defmacro refute_called({{:., _, [module, function]}, _, args}) do
quote do
value = :meck.called(unquote(module), unquote(function), unquote(args))
if value do
calls =
unquote(module)
|> :meck.history()
|> Enum.with_index(1)
|> Enum.map(fn {{_, {m, f, a}, ret}, i} ->
"#{i}. #{inspect(m)}.#{f}(#{a |> Enum.map(&Kernel.inspect/1) |> Enum.join(", ")}) -> #{
inspect(ret)
}"
end)
|> Enum.join("\n")
call_args = unquote(args) |> Enum.map(&Kernel.inspect/1) |> Enum.join(", ")
message = """
\n
Unexpected call received:
#{inspect(unquote(module))}.#{to_string(unquote(function))}(#{call_args})
Calls which were received:
#{calls}
"""
raise UnexpectedCall, message: message
end
end
end
@doc """
Asserts that the function has been called with any arity call
"""
@spec assert_any_call(module :: module(), function :: atom()) :: nil
def assert_any_call(module, function) do
calls =
module
|> :meck.history()
|> Enum.filter(fn
{_, {^module, ^function, _}, _} -> true
_ -> false
end)
if Enum.empty?(calls) do
message = """
\n
Expected any call received:
#{inspect(module)}.#{to_string(function)}
No calls found
"""
raise MissingCall, message: message
end
end
@doc """
Refutes that the function has been called with any arity call
"""
@spec refute_any_call(module :: module(), function :: atom()) :: nil
def refute_any_call(module, function) do
calls =
module
|> :meck.history()
|> Enum.filter(fn
{_, {^module, ^function, _}, _} -> true
_ -> false
end)
|> Enum.map(fn {_, {_, _, args}, ret} ->
{args, ret}
end)
unless Enum.empty?(calls) do
formatted_calls =
calls
|> Enum.with_index(1)
|> Enum.map(fn {{args, ret}, i} ->
"#{i}. #{inspect(module)}.#{to_string(function)}(#{
args |> Enum.map(&Kernel.inspect/1) |> Enum.join(", ")
}) -> #{inspect(ret)}"
end)
message = """
\n
Unexpected call received, expected no calls:
#{inspect(module)}.#{to_string(function)}
Calls which were received:
#{formatted_calls}
"""
raise UnexpectedCall, message: message
end
end
@doc """
Fakes out a module with an alternative implementation.
The real module can still be accessed with `real/1`.
For example, if your project has the module `Example.Datastore` and there's a fake available in the testing
environment named `Example.Test.InMemoryDatastore` the following table describes which calls are executed by which
code before and after faking with the following call.
```elixir
fake(Example.Datastore, Example.Test.InMemoryDatastore)
```
| Calling Code | Responding Module before fake/2 | Responding Module after fake/2 |
|--------------------------------------|--------------------------------------|--------------------------------------|
| Example.Datastore.get/1 | Example.Datastore.get/1 | Example.Test.InMemoryDatastore.get/1 |
| Example.Test.InMemoryDatastore.get/1 | Example.Test.InMemoryDatastore.get/1 | Example.Test.InMemoryDatastore.get/1 |
| real(Example.Datastore).get/1 | (UndefinedFunctionError) | Example.Datastore.get/1 |
The fake module can use the renamed module to access the original implementation.
"""
@spec fake(real_module :: module(), fake_module :: module()) :: :ok
def fake(real_module, fake_module) do
ensure_mocked(real_module)
real_functions = find_functions(real_module)
fake_functions = find_functions(fake_module)
Enum.each(fake_functions, fn {name, arity} ->
is_real_function? = Enum.any?(real_functions, &match?({^name, ^arity}, &1))
if is_real_function? do
patch(real_module, name, Patch.Function.for_arity(arity, fn args ->
apply(fake_module, name, args)
end))
end
end)
end
@doc """
Spies on the provided module
Once a module has been spied on the calls to that module can be asserted / refuted without
changing the behavior of the module.
"""
@spec spy(module :: module()) :: :ok
def spy(module) do
ensure_mocked(module)
:ok
end
@doc """
Patches a function in a module
The patched function will either always return the provided value or if a function is provided
then the function will be called and its result returned.
"""
@spec patch(module :: module(), function :: atom(), mock) :: mock when mock: fun()
def patch(module, function, mock) when is_function(mock) do
ensure_mocked(module)
:meck.expect(module, function, mock)
mock
end
@spec patch(module :: module(), function :: atom(), return_value) :: return_value
when return_value: term()
def patch(module, function, return_value) do
ensure_mocked(module)
module
|> find_arities(function)
|> Enum.each(fn arity ->
:meck.expect(module, function, Patch.Function.for_arity(arity, fn _ -> return_value end))
end)
return_value
end
@spec real(module :: Module.t()) :: Module.t()
def real(module) do
:meck_util.original_name(module)
end
@doc """
Remove any mocks or spies from the given module
"""
@spec restore(module :: module()) :: :ok
def restore(module) do
if :meck.validate(module), do: :meck.unload(module)
rescue
_ in ErlangError ->
:ok
end
## Private
@spec ensure_mocked(module :: module()) :: term()
defp ensure_mocked(module) do
:meck.validate(module)
rescue
_ in ErlangError ->
:meck.new(module, [:passthrough])
end
@spec find_functions(module :: module()) :: Keyword.t(arity())
defp find_functions(module) do
Code.ensure_loaded(module)
module.__info__(:functions)
end
@spec find_arities(module :: module(), function :: function()) :: [arity()]
defp find_arities(module, function) do
module
|> find_functions()
|> Keyword.get_values(function)
end
end