defmodule Patch.Assertions do
alias Patch.MissingCall
alias Patch.Mock
alias Patch.UnexpectedCall
@doc """
Asserts that the given module and function has been called with any arity.
```elixir
patch(Example, :function, :patch)
Patch.Assertions.assert_any_call(Example, :function) # fails
Example.function(1, 2, 3)
Patch.Asertions.assert_any_call(Example, :function) # passes
```
There are convenience delegates in the Developer Interface, `Patch.assert_any_call/1` and
`Patch.assert_any_call/2` which should be preferred over calling this function directly.
"""
@spec assert_any_call(module :: module(), function :: atom()) :: nil
def assert_any_call(module, function) do
unless Mock.called?(module, function) do
message = """
\n
Expected any call to the following function:
#{inspect(module)}.#{to_string(function)}
Calls which were received (matching calls are marked with *):
#{format_calls_matching_any(module, function)}
"""
raise MissingCall, message: message
end
end
@doc """
Given a call will assert that a matching call was observed by the patched function.
This macro fully supports patterns and will perform non-hygienic binding similar to ExUnit's
`assert_receive/3` and `assert_received/2`.
```elixir
patch(Example, :function, :patch)
Example.function(1, 2, 3)
Patch.Assertions.assert_called(Example, :function, [1, 2, 3]) # passes
Patch.Assertions.assert_called(Example, :function, [1, _, 3]) # passes
Patch.Assertions.assert_called(Example, :function, [4, 5, 6]) # fails
Patch.Assertions.assert_called(Example, :function, [4, _, 6]) # fails
```
There is a convenience macro in the Developer Interface, `Patch.assert_called/1` which should be
preferred over calling this macro directly.
"""
@spec assert_called(call :: Macro.t()) :: Macro.t()
defmacro assert_called(call) do
{module, function, patterns} = Macro.decompose_call(call)
quote do
unless Patch.Mock.called?(unquote(call)) do
history = Patch.Mock.match_history(unquote(call))
message = """
\n
Expected but did not receive the following call:
#{inspect(unquote(module))}.#{to_string(unquote(function))}(#{Patch.Assertions.format_patterns(unquote(patterns))})
Calls which were received (matching calls are marked with *):
#{Patch.Assertions.format_history(unquote(module), history)}
"""
raise MissingCall, message: message
end
{:ok, {unquote(function), arguments}} = Patch.Mock.latest_match(unquote(call))
Patch.Macro.match(unquote(patterns), arguments)
end
end
@doc """
Given a call will assert that a matching call was observed exactly the number of times provided
by the patched function.
This macro fully supports patterns and will perform non-hygienic binding similar to ExUnit's
`assert_receive/3` and `assert_received/2`. The value bound will be the from the latest call.
```elixir
patch(Example, :function, :patch)
Example.function(1, 2, 3)
Patch.Assertions.assert_called(Example, :function, [1, 2, 3], 1) # passes
Patch.Assertions.assert_called(Example, :function, [1, _, 3], 1) # passes
Example.function(1, 2, 3)
Patch.Assertions.assert_called(Example, :function, [1, 2, 3], 2) # passes
Patch.Assertions.assert_called(Example, :function, [1, _, 3], 2) # passes
```
There is a convenience macro in the Developer Interface, `Patch.assert_called/2` which
should be preferred over calling this macro directly.
"""
@spec assert_called(call :: Macro.t(), count :: non_neg_integer()) :: Macro.t()
defmacro assert_called(call, count) do
{module, function, patterns} = Macro.decompose_call(call)
quote do
call_count = Patch.Mock.call_count(unquote(call))
unless call_count == unquote(count) do
exception =
if call_count < unquote(count) do
MissingCall
else
UnexpectedCall
end
history = Patch.Mock.match_history(unquote(call))
message = """
\n
Expected #{unquote(count)} of the following calls, but found #{call_count}:
#{inspect(unquote(module))}.#{to_string(unquote(function))}(#{Patch.Assertions.format_patterns(unquote(patterns))})
Calls which were received (matching calls are marked with *):
#{Patch.Assertions.format_history(unquote(module), history)}
"""
raise exception, message
end
{:ok, {unquote(function), arguments}} = Patch.Mock.latest_match(unquote(call))
Patch.Macro.match(unquote(patterns), arguments)
end
end
@doc """
Given a call will assert that a matching call was observed exactly once by the patched function.
This macro fully supports patterns and will perform non-hygienic binding similar to ExUnit's
`assert_receive/3` and `assert_received/2`.
```elixir
patch(Example, :function, :patch)
Example.function(1, 2, 3)
Patch.Assertions.assert_called_once(Example, :function, [1, 2, 3]) # passes
Patch.Assertions.assert_called_once(Example, :function, [1, _, 3]) # passes
Example.function(1, 2, 3)
Patch.Assertions.assert_called_once(Example, :function, [1, 2, 3]) # fails
Patch.Assertions.assert_called_once(Example, :function, [1, _, 3]) # fails
```
There is a convenience macro in the Developer Interface, `Patch.assert_called_once/1` which
should be preferred over calling this macro directly.
"""
@spec assert_called_once(call :: Macro.t()) :: Macro.t()
defmacro assert_called_once(call) do
{module, function, patterns} = Macro.decompose_call(call)
quote do
call_count = Patch.Mock.call_count(unquote(call))
unless call_count == 1 do
exception =
if call_count == 0 do
MissingCall
else
UnexpectedCall
end
history = Patch.Mock.match_history(unquote(call))
message = """
\n
Expected the following call to occur exactly once, but call occurred #{call_count} times:
#{inspect(unquote(module))}.#{to_string(unquote(function))}(#{Patch.Assertions.format_patterns(unquote(patterns))})
Calls which were received (matching calls are marked with *):
#{Patch.Assertions.format_history(unquote(module), history)}
"""
raise exception, message
end
{:ok, {unquote(function), arguments}} = Patch.Mock.latest_match(unquote(call))
Patch.Macro.match(unquote(patterns), arguments)
end
end
@doc """
Refutes that the given module and function has been called with any arity.
```elixir
patch(Example, :function, :patch)
Patch.Assertions.refute_any_call(Example, :function) # passes
Example.function(1, 2, 3)
Patch.Assertions.refute_any_call(Example, :function) # fails
```
There are convenience delegates in the Developer Interface, `Patch.refute_any_call/1` and
`Patch.refute_any_call/2` which should be preferred over calling this function directly.
"""
@spec refute_any_call(module :: module(), function :: atom()) :: nil
def refute_any_call(module, function) do
if Mock.called?(module, function) do
message = """
\n
Unexpected call received, expected no calls:
#{inspect(module)}.#{to_string(function)}
Calls which were received (matching calls are marked with *):
#{format_calls_matching_any(module, function)}
"""
raise UnexpectedCall, message: message
end
end
@doc """
Given a call will refute that a matching call was observed by the patched function.
This macro fully supports patterns.
```elixir
patch(Example, :function, :patch)
Example.function(1, 2, 3)
Patch.Assertions.refute_called(Example, :function, [4, 5, 6]) # passes
Patch.Assertions.refute_called(Example, :function, [4, _, 6]) # passes
Patch.Assertions.refute_called(Example, :function, [1, 2, 3]) # fails
Patch.Assertions.refute_called(Example, :function, [1, _, 3]) # passes
```
There is a convenience macro in the Developer Interface, `Patch.refute_called/1` which should be
preferred over calling this macro directly.
"""
@spec refute_called(call :: Macro.t()) :: Macro.t()
defmacro refute_called(call) do
{module, function, patterns} = Macro.decompose_call(call)
quote do
if Patch.Mock.called?(unquote(call)) do
history = Patch.Mock.match_history(unquote(call))
message = """
\n
Unexpected call received:
#{inspect(unquote(module))}.#{to_string(unquote(function))}(#{Patch.Assertions.format_patterns(unquote(patterns))})
Calls which were received (matching calls are marked with *):
#{Patch.Assertions.format_history(unquote(module), history)}
"""
raise UnexpectedCall, message: message
end
end
end
@doc """
Given a call will refute that a matching call was observed exactly the number of times provided
by the patched function.
This macro fully supports patterns.
```elixir
patch(Example, :function, :patch)
Example.function(1, 2, 3)
Patch.Assertions.refute_called(Example, :function, [1, 2, 3], 2) # passes
Patch.Assertions.refute_called(Example, :function, [1, _, 3], 2) # passes
Example.function(1, 2, 3)
Patch.Assertions.refute_called(Example, :function, [1, 2, 3], 1) # passes
Patch.Assertions.refute_called(Example, :function, [1, _, 3], 1) # passes
```
There is a convenience macro in the Developer Interface, `Patch.refute_called/2` which
should be preferred over calling this macro directly.
"""
@spec refute_called(call :: Macro.t(), count :: non_neg_integer()) :: Macro.t()
defmacro refute_called(call, count) do
{module, function, patterns} = Macro.decompose_call(call)
quote do
call_count = Patch.Mock.call_count(unquote(call))
if call_count == unquote(count) do
history = Patch.Mock.match_history(unquote(call))
message = """
\n
Expected any count except #{unquote(count)} of the following calls, but found #{call_count}:
#{inspect(unquote(module))}.#{to_string(unquote(function))}(#{Patch.Assertions.format_patterns(unquote(patterns))})
Calls which were received (matching calls are marked with *):
#{Patch.Assertions.format_history(unquote(module), history)}
"""
raise UnexpectedCall, message
end
end
end
@doc """
Given a call will refute that a matching call was observed exactly once by the patched function.
This macro fully supports patterns.
```elixir
patch(Example, :function, :patch)
Example.function(1, 2, 3)
Patch.Assertions.refute_called_once(Example, :function, [1, 2, 3]) # fails
Patch.Assertions.refute_called_once(Example, :function, [1, _, 3]) # fails
Example.function(1, 2, 3)
Patch.Assertions.refute_called_once(Example, :function, [1, 2, 3]) # passes
Patch.Assertions.refute_called_once(Example, :function, [1, _, 3]) # passes
```
There is a convenience macro in the Developer Interface, `Patch.refute_called_once/1` which
should be preferred over calling this macro directly.
"""
@spec refute_called_once(call :: Macro.t()) :: Macro.t()
defmacro refute_called_once(call) do
{module, function, patterns} = Macro.decompose_call(call)
quote do
call_count = Patch.Mock.call_count(unquote(call))
if call_count == 1 do
history = Patch.Mock.match_history(unquote(call))
message = """
\n
Expected the following call to occur any number of times but once, but it occurred once:
#{inspect(unquote(module))}.#{to_string(unquote(function))}(#{Patch.Assertions.format_patterns(unquote(patterns))})
Calls which were received (matching calls are marked with *):
#{Patch.Assertions.format_history(unquote(module), history)}
"""
raise UnexpectedCall, message
end
end
end
@doc """
Formats the AST for a list of patterns AST as they would appear in an argument list.
"""
@spec format_patterns(patterns :: [term()]) :: String.t()
defmacro format_patterns(patterns) do
patterns
|> Macro.to_string()
|> String.slice(1..-2)
end
@doc """
Formats history entries like those returned by `Patch.Mock.match_history/1`.
"""
@spec format_history(module :: module(), calls :: [{boolean(), {atom(), [term()]}}]) :: String.t()
def format_history(module, calls) do
calls
|> Enum.reverse()
|> Enum.with_index(1)
|> Enum.map(fn {{matches, {function, arguments}}, i} ->
marker =
if matches do
"* "
else
" "
end
"#{marker}#{i}. #{inspect(module)}.#{function}(#{format_arguments(arguments)})"
end)
|> case do
[] ->
" [No Calls Received]"
calls ->
Enum.join(calls, "\n")
end
end
## Private
@spec format_arguments(arguments :: [term()]) :: String.t()
defp format_arguments(arguments) do
arguments
|> Enum.map(&Kernel.inspect/1)
|> Enum.join(", ")
end
@spec format_calls_matching_any(module :: module(), expected_function :: atom()) :: String.t()
defp format_calls_matching_any(module, expected_function) do
module
|> Patch.history()
|> Enum.with_index(1)
|> Enum.map(fn {{actual_function, arguments}, i} ->
marker =
if expected_function == actual_function do
"* "
else
" "
end
"#{marker}#{i}. #{inspect(module)}.#{actual_function}(#{format_arguments(arguments)})"
end)
|> case do
[] ->
" [No Calls Received]"
calls ->
Enum.join(calls, "\n")
end
end
end