Skip to content

Instantly share code, notes, and snippets.

@josevalim
Last active September 22, 2017 10:17
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save josevalim/c1b1ca9c3ff1d68ce15ef46ddbad3f59 to your computer and use it in GitHub Desktop.
Save josevalim/c1b1ca9c3ff1d68ce15ef46ddbad3f59 to your computer and use it in GitHub Desktop.
A library for creating mocks (not mocking!) that is also concurrent (similar to Ecto's sandbox)
# This is the library
defmodule Expect do
# TODO: This should actually be started inside the supervisor in an app
@name __MODULE__
Registry.start_link(:unique, @name)
def defmock(name, options) do
behaviour = options[:for] || raise ArgumentError, ":for option is required on defmock"
validate_behaviour!(behaviour)
define_mock_module(name, behaviour)
name
end
defp validate_behaviour!(behaviour) do
cond do
not Code.ensure_loaded?(behaviour) ->
raise ArgumentError, "#{inspect behaviour} is not available, please pass an existing module to :for"
not function_exported?(behaviour, :behaviour_info, 1) ->
raise ArgumentError, "#{inspect behaviour} is not a behaviour, please pass a behaviour to :for"
true ->
:ok
end
end
defp define_mock_module(name, behaviour) do
funs =
for {fun, arity} <- behaviour.behaviour_info(:callbacks) do
args = 0..arity |> Enum.to_list |> tl() |> Enum.map(&Macro.var(:"arg#{&1}", Elixir))
quote do
def unquote(fun)(unquote_splicing(args)) do
Expect.__dispatch__(__MODULE__, unquote(fun), unquote(arity), unquote(args))
end
end
end
info =
quote do
def __mock_for__ do
unquote(behaviour)
end
end
Module.create(name, [info | funs], Macro.Env.location(__ENV__))
end
def expect(mock, name, code) do
arity = :erlang.fun_info(code)[:arity]
validate_mock!(mock, name, arity)
case Registry.register(@name, {self(), mock, name, arity}, code) do
{:ok, _} ->
mock
{:error, _} ->
mfa = Exception.format_mfa(mock, name, arity)
raise ArgumentError, "expectation already set for #{mfa} in the current process"
end
end
defp validate_mock!(mock, name, arity) do
cond do
not Code.ensure_loaded?(mock) ->
raise ArgumentError, "mock #{inspect mock} is not available"
not function_exported?(mock, :__mock_for__, 0) ->
raise ArgumentError, "module #{inspect mock} is not a mock"
not function_exported?(mock, name, arity) ->
raise ArgumentError, "unknown #{name}/#{arity} for mock #{inspect mock}"
true ->
:ok
end
end
@doc false
def __dispatch__(mock, name, arity, args) do
case Registry.lookup(@name, {self(), mock, name, arity}) do
[] ->
mfa = Exception.format_mfa(mock, name, arity)
raise "no mock defined for #{mfa} in process #{inspect self()}"
[{_, code}] ->
apply(code, args)
end
end
end
# In your code
defmodule MyApp.Behaviour do
@callback add(integer(), integer()) :: integer()
@callback mult(integer(), integer()) :: integer()
end
# You call this in your test helper, as it is expensive since it defines module.
# Then you can have all of your tests use MyApp.CalcMock
Expect.defmock(MyApp.CalcMock, for: MyApp.Behaviour)
MyApp.CalcMock
|> Expect.expect(:add, fn x, y -> x + y end)
|> Expect.expect(:mult, fn x, y -> x * y end)
IO.puts MyApp.CalcMock.add(2, 3)
IO.puts MyApp.CalcMock.mult(2, 3)
@josevalim
Copy link
Author

josevalim commented Sep 22, 2017

This code is under the MIT License. Feel free to do whatever you want with it as long as you link to the gist (so its original goals are not lost).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment