Skip to content

Instantly share code, notes, and snippets.

@teamon
Last active March 28, 2017 10:39
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save teamon/f759a4ced0e21b02a51dda759de5da03 to your computer and use it in GitHub Desktop.
Save teamon/f759a4ced0e21b02a51dda759de5da03 to your computer and use it in GitHub Desktop.
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
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