Promox

Protocol-based mocks and explicit contracts in Elixir.

See also Mox for Behaviour-based mocks.

Installation

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

def deps do
  [
    {:promox, "~> 0.1.0", only: :test}
  ]
end

Examples

Let's say we have a Storable protocol:

defprotocol MyApp.Storable do
  @spec upload(t(), String.t(), any()) :: :ok | {:error, any()}
  def upload(storage, path, data)

  @spec download(t(), String.t()) :: {:ok, any()} | {:error, any()}
  def download(storage, path)
end

Then we define the mock for Storable in test_helper.exs:

require Promox

Promox.defmock(for: MyApp.Storable)

(Notice that Promox.defmock is a macro, so we need to require Promox first.)

Now in our tests, we can initialize mocks and define expectations on them:

defmodule MyApp.Storable.WithRetryTest do
  use ExUnit.Case, async: true

  alias MyApp.Storable

  test "retries upload `n` times" do
    storable =
      Promox.new()
      |> Promox.expect(Storable, :upload, 2, fn _, "path", "data" -> {:error, :test_retry} end)
      |> Promox.expect(Storable, :upload, fn _, "path", "data" -> :ok end)

    storable_with_retry = Storable.WithRetry.new(storable, max_attempt: 3)

    assert :ok = Storable.upload(storable_with_retry, "path", "data")
    Promox.verify!(storable)
  end
end

Multiple mocks

Since a Promox mock is just a piece of data, you can initialize multiple mocks and define different expectations on them:

defmodule MyApp.Storable.FallbackChainTest do
  use ExUnit.Case, async: true

  alias MyApp.Storable

  test "falls=back to next storable when first storable fails" do
    error_storable =
      Promox.new()
      |> Promox.stub(Storable, :download, fn _, "path", "data" -> {:error, :test_fallback} end)

    ok_storable =
      Promox.new()
      |> Promox.stub(Storable, :download, fn _, "path", "data" -> {:ok, "result from ok_storable"} end)

    fallback_chain = Storable.FallbackChain.new([error_storable, ok_storable])

    assert {:ok, "result from ok_storable"} = Storable.download(storable_with_retry, "path")
  end
end

Multi-process collaboration

Again, since a Promox mock is just a piece of data, you can pass a mock to another process without managing allowances:

defmodule MyApp.Storable.AsyncTest do
  use ExUnit.Case, async: true

  alias MyApp.Storable

  test "falls=back to next storable when first storable fails" do
    test = self()

    mock_storable =
      Promox.new()
      |> Promox.expect(Storable, :upload, fn _, "path", "data" ->
        send(test, :mock_gets_called)

        :ok
      end)

    async_storable = Storable.Async.new(mock_storable)

    assert :ok = Storable.upload(async_storable, "path", "data")
    assert_receive(:mock_gets_called)
    Promox.verify!(mock_storable)
  end
end

Why would you need Promox when Mox exists?

Mox simplifies mocking Behaviour callbacks;
Promox simplifies mocking Protocol callbacks.
Protocols and Behaviours are both ways to achieve polymorphism in Elixir.
You should pick Protocols or Behaviours depending on the problem in your hand.
When you pick Protocols, you may need Promox to create mocks dynamically in your tests.