Current section
Files
Jump to
Current section
Files
lib/promox.ex
defmodule Promox do
@moduledoc """
Documentation for `Promox`.
"""
defmodule UnexpectedCallError do
defexception [:message]
end
defmodule VerificationError do
defexception [:message]
end
@doc """
Enables mock `:for` the given protocol.
## Caveats
Since `defmock/1` is a macro, so remember to `require Promox` before calling `Promox.defmock/1`.
```
require Promox
Promox.defmock(for: MyProtocol)
```
"""
defmacro defmock(for: protocol) do
quote bind_quoted: [protocol: protocol] do
defimpl protocol, for: Promox do
for {fun, arity} <- protocol.__protocol__(:functions) do
args = Macro.generate_arguments(arity - 1, __MODULE__)
def unquote(fun)(mock, unquote_splicing(args)) do
Promox.call(
mock,
{unquote(protocol), unquote(fun), unquote(arity)},
[mock | unquote(args)]
)
end
end
end
end
end
@enforce_keys [:agent]
defstruct [:agent]
@doc """
Initialize a new mock struct.
```
my_mock = Promox.new()
# Promox.expect(my_mock, MyProtocol, :callback, fn _mock, ... -> ... end)
```
Since mocks are just isolated data structures, you can use them in concurrent processes.
```
mock1 =
Promox.new()
|> Promox.expect(MyProtocol, :callback, fn _mock, ... -> :ok end)
mock2 =
Promox.new()
|> Promox.expect(MyProtocol, :callback, fn _mock, ... -> :error end)
assert :ok = MyProtocol.callback(mock1, ...)
assert :error =
fn -> MyProtocol.callback(mock2, ...) end
|> Task.async()
|> Task.await()
```
"""
def new() do
{:ok, agent} = Agent.start_link(Promox.State, :new, [])
%__MODULE__{agent: agent}
end
@doc """
Allows the `protocol.name` callback with arity given by `code` to be invoked with `mock` any times.
The call would be delegated to `code` and returns whatever `code` returns.
## Caveat
1. The first argument passed to `code` is always the `mock` being stubbed.
2. `stub/4` will overwrite any previous calls to `stub/4`
3. If expectations and stubs are defined for the same function and arity, the stub is invoked only after all expectations are fulfilled.
## Examples
To allow `MyProtocol.callback/1` to be called any times:
```
my_mock =
Promox.new()
|> Promox.stub(MyProtocol, :callback, fn _mock -> :ok end)
```
"""
def stub(mock, protocol, name, code) do
verify_protocol!(protocol, mock)
verify_callback!(protocol, name, code)
:ok = Agent.update(mock.agent, &Promox.State.stub(&1, protocol, name, code))
mock
end
@doc """
Expects the `protocol.name` callback with arity given by `code` to be invoked with `mock` `n` times.
## Examples
To expect `MyProtocol.callback/1` to be called once:
```
my_mock =
Promox.new()
|> Promox.expect(MyProtocol, :callback, fn _mock -> :ok end)
```
To expect `MyProtocol.callback/1` to be called five times:
```
my_mock =
Promox.new()
|> Promox.expect(MyProtocol, :callback, 5, fn _mock -> :ok end)
```
"""
def expect(mock, protocol, name, n \\ 1, code) do
verify_protocol!(protocol, mock)
verify_callback!(protocol, name, code)
:ok = Agent.update(mock.agent, &Promox.State.expect(&1, protocol, name, n, code))
mock
end
defp verify_protocol!(protocol, mock) do
case protocol.impl_for(mock) do
nil ->
raise ArgumentError,
"unmocked Protocol #{inspect(protocol)}. Call Promox.defmock(for: #{inspect(protocol)}) first."
_ ->
:ok
end
end
defp verify_callback!(protocol, name, code) do
{:arity, arity} = Function.info(code, :arity)
if Enum.find(protocol.__protocol__(:functions), &(&1 == {name, arity})),
do: :ok,
else:
raise(
ArgumentError,
"unknown callback function #{Exception.format_mfa(protocol, name, arity)}"
)
end
@doc false
def call(mock, pfa, args) do
mock.agent
|> Agent.get_and_update(&Promox.State.retrieve(&1, pfa))
|> case do
nil ->
{protocol, fun, arity} = pfa
raise UnexpectedCallError,
"no expectation defined for #{Exception.format_mfa(protocol, fun, arity)}"
fun when is_function(fun) ->
apply(fun, args)
end
end
@doc """
Verifys that all the expectations set for the `mock` have been called.
Returns `:ok` if so;
Otherwise, raises `Promox.VerificationError`.
"""
def verify!(mock) do
mock.agent
|> Agent.get(&Promox.State.get_expects/1)
|> tap(fn expects ->
Enum.map(expects, fn
{{protocol, fun, arity}, nil} ->
raise UnexpectedCallError,
~s"""
unexpected call to #{Exception.format_mfa(protocol, fun, arity)}
Usually, this error means that the mock was not used to stub or expect this function.
And when this function was called, a Promox.UnexpectedCallError should be raised immediately.
But this error was raised from Promox.verify!/1, which means that the above Promox.UnexpectedCallError was rescued.
You may also want to check if your code is rescuing more exceptions than it should.
"""
_ ->
:ok
end)
end)
|> Enum.filter(fn {_pfa, {expects, _used_expects}} -> length(expects) > 0 end)
|> case do
[] ->
:ok
unmet_expects ->
messages =
unmet_expects
|> Enum.map(fn {{protocol, fun, arity}, {expects, used_expects}} ->
total = length(expects) + length(used_expects)
called = length(used_expects)
" * expect #{Exception.format_mfa(protocol, fun, arity)} to be called #{times(total)}, but it was called #{times(called)}"
end)
raise VerificationError,
"error while verifying mocks for these protocols:\n\n" <> Enum.join(messages, "\n")
end
end
defp times(1), do: "once"
defp times(n), do: "#{n} times"
end