-
-
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 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
# 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) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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).