Current section
Files
Jump to
Current section
Files
lib/double.ex
defmodule Double do
@moduledoc """
Double builds on-the-fly injectable dependencies for your tests.
It does NOT override behavior of existing modules or functions.
Double uses Elixir's built-in language features such as pattern matching and message passing to
give you everything you would normally need a complex mocking tool for.
"""
alias Double.Registry
alias Double.FuncList
use GenServer
@default_options [verify: true]
@type allow_option :: {:with, [...]}
| {:returns, any}
| {:raises, String.t
| {atom, String.t}}
@type double_option :: {:verify, true | false}
# API
@spec double :: map
@spec double(struct, [double_option]) :: struct
@spec double(atom, [double_option]) :: atom
@doc """
Returns a map that can be used to setup stubbed functions.
"""
def double, do: double(%{})
@doc """
Same as double/0 but can return structs and modules too
"""
def double(source, opts \\ @default_options) do
Registry.start
test_pid = self()
{:ok, pid} = GenServer.start_link(__MODULE__, [])
double_id = case is_atom(source) do
true ->
source_name = source |> Atom.to_string |> String.split(".") |> List.last
"#{source_name}Double#{:erlang.unique_integer([:positive])}"
false ->
:sha
|> :crypto.hash(inspect(pid))
|> Base.encode16
|> String.downcase
end
Registry.register_double(double_id, pid, test_pid, source, opts)
case is_atom(source) do
true -> double_id |> String.to_atom
false -> Map.put(source, :_double_id, double_id)
end
end
@doc """
Adds a stubbed function to the given map, struct, or module.
Structs will fail if they are missing the key given for function_name.
Modules will fail if the function is not defined.
"""
@spec allow(any, atom, [function | [allow_option]]) :: struct | map | atom
def allow(dbl, function_name) when is_atom(function_name), do: allow(dbl, function_name, with: [])
def allow(dbl, function_name, func_opts) when is_list(func_opts) do
return_values = Enum.reduce(func_opts, [], fn({k, v}, acc) ->
if k == :returns, do: acc ++ [v], else: acc
end)
return_values = if return_values == [], do: [nil], else: return_values
option_sets = return_values |> Enum.reduce([], fn(return_value, acc) ->
append_opts = func_opts
|> Keyword.take([:with, :raises])
|> Keyword.put(:returns, return_value)
acc ++ [append_opts]
end)
option_sets |> Enum.reduce(dbl, fn(opts, acc) ->
{func, _} = create_function_from_opts(opts)
allow(acc, function_name, func)
end)
end
def allow(dbl, function_name, func) when is_function(func) do
dbl
|> verify_mod_double(function_name, func)
|> verify_struct_double(function_name)
|> do_allow(function_name, func)
end
@doc false
def func_list(pid) do
GenServer.call(pid, :func_list)
end
defp do_allow(dbl, function_name, func) do
double_id = if is_atom(dbl), do: Atom.to_string(dbl), else: dbl._double_id
pid = Registry.whereis_double(double_id)
GenServer.call(pid, {:allow, dbl, function_name, func})
end
defp verify_mod_double(dbl, function_name, func) when is_atom(dbl) do
double_opts = Registry.opts_for("#{dbl}")
if double_opts[:verify] do
source = Registry.source_for("#{dbl}")
source_functions = source.__info__(:functions)
stub_arity = :erlang.fun_info(func)[:arity]
matching_function = Enum.find(source_functions, fn({k, v}) ->
k == function_name && v == stub_arity
end)
if matching_function == nil do
raise VerifyingDoubleError, message: "The function '#{function_name}/#{stub_arity}' is not defined in TestModuleDouble"
end
end
dbl
end
defp verify_mod_double(dbl, _, _), do: dbl
defp verify_struct_double(%{__struct__: _} = dbl, function_name) do
if Enum.member?(Map.keys(dbl), function_name) do
dbl
else
struct_key_error(dbl, function_name)
end
end
defp verify_struct_double(dbl, _), do: dbl
# SERVER
def init([]) do
{:ok, pid} = GenServer.start_link(FuncList, [])
{:ok, %{func_list: pid}}
end
@doc false
def handle_call(:func_list, _from, state) do
{:reply, state.func_list, state}
end
@doc false
def handle_call({:allow, dbl, function_name, func}, _from, state) do
FuncList.push(state.func_list, function_name, func)
dbl = case is_atom(dbl) do
true ->
stub_module(dbl, state)
dbl
false ->
dbl
|> Map.put(
function_name,
stub_function(dbl._double_id, function_name, func)
)
end
{:reply, dbl, state}
end
defp stub_module(mod, state) do
funcs = state.func_list
|> FuncList.list
|> Enum.uniq_by(fn({function_name, func}) ->
{function_name, arity(func)}
end)
code = """
defmodule :#{mod} do
"""
code = Enum.reduce(funcs, code, fn({function_name, func}, acc) ->
{signature, message} = function_parts(function_name, func)
acc <> """
def #{function_name}(#{signature}) do
#{function_body(mod, message, function_name, signature)}
end
"""
end)
code = code <> "\nend"
Code.compiler_options(ignore_module_conflict: true)
Code.eval_string(code)
Code.compiler_options(ignore_module_conflict: false)
end
defp stub_function(double_id, function_name, func) do
{signature, message} = function_parts(function_name, func)
func_str = """
fn(#{signature}) ->
#{function_body(double_id, message, function_name, signature)}
end
"""
{result, _} = Code.eval_string(func_str)
result
end
defp function_body(double_id, message, function_name, signature) do
"""
test_pid = Double.Registry.whereis_test(\"#{double_id}\")
send(test_pid, #{message})
pid = Double.Registry.whereis_double(\"#{double_id}\")
func_list = Double.func_list(pid)
Double.FuncList.apply(func_list, :#{function_name}, [#{signature}])
"""
end
defp function_parts(function_name, func) do
signature = case arity(func) do
0 -> ""
x ->
0..(x - 1)
|> Enum.map(fn(i) -> << 97 + i :: utf8 >> end)
|> Enum.join(", ")
end
message = case signature do
"" -> ":#{function_name}"
_ -> "{:#{function_name}, #{signature}}"
end
{signature, message}
end
defp arity(func) do
:erlang.fun_info(func)[:arity]
end
defp struct_key_error(dbl, key) do
msg = "The struct #{dbl.__struct__} does not contain key: #{key}. Use a Map if you want to add dynamic function names."
raise ArgumentError, message: msg
end
defp create_function_from_opts(opts) do
args = case opts[:with] do
{:any, with_arity} ->
0..(with_arity - 1)
|> Enum.map(fn(i) -> << 97 + i :: utf8 >> |> String.to_atom end)
|> Enum.map(fn(arg_atom) -> {arg_atom, [], Elixir} end)
nil -> []
with_args -> with_args
end
args
|> quoted_fn(opts)
|> Code.eval_quoted
end
defp quoted_fn(args, opts) do
{:fn, [], [{:->, [], [args, quoted_fn_body(opts, opts[:raises])]}]}
end
defp quoted_fn_body(_opts, {error_module, message}) do
{
:raise,
[context: Elixir, import: Kernel],
[{:__aliases__, [alias: false], [error_module]}, message]
}
end
defp quoted_fn_body(_opts, message) when is_binary(message) do
{
:raise,
[context: Elixir, import: Kernel],
[message]
}
end
defp quoted_fn_body(opts, nil) do
opts[:returns]
end
end