View Source ProtoMock (ProtoMock v1.0.0)

ProtoMock is a library for mocking Elixir protocols.

Motivation / use case

ProtoMock was built to support using protocols, rather than behaviours or plain modules, for modeling and accessing external APIs. When external APIs are modeled with protocols, ProtoMock can provide mocking capabilities.

Modeling external APIs with protocols provides these benefits:

  • API transparency
  • IDE navigability
  • Compiler / dialyzer error detection
  • Flexible options for mocking (for example using custom fake objects for some tests, instead of a mocking library)

It is not expected that ProtoMock would be useful for more traditional protocol use cases, wherein protocols such as Enumerable provide a common interface for operating on disparate data structures. In such situations, there is no value in testing with mocks, therefore ProtoMock has no role.

Getting started

Add protomock to your list of dependencies in mix.exs:

    def deps do
      [
        # ...
        {:protomock, "~> 1.0.0", only: :test}
      ]
    end

Because ProtoMock generates implementations of the protocols that it mocks, we need to disable protocol consolidation for the :test environment in mix.exs:

    def project do
      [
        # ...
        consolidate_protocols: Mix.env() != :test
      ]
    end

Example

Following the traditional Mox example, imagine that we have an app that displays the weather. To retrieve weather data, we use an external weather API called AcmeWeather, and we model the API with our own protocol:

    defprotocol MyApp.WeatherAPI do
      @type lat_long :: {float(), float()}
      @type api_result :: {:ok, float()} | {:error, String.t()}

      @spec temperature(t(), lat_long()) :: api_result()
      def temperature(weather_api, lat_long)

      @spec humidity(t(), lat_long()) :: api_result()
      def humidity(weather_api, lat_long)
    end

We create a "real" implementation of WeatherAPI that calls out to the AcmeWeather API client:

    defimpl MyApp.WeatherAPI, for: AcmeWeather.ApiConfig do
      def temperature(api_config, {lat, long}) do
        AcmeWeather.Client.get_temperature(lat, long, api_config)
      end

      def humidity(api_config, {lat, long}) do
        AcmeWeather.Client.get_humidity(lat, long, api_config)
      end
    end

For testing, however, we want to mock the service.

Continuing with the Mox example, imagine that our application code looks like:

    defmodule MyApp.HumanizedWeather do
      alias MyApp.WeatherAPI

      def display_temp({lat, long}, weather_api) do
        {:ok, temp} = WeatherAPI.temperature(weather_api, {lat, long})
        "Current temperature is #{temp} degrees"
      end

      def display_humidity({lat, long}, weather_api) do
        {:ok, humidity} = WeatherAPI.humidity(weather_api, {lat, long})
        "Current humidity is #{humidity}%"
      end
    end

We can test HumanizedWeather by mocking WeatherAPI with ProtoMock:

    defmodule MyApp.HumanizedWeatherTest do
      use ExUnit.Case, async: true

      alias MyApp.HumanizedWeather
      alias MyApp.WeatherAPI

      test "gets and formats temperature" do
        protomock =
          ProtoMock.new(WeatherAPI)
          |> ProtoMock.expect(&WeatherAPI.temperature/2, 1, fn _lat_long -> {:ok, 30} end)

        assert HumanizedWeather.display_temp({50.06, 19.94}, protomock) ==
                "Current temperature is 30 degrees"

        ProtoMock.verify!(protomock)
      end

      test "gets and formats humidity" do
        protomock =
          ProtoMock.new(WeatherAPI)
          |> ProtoMock.stub(&WeatherAPI.humidity/2, fn _lat_long -> {:ok, 60} end)

        assert HumanizedWeather.display_humidity({50.06, 19.94}, protomock) ==
              "Current humidity is 60%"
      end
    end

In the first test, we use expect/4 to declare that WeatherAPI.temperature/2 should be called exactly once. The expectation is verified via verify!/1.

In the second test, we use stub/3, which does not set expectations on the number of times the mocked function should be called, therefore we do not need to verify.

Under the hood: a GenServer

The ProtoMock module is a GenServer. Each time we create a ProtoMock with new/1, we start a new ProtoMock GenServer that is linked to the calling process - typically an ExUnit test process. When the test pid dies, the ProtoMock GenServer dies with it.

expect/4 and stub/3 modify the ProtoMock GenServer state to tell the ProtoMock how it will be used and how it should respond. As the ProtoMock instance is used to dispatch functions of a mocked protocol, it records each function invocation. verify!/1 compares the function invocations to the expectations defined via expect/4, and raises in case of an expectations mismatch.

Comparison to Mox

In order to feel familiar to developers, the ProtoMock API was modeled after the Mox API.

Some differences worth noting:

  • ProtoMock has no concept of private mode or global mode. It's expected that each ExUnit test will create its own instance or instances of ProtoMock that are implicitly private to the test pid, thereby always being safe for async: true
  • Similarly, ProtoMock has no concept of allowances. Each ProtoMock instance is just a GenServer that can be used freely and without worry by any process spawned by an ExUnit test process (provided that the child process does not interact with other tests).
  • Rather than specificying expectations and stubs with a module name and a function name, e.g. (MyAPIModule, :my_api_function ...), ProtoMock uses function captures, e.g. &MyApiProtocol.my_api_function/2. As a benefit, API mismatches between actual code and expectations/stubs will be flagged by the compiler.
  • stub_with and verify_on_exit are not meaningful when using ProtoMock, and they are not implemented.

Goals and philosophy

ProtoMock supports the idea that each test should be its own little parallel universe, without any modifiable state shared between tests. It avoids practices common in mocking libraries such as setting/resetting Application environment variables. Such practices create potential collisions between tests that must be avoided with async: false. ProtoMock believes async should always be true!

ProtoMock aims to provide an easy-on-the-eyes, function-oriented API that doesn't rely on macros and doesn't require wrapping test code in closures.

In alignment with José Valim's "mocks as nouns" suggestion, ProtoMock takes an approach to mocking that is centered on data structures. Good things happen when we customize behavior by providing custom data structures at the point of usage, without resorting to global variables such as the Application environment.

We improve our code by creating pathways to get our data structures where we need them. The same pathways used for tests can also be used to customize production behavior. We can use the pathways to provide test fakes instead of mocks created by a mocking library, which is a much better way to mock in many situations.

Protocol implementations are one form of such data structures. Libraries such as Tesla and Req provide built-in mocking capabilities via application-specific data structures - Tesla through custom adapters and Req through fake request adapters. When libraries provide such hooks, we might decide we wouldn't benefit from using a protocol to model/mock a given external service. What's important is that we mock using data structures (rather than modules or fancy runtime tricks), and use them locally (not globally).

ProtoMock is often spotted muttering "mock locally, with nouns" over and over again.

Summary

Functions

Expects mocked_function to be dispatched to protomock invocation_count times.

Creates a new instance of ProtoMock that mocks the given protocol.

Allows mocked_function to be dispatched to protomock and proxied to impl.

Verifies that all expectations have been fulfilled.

Types

@opaque t()

Functions

Link to this function

expect(protomock, mocked_function, invocation_count \\ 1, impl)

View Source
@spec expect(t(), function(), non_neg_integer(), function()) :: t()

Expects mocked_function to be dispatched to protomock invocation_count times.

When mocked_function is dispatched, the impl function will be invoked, using the arguments passed to mocked_function (except for the first arg - see next paragraph). The value returned from impl will be returned from mocked_function.

The impl function must have an arity that is one less than the arity of mocked_function. Because mocked_function is a protocol function, its first argument is the data structure that implements the protocol, which in this case is protomock. The impl function has no need for this data structure, so it is omitted from the impl argument list.

When expect/4 is invoked, any previously declared stubs for the same mocked_function will be removed. This ensures that expect will fail if the function is called more than invocation_count times. If stub/3 is invoked after expect/4 for the same mocked_function, the stub will be used after all expectations are fulfilled.

expect/4 will raise an ArgumentError if mocked_function is not a member function of the protocol mocked by protomock, as indicated via new/1.

Examples

To expect WeatherAPI.temperature/2 to be called once:

protomock =
  ProtoMock.new(WeatherAPI)
  |> ProtoMock.expect(&WeatherAPI.temperature/2, fn _lat_long -> {:ok, 30} end)

To expect WeatherAPI.temperature/2 to be called five times:

protomock =
  ProtoMock.new(WeatherAPI)
  |> ProtoMock.expect(&WeatherAPI.temperature/2, 5, fn _lat_long -> {:ok, 30} end)

To expect WeatherAPI.temperature/2 to not be called:

protomock =
  ProtoMock.new(WeatherAPI)
  |> ProtoMock.expect(&WeatherAPI.temperature/2, 0, fn _lat_long -> {:ok, 30} end)

expect/4 can be invoked multiple times for the same mocked_function, permitting different behaviors for each invocation. For example, we could test that our code will try an API call three times before giving up:

protomock =
  ProtoMock.new(WeatherAPI)
  |> ProtoMock.expect(&WeatherAPI.temperature/2, 2, fn _ -> {:error, :unreachable} end)
  |> ProtoMock.expect(&WeatherAPI.temperature/2, 1, fn _ -> {:ok, 30} end)

lat_long = {0.0, 0.0}

log = capture_log(fn ->
  humanized_temp = HumanizedWeather.display_temp(lat_long, protomock)
  assert humanized_temp == "It's currently 30 degrees"
end)

assert log =~ "attempt 1 failed"
assert log =~ "attempt 2 failed"
assert log =~ "attempt 3 succeeded"

ProtoMock.expect(protomock, &WeatherAPI.temperature/2, 3, fn _ -> {:error, :unreachable} end)

result = HumanizedWeather.display_temp(lat_long, protomock)
assert result == "Current temperature is unavailable"
@spec new(module()) :: t()

Creates a new instance of ProtoMock that mocks the given protocol.

After creating a new ProtoMock, tests can add expectations and stubs to the instance using expect/4 and stub/3. With expectations and stubs in place, the ProtoMock instance can be provided to the code under test, and used by the code under test where it expects an implementation of protocol.

If ProtoMock does not yet implement protocol, new/1 will generate an implementation.

Subsequent calls to expect/4 and stub/3 will verify that their mocked functions are member functions of protocol.

The ProtoMock module is a GenServer. new/1 starts a new instance of the GenServer that is linked to the calling process, typically an ExUnit test pid. When the test pid exits, any child ProtoMock GenServers also exit.

Link to this function

stub(protomock, mocked_function, impl)

View Source
@spec stub(t(), function(), function()) :: t()

Allows mocked_function to be dispatched to protomock and proxied to impl.

When mocked_function is dispatched, the impl function will be invoked, using the arguments passed to mocked_function (except for the first arg - see next paragraph). The value returned from impl will be returned from mocked_function.

The impl function must have an arity that is one less than the arity of mocked_function. Because mocked_function is a protocol function, its first argument is the data structure that implements the protocol, which in this case is protomock. The impl function has no need for this data structure, so it is omitted from the impl argument list.

Unlike expectations, stubs are never verified.

If expectations and stubs are defined for the same mocked_function, the stub is invoked only after all expectations are fulfilled.

stub/3 will raise an ArgumentError if mocked_function is not a member function of the protocol mocked by protomock, as indicated via new/1.

Example

To allow WeatherAPI.temperature/2 to be dispatched to a ProtoMock instance any number of times:

protomock =
  ProtoMock.new(WeatherAPI)
  |> ProtoMock.stub(&WeatherAPI.temperature/2, fn _lat_long -> {:ok, 30} end)

stub/3 will overwrite any previous calls to stub/3.

@spec verify!(t()) :: :ok

Verifies that all expectations have been fulfilled.