Last active
March 28, 2017 10:39
-
-
Save teamon/f759a4ced0e21b02a51dda759de5da03 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule Double do | |
@moduledoc """ | |
See http://teamon.eu/2017/different-approach-to-elixir-mocks-doubles/ | |
""" | |
## GEN SERVER | |
use GenServer | |
## CLIENT API | |
def start_link, do: GenServer.start_link(__MODULE__, [], name: __MODULE__) | |
def call(mod, fun, args), do: GenServer.call(__MODULE__, {:call, mod, fun, args}) | |
def return(mod, fun, ret), do: GenServer.call(__MODULE__, {:return, mod, fun, ret}) | |
def called?(mod, fun), do: GenServer.call(__MODULE__, {:called?, mod, fun}) | |
## CALLBACKS | |
# @type state :: %{pid => %{ | |
# returns: %{{m,f} => term}, | |
# history: [{f,a}] | |
# }} when m :: atom, f :: atom, a :: [term] | |
def init(_) do | |
{:ok, %{}} | |
end | |
def handle_call({:call, mod, fun, args}, {from, _}, state) do | |
{ret, dict} = do_call(state[from], {mod, fun, args}) | |
{:reply, ret, Map.put(state, from, dict)} | |
end | |
def handle_call({:return, mod, fun, ret}, {from, _}, state) do | |
dict = do_return(state[from], {mod, fun}, ret) | |
{:reply, :ok, Map.put(state, from, dict)} | |
end | |
def handle_call({:called?, mod, fun}, {from, _}, state) do | |
ret = do_called?(state[from], {mod, fun}) | |
{:reply, ret, state} | |
end | |
defp do_call(nil, {m,f,a}) do | |
{apply(m,f,a), %{returns: %{}, history: [{m,f,a}]}} | |
end | |
defp do_call(%{returns: returns, history: history}, {m,f,a}) do | |
# check for mocks, else pass-through | |
{ret, returns} = case Map.pop(returns, {m,f}) do | |
{nil, _} -> {apply(m,f,a), returns} | |
{ret, rest} -> {ret, rest} | |
end | |
# save call to history | |
{ret, %{returns: returns, history: [{m,f,a} | history]}} | |
end | |
defp do_return(nil, mf, ret), do: %{returns: %{mf => ret}, history: []} | |
defp do_return(dict, mf, ret), do: put_in(dict, [:returns, mf], ret) | |
defp do_called?(nil, _), do: false | |
defp do_called?(%{history: history}, {m,f}), do: Enum.find(history, &match?({^m, ^f, _}, &1)) != nil | |
## MACROS | |
defmacro __using__(_) do | |
quote do | |
@before_compile Double | |
end | |
end | |
defmacro __before_compile__(env) do | |
if Mix.env == :test do | |
defs = | |
env.module | |
|> Module.definitions_in(:def) | |
|> Enum.map(fn {name, arity} -> | |
args = mkargs(env.module, arity) | |
quote do | |
def unquote(name)(unquote_splicing(args)) do | |
Double.call(unquote(env.module), unquote(name), [unquote_splicing(args)]) | |
end | |
end | |
end) | |
quote do | |
defmodule TestDouble do | |
unquote(defs) | |
end | |
end | |
end | |
end | |
defp mkargs(_, 0), do: [] | |
defp mkargs(mod, n), do: Enum.map(1..n, &Macro.var(:"arg#{&1}", mod)) | |
## UTILS | |
def get(mod) do | |
case Mix.env do | |
:test -> Module.concat([mod, TestDouble]) | |
_ -> mod | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
defmodule DoubleTest do | |
use ExUnit.Case, async: true | |
defmodule Dep do | |
use Double | |
def get1, do: 1 | |
def get2, do: 2 | |
end | |
defmodule App do | |
@dep Double.get(Dep) | |
def run, do: @dep.get1 + @dep.get2 | |
end | |
test "default - passthrough" do | |
assert App.run == 3 | |
end | |
test "mock return value once" do | |
Double.return(Dep, :get1, 10) | |
assert App.run == 12 | |
assert App.run == 3 | |
end | |
test "mock multiple return values" do | |
Double.return(Dep, :get1, 10) | |
Double.return(Dep, :get2, 20) | |
assert App.run == 30 | |
assert App.run == 3 | |
end | |
test "track dependency calls" do | |
App.run | |
assert Double.called?(Dep, :get1) | |
assert Double.called?(Dep, :get2) | |
refute Double.called?(Dep, :get3) | |
end | |
test "keep mock local to current process" do | |
out = self() | |
{pid, ref} = spawn_monitor fn -> | |
# 1. Mock inside process | |
Double.return(Dep, :get1, 5) | |
# 2. Notify outer process that mock has been set | |
send out, :ready | |
# wait for message to execute mock | |
assert_receive :go | |
assert App.run == 7 | |
end | |
# wait for ready message | |
assert_receive :ready | |
# change the mock - should have no effect | |
Double.return(Dep, :get1, 0) | |
send pid, :go | |
assert_receive {:DOWN, ^ref, :process, _, :normal}, 100 | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment