defmodule Mockable do
require Logger
@moduledoc """
Documentation for `Mockable`.
"""
@doc """
Configures which module to use for a given mockable module.
If no implementation is provided, the mockable module itself will be used.
This is useful for testing your Mockable module.
```elixir
Mockable.use(Client)
```
Or to use a specific implementation:
```elixir
Mockable.use(Client, ClientMock)
```
This function stores the configuration in process memory so that it is compatible with async tests.
"""
def use(mockable, implementation \\ nil) do
implementation = implementation || mockable
Process.put({Mockable, mockable}, implementation)
end
@doc """
Configures an implementation to be used only for the duration of the given function.
This is used internally and is not expected to be useful for end users, but is documented for completeness.
"""
def use(mockable, implementation, func) do
before = Process.get({Mockable, mockable}, :not_set)
Process.put({Mockable, mockable}, implementation)
try do
func.()
after
case before do
:not_set -> Process.delete({Mockable, mockable})
_ -> Process.put({Mockable, mockable}, before)
end
end
end
defmacro __using__(_opts) do
quote do
@before_compile unquote(__MODULE__)
@behaviour __MODULE__
end
end
defmacro __before_compile__(env) do
module = env.module
module_string = module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")
if Application.get_env(:mockable, module) do
callback_specs =
Module.get_attribute(module, :callback)
|> Enum.map(fn {:callback, spec, _} ->
case spec do
{:"::", _, [{name, _, args}, _return_type]} ->
{{name, length(args)}, spec}
end
end)
|> Map.new()
|> Map.take(Module.definitions_in(module, :def))
wrapped_functions =
for {{name, arity}, spec} <- callback_specs do
args = Macro.generate_arguments(arity, __MODULE__)
quote do
defoverridable [{unquote(name), unquote(arity)}]
@spec unquote(spec)
def unquote(name)(unquote_splicing(args)) do
implementation =
Process.get({Mockable, unquote(module)}) ||
Application.get_env(:mockable, unquote(module))
if implementation != unquote(module) do
Mockable.log_implementation_usage(
implementation,
unquote(name),
unquote(arity),
unquote(module_string)
)
apply(implementation, unquote(name), [unquote_splicing(args)])
else
Mockable.log_implementation_usage(
implementation,
unquote(name),
unquote(arity),
unquote(module_string)
)
super(unquote_splicing(args))
end
end
end
end
impl_functions =
for {{name, arity}, _spec} <- callback_specs do
args = Macro.generate_arguments(arity, __MODULE__)
quote do
def unquote(name)(unquote_splicing(args)) do
Mockable.use(unquote(module), unquote(module), fn ->
apply(unquote(module), unquote(name), [unquote_splicing(args)])
end)
end
end
end
quote do
require Logger
(unquote_splicing(wrapped_functions))
defmodule Impl do
@behaviour unquote(module)
(unquote_splicing(impl_functions))
end
end
end
end
if Application.compile_env(:mockable, :log, true) do
def log_implementation_usage(implementation, function_name, arity, module) do
Logger.debug("Using #{inspect(implementation)}.#{function_name}/#{arity} for #{module}")
end
else
def log_implementation_usage(_implementation, _function_name, _arity, _module), do: :ok
end
end