Last active
June 9, 2017 10:34
-
-
Save teamon/e3d880e183cc5a2a2afaaeb4a2d72975 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 Twin do | |
@moduledoc """ | |
See http://teamon.eu/2017/different-approach-to-elixir-mocks-doubles/ | |
Example usage | |
defmodule MyApp.Main do | |
@api Twin.get(MyApp.Api) | |
def run do | |
@api.post(..) | |
end | |
end | |
# in tests | |
import Twin | |
stub(MyApp.Api, :post, {:ok, 123}) | |
assert MyApp.Main.run() == {:ok, 123} | |
""" | |
## PROXY | |
defmodule Proxy do | |
def unquote(:"$handle_undefined_function")(fun, args) do | |
[{__MODULE__, mod} | rest] = Enum.reverse(args) | |
args = Enum.reverse(rest) | |
case Twin.call(mod, fun, args) do | |
{:ok, ret} -> ret | |
{:error, :nostub} -> apply(mod, fun, args) | |
end | |
end | |
end | |
## MACROS | |
def assert_called(mod, fun) do | |
ExUnit.Assertions.assert Twin.called?(mod, fun), "#{mod}.#{fun} was not called" | |
end | |
def assert_called(mod, fun, args) do | |
case Twin.called?(mod, fun, args) do | |
:ok -> :ok | |
{:error, %{history: history}} -> | |
history = | |
history | |
|> Enum.map(fn {_,f,a} -> "#{f}(#{format_args(a)})" end) | |
|> Enum.join("\n") | |
msg = """ | |
#{mod}.#{fun}(#{format_args(args)}) was NOT called | |
Recorded calls to module #{mod}: | |
#{history} | |
""" | |
raise ExUnit.AssertionError, message: msg | |
end | |
end | |
def refute_called(mod, fun) do | |
ExUnit.Assertions.refute Twin.called?(mod, fun), "#{mod}.#{fun} was called" | |
end | |
def refute_called(mod, fun, args) do | |
ExUnit.Assertions.refute Twin.called?(mod, fun, args), | |
"#{mod}.#{fun}(#{args |> Enum.map(&inspect/1) |> Enum.join(", ")}) was called" | |
end | |
def verify_stubs do | |
stubs = Twin.stubs | |
ExUnit.Assertions.assert stubs == [], | |
"Following stubs were not called:\n#{stubs |> Enum.map(&inspect/1) |> Enum.join("\n")}" | |
end | |
defp format_args(args), do: args |> Enum.map(&inspect/1) |> Enum.join(", ") | |
use GenServer | |
## CLIENT API | |
def start, do: GenServer.start(__MODULE__, [], name: __MODULE__) | |
def call(mod, fun, args), do: GenServer.call(__MODULE__, {:call, {mod, fun, args}}) | |
def called?(mod, fun), do: GenServer.call(__MODULE__, {:called?, {mod, fun}}) | |
def called?(mod, fun, args), do: GenServer.call(__MODULE__, {:called?, {mod, fun, args}}) | |
def stubs(pid \\ self()), do: GenServer.call(__MODULE__, {:stubs, pid}) | |
def stub(pid \\ self(), mod, fun, ret) do | |
if apply(mod, :__info__, [:functions])[fun] do | |
GenServer.call(__MODULE__, {:stub, pid, {mod, fun, ret}}) | |
mod | |
else | |
raise ExUnit.AssertionError, message: "Module #{mod} does not export function #{fun}" | |
end | |
end | |
def get(mod) do | |
case Mix.env do | |
:test -> {Twin.Proxy, mod} | |
_ -> mod | |
end | |
end | |
## CALLBACKS | |
def init(_) do | |
{:ok, %{}} | |
end | |
def handle_call({:call, mfa}, {pid, _}, state) do | |
{ret, dict} = do_call(state[pid], mfa) | |
{:reply, ret, Map.put(state, pid, dict)} | |
end | |
def handle_call({:stub, pid, mfr}, _, state) do | |
dict = do_stub(state[pid], mfr) | |
{:reply, :ok, Map.put(state, pid, dict)} | |
end | |
def handle_call({:called?, mfa}, {pid, _}, state) do | |
if do_called?(state[pid], mfa) do | |
{:reply, :ok, state} | |
else | |
{:reply, {:error, state[pid]}, state} | |
end | |
end | |
def handle_call({:stubs, pid}, _, state) do | |
{:reply, get_in(state, [pid, :stubs]) || [], state} | |
end | |
## INTERNALS | |
defp do_call(nil, mfa) do | |
{{:error, :nostub}, %{stubs: [], history: [mfa]}} | |
end | |
defp do_call(%{stubs: stubs, history: history}, {m,f,_} = mfa) do | |
# check for stubs, else pass-through | |
{ret, stubs} = case find_stub(stubs, {m,f}) do | |
{nil, stubs} -> {{:error, :nostub}, stubs} | |
{ret, stubs} -> {{:ok, ret}, stubs} | |
end | |
# save call to history | |
{ret, %{stubs: stubs, history: [mfa | history]}} | |
end | |
defp do_stub(nil, mfr), do: %{stubs: [mfr], history: []} | |
defp do_stub(dict, mfr), do: %{dict | stubs: dict.stubs ++ [mfr]} | |
defp do_called?(nil, _), do: false | |
defp do_called?(%{history: history}, {m,f}), do: Enum.find(history, &match?({^m, ^f, _}, &1)) != nil | |
defp do_called?(%{history: history}, {m,f,a}), do: Enum.find(history, &match?({^m, ^f, ^a}, &1)) != nil | |
defp find_stub(xs, mf), do: find_stub(xs, mf, []) | |
defp find_stub([], _, rest), do: {nil, Enum.reverse(rest)} | |
defp find_stub([{m,f,r} | xs], {m,f}, rest), do: {r, Enum.reverse(rest) ++ xs} | |
defp find_stub([x | xs], mf, rest), do: find_stub(xs, mf, [x | rest]) | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment