Ersatz v0.1.0 Ersatz View Source

Ersatz is a library for defining concurrent mocks in Elixir.

The library follows the principles outlined in "Mocks and explicit contracts", summarized below:

  1. No ad-hoc mocks. You can only create mocks based on behaviours

  2. No dynamic generation of modules during tests. Mocks are preferably defined in your test_helper.exs or in a setup_all block and not per test

  3. Concurrency support. Tests using the same mock can still use async: true

  4. Rely on pattern matching and function clauses for asserting on the input instead of complex expectation rules

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 expectations and verify them:

use ExUnit.Case, async: true

import Ersatz

# Make sure mocks are verified when the test exits
setup :verify_on_exit!

test "invokes add and mult" do
  MyApp.CalcMock
  |> expect(:add, fn x, y -> x + y end)
  |> expect(:mult, fn x, y -> x * y end)

  assert MyApp.CalcMock.add(2, 3) == 5
  assert MyApp.CalcMock.mult(2, 3) == 6
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:

  1. explicit allowances
  2. 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
  MyApp.CalcMock
  |> expect(:add, fn x, y -> x + y end)
  |> expect(:mult, fn x, y -> x * y end)

  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()

which can be done as a setup callback:

setup :set_ersatz_global
setup :verify_on_exit!

test "invokes add and mult from a task" do
  MyApp.CalcMock
  |> expect(:add, fn x, y -> x + y end)
  |> expect(:mult, fn x, y -> x * y end)

  Task.async(fn ->
    assert MyApp.CalcMock.add(2, 3) == 5
    assert MyApp.CalcMock.mult(2, 3) == 6
  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

Defines a mock with the given name :for the given behaviour(s).

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.

TODO: write doc for set_mock_implementation/3

Link to this section Functions

Link to this function

clear_mock_calls(mocked_function) 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.

Link to this function

get_mock_calls(mocked_function) View Source

Link to this function

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
Link to this function

set_ersatz_global(context \\ %{}) View Source

Sets the Ersatz to global mode, where mocks can be consumed by any process.

setup :set_ersatz_global
Link to this function

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
Link to this function

set_mock_implementation(function_to_mock, mock_function, options \\ []) View Source

TODO: write doc for set_mock_implementation/3