Protocol-based mocks and explicit contracts in Elixir.
See also Mox for Behaviour-based mocks.
Add promox
to your list of dependencies in mix.exs
:
def deps do
[
{:promox, "~> 0.1.0", only: :test}
]
end
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
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(fallback_chain, "path")
end
end
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
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.