Ersatz v0.2.0 Ersatz View Source
Ersatz is a library for defining mocks in Elixir.
- Mocks are generated based on behaviours during configuration and injected using env variables.
- Add the mock behaviour by specifying functions to be used during tests with
Ersatz.set_mock_implementation/3
orErsatz.set_mock_return_value/3
. - Test your code's actions on the mock dependency using
Ersatz.get_mock_calls/1
or the Espec custom matchers.
Example
As an example, imagine that your library defines a calculator behaviour:
defmodule MyApp.Calculator do
@callback add(integer(), integer()) :: integer()
@callback mult(integer(), integer()) :: integer()
end
If you want to mock the calculator behaviour during tests, the first step
is to define the mock, usually in your test_helper.exs
:
Ersatz.defmock(MyApp.CalcMock, for: MyApp.Calculator)
Now in your tests, you can define mock implementations and verify that they were called the right number of times and with the right parameters:
use ExUnit.Case, async: true
import Ersatz
test "invokes add and mult" do
# Arrange
Ersatz.set_mock_implementation(&MyApp.CalcMock.add/2, fn x, y -> x + y end)
Ersatz.set_mock_return_value(&MyApp.CalcMock.mult/2, 42)
# Act
add_result = MyApp.CalcMock.add(2, 3)
mult_result = MyApp.CalcMock.mult(4, 1)
# Assert
assert add_result == 5 # assert the result is the one we expected
assert mult_result == 42 # assert the result is the one we expected
add_mock_calls = Ersatz.get_mock_calls(&MyApp.CalcMock.add/2) # get the calls our mock implementation received
assert add_mock_calls == [[2, 3]] # assert the call args are the ones we expected
mult_mock_calls = Ersatz.get_mock_calls(&MyApp.CalcMock.mult/2) # get the calls our mock implementation received
assert mult_mock_calls == [[4, 1]] # assert the call args are the ones we expected
end
In practice, you will have to pass the mock to the system under the test. If the system under test relies on application configuration, you should also set it before the tests starts to keep the async property. Usually in your config files:
config :my_app, :calculator, MyApp.CalcMock
Or in your test_helper.exs
:
Application.put_env(:my_app, :calculator, MyApp.CalcMock)
All expectations are defined based on the current process. This means multiple tests using the same mock can still run concurrently unless the Ersatz is set to global mode. See the "Multi-process collaboration" section.
Multiple behaviours
Ersatz supports defining mocks for multiple behaviours.
Suppose your library also defines a scientific calculator behaviour:
defmodule MyApp.ScientificCalculator do
@callback exponent(integer(), integer()) :: integer()
end
You can mock both the calculator and scientific calculator behaviour:
Ersatz.defmock(MyApp.SciCalcMock, for: [MyApp.Calculator, MyApp.ScientificCalculator])
Compile-time requirements
If the mock needs to be available during the project compilation, for
instance because you get undefined function warnings, then instead of
defining the mock in your test_helper.exs
, you should instead define
it under test/support/mocks.ex
:
Ersatz.defmock(MyApp.CalcMock, for: MyApp.Calculator)
Then you need to make sure that files in test/support
get compiled
with the rest of the project. Edit your mix.exs
file to add the
test/support
directory to compilation paths:
def project do
[
...
elixirc_paths: elixirc_paths(Mix.env),
...
]
end
defp elixirc_paths(:test), do: ["test/support", "lib"]
defp elixirc_paths(_), do: ["lib"]
Multi-process collaboration
Ersatz supports multi-process collaboration via two mechanisms:
- explicit allowances
- global mode
The allowance mechanism can still run tests concurrently while the global one doesn't. We explore both next.
Explicit allowances
An allowance permits a child process to use the expectations and stubs defined in the parent process while still being safe for async tests.
test "invokes add and mult from a task" do
Ersatz.set_mock_implementation(&MyApp.CalcMock.add/2, fn x, y -> x + y end)
Ersatz.set_mock_return_value(&MyApp.CalcMock.mult/2, 42)
parent_pid = self()
Task.async(fn ->
MyApp.CalcMock |> allow(parent_pid, self())
assert MyApp.CalcMock.add(2, 3) == 5
assert MyApp.CalcMock.mult(2, 3) == 6
end)
|> Task.await
end
Note: if you're running on Elixir 1.8.0 or greater and your concurrency comes
from a Task
then you don't need to add explicit allowances. Instead
$callers
is used to determine the process that actually defined the
expectations.
Global mode
Ersatz supports global mode, where any process can consume mocks and stubs defined in your tests. To manually switch to global mode use:
set_ersatz_global()
test "invokes add and mult from a task" do
Ersatz.set_mock_implementation(&MyApp.CalcMock.add/2, fn x, y -> x + y end)
Ersatz.set_mock_return_value(&MyApp.CalcMock.mult/2, 42)
Task.async(fn ->
assert MyApp.CalcMock.add(2, 3) == 5
assert MyApp.CalcMock.mult(2, 3) == 42
end)
|> Task.await
end
The global mode must always be explicitly set per test. By default
mocks run on private
mode.
You can also automatically choose global or private mode depending on
if your tests run in async mode or not. In such case Ersatz will use
private mode when async: true
, global mode otherwise:
setup :set_ersatz_from_context
Link to this section Summary
Functions
Allows other processes to share expectations and stubs defined by owner process.
Resets the calls to that mock function. Useful in case of permanent mock implementations shared between multiple tests (using a setup block for example)
Defines a mock with the given name :for
the given behaviour(s).
Get the calls arguments that were used to call the mock function.
Chooses the Ersatz mode based on context. When async: true
is used
the mode is :private
, otherwise :global
is chosen.
Sets the Ersatz to global mode, where mocks can be consumed by any process.
Sets the Ersatz to private mode, where mocks can be set and consumed by the same process unless other processes are explicitly allowed.
Specify the function to be used as mock. It can be a limited in use mock implementation or a permanent mock that does not wear down (defaults to permanent mock).
Specify the return value to be used as response of the function that is going to be replaced. It can be a limited in use or a permanent response that does not wear down (defaults to permanent response).
Link to this section Functions
allow(mock_module, owner_pid, allowed_via) View Source
Allows other processes to share expectations and stubs defined by owner process.
Examples
To allow child_pid
to call any stubs or expectations defined for mock module CalcMock
:
Ersatz.allow(CalcMock, self(), child_pid)
allow/3
also accepts named process or via references:
Ersatz.allow(CalcMock, self(), SomeChildProcess)
clear_mock_calls(mocked_function) View Source
Resets the calls to that mock function. Useful in case of permanent mock implementations shared between multiple tests (using a setup block for example)
Example
Ersatz.clear_mock_calls(&MockCalc.add/2)
defmock(name, options) View Source
Defines a mock with the given name :for
the given behaviour(s).
Ersatz.defmock(MyMock, for: MyBehaviour)
With multiple behaviours:
Ersatz.defmock(MyMock, for: [MyBehaviour, MyOtherBehaviour])
Skipping optional callbacks
By default, functions are created for all callbacks, including all optional
callbacks. But if for some reason you want to skip optional callbacks, you can
provide the list of callback names to skip (along with their arities) as
:skip_optional_callbacks
:
Ersatz.defmock(MyMock, for: MyBehaviour, skip_optional_callbacks: [on_success: 2])
This will define a new mock (MyMock
) that has a defined function for each
callback on MyBehaviour
except for on_success/2
. Note: you can only skip
optional callbacks, not required callbacks.
You can also pass true
to skip all optional callbacks, or false
to keep
the default of generating functions for all optional callbacks.
get_mock_calls(mocked_function) View Source
Get the calls arguments that were used to call the mock function.
Example
# For a mock implementation that was called twice once with 2, 2 and the second time with 3, 4
Ersatz.get_mock_calls(&MockCalc.add/2) # [[2, 2], [3, 4]]
set_ersatz_from_context(context) View Source
Chooses the Ersatz mode based on context. When async: true
is used
the mode is :private
, otherwise :global
is chosen.
setup :set_ersatz_from_context
set_ersatz_global(context \\ %{}) View Source
Sets the Ersatz to global mode, where mocks can be consumed by any process.
setup :set_ersatz_global
set_ersatz_private(context \\ %{}) View Source
Sets the Ersatz to private mode, where mocks can be set and consumed by the same process unless other processes are explicitly allowed.
setup :set_ersatz_private
set_mock_implementation(function_to_mock, mock_function, options \\ []) View Source
Specify the function to be used as mock. It can be a limited in use mock implementation or a permanent mock that does not wear down (defaults to permanent mock).
Note that only one permanent mock implementation is possible at the same time but multiple time limited implementation are possible (used in the same order they were added). If a permanent and (multiple) temporary mock(s) implementations are defined, the temporary mock implementations are used before the permanent one.
If no mock implementation is available and the mock is nevertheless called, an error is raised.
Options
times:
to specify the number of usage that are allowed for that mock implementation. If it is an integer the mock implementation will be limited to that number of usages. If it is set totimes: :permanent
the mock implementation will be ok for an unlimited number of uses.
Example
# For a permanent mock implementation
Ersatz.set_mock_implementation(&MockCalc.add/2, fn x, y -> x + y)
Ersatz.set_mock_implementation(&MockCalc.add/2, fn x, y -> x + y, times: :permanent)
# For a mock implementation limited to 2 usages
Ersatz.set_mock_implementation(&MockCalc.add/2, fn x, y -> x + y, times: 2)
set_mock_return_value(function_to_mock, return_value, options \\ []) View Source
Specify the return value to be used as response of the function that is going to be replaced. It can be a limited in use or a permanent response that does not wear down (defaults to permanent response).
It is a simpler way to give a mock implementation to your mock modules compared to the set_mock_implementation/3
function.
Note that only one permanent response or mock function implementation is possible at the same time but multiple time limited implementation are possible (used in the same order they were added). If a permanent and (multiple) temporary mock(s) implementations are defined, the temporary mock implementations are used before the permanent one.
If no mock implementation is available and the mock is nevertheless called, an error is raised.
set_mock_return_value/3
and set_mock_implementation/3
react on the same way. set_mock_return_value/3
is a type
of mock implementation. So expect the same behaviour for permanent mock implementation (only one at the same time), or
for order of the temporary mock implementations.
Options
times:
to specify the number of usage that are allowed for that mock implementation. If it is an integer the mock implementation will be limited to that number of usages. If it is set totimes: :permanent
the mock implementation will be ok for an unlimited number of uses.
Example
# For a permanent mock implementation defined by a return value
Ersatz.set_mock_return_value(&MockCalc.add/2, 4)
Ersatz.set_mock_return_value(&MockCalc.add/2, 4, times: :permanent)
# For a mock implementation defined by a return value limited to 2 usages
Ersatz.set_mock_return_value(&MockCalc.add/2, 4, times: 2)