Skip to content

Instantly share code, notes, and snippets.

@teamon
Last active June 9, 2017 10:34
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/e3d880e183cc5a2a2afaaeb4a2d72975 to your computer and use it in GitHub Desktop.
Save teamon/e3d880e183cc5a2a2afaaeb4a2d72975 to your computer and use it in GitHub Desktop.
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