Elixir mock (test double) testing seams: a modest proposal
The José Valim approved (tm) way of introducing mocks1 into Elixir is through injecting implementations of explicit contracts defined by behaviours. José and pals crystallised this approach with the popular Mox hexicle.
The standard way of injecting the mock or real implementation into the code under test is by passing modules around by some method. The implementation module is typically loaded from application config which can be tailored to the mix environment. I find this approach somewhat dissatisfying as the module being passed around is just an atom containing no metadata.
Apart from a general lack of tidiness, this provides a route for errors to slip through code which passes all the test. I do have a suggestion which would help. It will involve code that looks like this
@implementation if Mix.env() == :test, do: MockCatFactsApi, else: RealCatFactsApi
defmacro __using__(_) do
quote do
alias unquote(@implementation), as: CatFactsApi
end
end
You may not be attracted by that, but please bear with me. You may, at least, learn something surprising about feline collar bones. (I don’t believe the thing about Newton, though.)
The usual approach
Let’s get some cat facts using the standard method of implementation injection. The full implementation is here.
We’ll define a behaviour as a testing seam
defmodule CatFacts.CatFactsApi do
@callback get_facts(path :: String.t(), finch_pool :: atom) ::
{:ok, Finch.Response.t()} | {:error, Exception.t()}
end
And we’re going to drive out our behaviour with tests.
defmodule CatFactsTest do
use ExUnit.Case
import Mox
setup :verify_on_exit!
test "Can get a fact" do
expect(MockCatFactsApi, :get_facts, fn "fact", CatFinch ->
{:ok,
%Finch.Response{
body: "{\"fact\":\"Cats are really dogs in disguise.\",\"length\":33}",
status: 200
}}
end)
assert {:ok, "Cats are really dogs in disguise."} == CatFacts.fact()
end
# After this we will want to test out verious error and edge conditions but
# we'll leave those out of here for brevity
end
Inject mock and real implementations, with the private function get_cat_facts_api/0
.
defmodule CatFacts do
def fact do
"fact"
|> cat_facts_api().get_facts(CatFinch)
|> handle_response()
end
defp cat_facts_api do
Application.get_env(:cat_facts, CatFacts.CatFactsApi, CatFacts.RealCatFactsApi)
end
# handle_response/2 ommited for brevity
end
If we define the mock
(say in “test/support/mocks.ex”) and configure it for test then our tests will run.
Mox.defmock(MockCatFactsApi, for: CatFacts.CatFactsApi)
# config/config.exs
import Config
import_config "#{config_env()}.exs"
# config/test.exs
import Config
config :cat_facts, CatFacts.CatFactsApi, MockCatFactsApi
cat_facts (main) $ mix test
....
Finished in 0.02 seconds (0.00s async, 0.02s sync)
4 tests, 0 failures
Randomized with seed 566481
Nearly there. We also need an actual implementation.
defmodule CatFacts.RealCatFactsApi do
@cat_facts_base "https://catfact.ninja"
def get_facts(path, finch_pool) do
url = Path.join(@cat_facts_base, path)
:get
|> Finch.build(url)
|> Finch.request(finch_pool)
end
end
Let’s get a cat fact!
Erlang/OTP 25 [erts-13.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit]
Interactive Elixir (1.14.3) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> CatFacts.fact()
{:ok,
"The cat's clavicle, or collarbone, does not connect with other bones but is buried in the muscles of the shoulder region. This lack of a functioning collarbone allows them to fit through any opening the size of their head."}
I did not know that about cat’s clavicles.
So, this is all great, but your company’s Star Chamber of Staff Engineers have just decreed a new coding standard: api functions must be alliterative. As the annual performance review is looming, we rush to rename.
The code change
defmodule CatFacts.CatFactsApi do
@callback fetch_fun_feline_facts(path :: String.t(), finch_pool :: atom) ::
{:ok, Finch.Response.t()} | {:error, Exception.t()}
end
defmodule CatFactsTest do
use ExUnit.Case
import Mox
setup :verify_on_exit!
test "Can get a fact" do
expect(MockCatFactsApi, :fetch_fun_feline_facts, fn "fact", CatFinch ->
{:ok,
%Finch.Response{
body: "{\"fact\":\"Cats are really dogs in disguise.\",\"length\":33}",
status: 200
}}
end)
assert {:ok, "Cats are really dogs in disguise."} == CatFacts.fact()
end
# still taking the other tests as read
end
defmodule CatFacts do
# ...
def fact do
"fact"
|> cat_facts_api().fetch_fun_feline_facts(CatFinch)
|> handle_response()
end
# etc...
end
cat_facts (main) $ mix test
Compiling 2 files (.ex)
....
Finished in 0.01 seconds (0.00s async, 0.01s sync)
4 tests, 0 failures
Phew! We’re done. Except, oh no! There’s an error in production.
cat_facts (main) $ iex -S mix
Erlang/OTP 25 [erts-13.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit]
Compiling 4 files (.ex)
Generated cat_facts app
Interactive Elixir (1.14.3) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> CatFacts.fact()
** (UndefinedFunctionError) function CatFacts.RealCatFactsApi.fetch_fun_feline_facts/2 is undefined or private
(cat_facts 0.1.0) CatFacts.RealCatFactsApi.fetch_fun_feline_facts("fact", CatFinch)
(cat_facts 0.1.0) lib/cat_facts.ex:9: CatFacts.fact/0
iex:1: (file)
Obviously you, perceptive reader, have already spotted both errors:
- We (ok I) forgot to add
@behaviour CatFacts.CatFactsApi
- And we did not rename
CatFacts.get_facts/1
You spotted it but the compiler did not; there were no warnings. Dialyzer can not help you either. The most you could say about CatFacts.cat_facts_api/1
is that it returns an atom; that is returns an atom representing a module that implements a specific behaviour is an unsayable concept.
# not very helpful
@spec cat_facts_api :: atom()
You may be thinking that this is a contrived example: this is not the kind of error that would be written and get past a code review.
Ok, Blackhat, my experience is a little different. I definitely found many examples of @behaviour
being missed in a particular company’s codebase.
This kind of error can happen. It would be lovely if we could at least get some kind of compiler warning when renaming (or function arity changing) goes wrong. Read on to find out how we can.
The alternative approach
defmodule CatFacts.CatFactsApi do
@callback fetch_fun_feline_facts(path :: String.t(), finch_pool :: atom) ::
{:ok, Finch.Response.t()} | {:error, Exception.t()}
defmacro alias do
implementation = Application.get_env(:cat_facts, __MODULE__, CatFacts.RealCatFactsApi)
quote do
alias unquote(implementation), as: CatFactsApi
end
end
end
defmodule CatFacts do
require CatFacts.CatFactsApi
CatFacts.CatFactsApi.alias()
def fact do
"fact"
|> CatFactsApi.fetch_fun_feline_facts(CatFinch)
|> handle_response()
end
# etc...
end
The tests still run.
cat_facts (main) $ mix test
Compiling 3 files (.ex)
....
Finished in 0.01 seconds (0.00s async, 0.01s sync)
4 tests, 0 failures
But if we compile for dev
or prod
we get a warning. This should be a welcome safety net, especially if your build server is configured to treat warnings as errors.
cat_facts (main) $ mix compile
Compiling 2 files (.ex)
warning: CatFacts.RealCatFactsApi.fetch_fun_feline_facts/2 is undefined or private
lib/cat_facts.ex:13: CatFacts.fact/0
Incidentally, dialyzer is now similarly unimpressed
lib/cat_facts.ex:12:call_to_missing
Call to missing or private function CatFacts.RealCatFactsApi.fetch_fun_feline_facts/2.
________________________________________________________________________________
done (warnings were emitted)
Halting VM with exit status 2
There’s still some room for improvement.
- Having to
require CatFacts.CatFactsApi
before calling thealias/0
macro is a bit awkward. My preference is to sidestep this a bit withuse
. - We’re using
Application.get_env/3
at compile time but we can’t useApplication.compile_env/3
inside a macro. We could use this with an attribute@impl Application.compile_env(:cat_facts, __MODULE__, CatFacts.RealCatFactsApi)
but … - We are always using one implementation in the
test
environment and another elsewhere. I do not consider that configuration. My preference is to explicitly state the implementations in the code rather than having to look in another file (“config/test.exs”).
defmodule CatFacts.CatFactsApi do
@callback fetch_fun_feline_facts(path :: String.t(), finch_pool :: atom) ::
{:ok, Finch.Response.t()} | {:error, Exception.t()}
@implementation if Mix.env() == :test, do: MockCatFactsApi, else: CatFacts.RealCatFactsApi
defmacro __using__(_) do
quote do
alias unquote(@implementation), as: CatFactsApi
end
end
end
defmodule CatFacts do
use CatFacts.CatFactsApi
# etc ..
end
Drawbacks
There are two potential drawbacks to the approach I am suggesting here:-
Reduction in flexibility
This alternative approach needs the implementation switching to be happen at compile time. The standard approach allows for the implementation to be determined at runtime. This matters little when injecting mocks. It is possible you may want to use the pattern for other purposes, such as switching out an actual implementation in production controlled by a feature flag; in this case I would agree that something along the lines of passing modules around is still a reasonable approach.
Reviewers
Asynchronous code reviews via pull requests have become ubiquitous over the last ten or so years. Combined with FAANG-style performance reviews, one of the side effects is presure on developers to review quickly while being able to display their own knowledge and competence2.
Finding a macro in the code can be like catnip3 to a reviewing developer under those pressures. Without much thought they can block and say something to the effect of “This is over-engineering. You can replace these 3 simple lines of code with these other lines of code.” If they are feeling particularly pompous they might just quote from the macro or library guidelines.
If I have managed to persuade you to give this method of implementation-injection a shot, your ability to to actually do so may be limited by the social and power dynamics in your organisation.
Advantages
Better mistake cover from compiler or dialyzer warnings
I hope that I have already established this advantage. I should probably point out that the alternative approach will not let you know about forgotten @behaviour
directives, but it will protect you from the consequences.
Less bloated configuration
Large projects that make heavy use of Mox
often end up massive “text.exs” files which are a headache to organise and maintain.
If something can be known at compile time and never changes between different physical environment (ie different developer’s laptops, build servers, deployments) then I do not think that is configuration. If you can inline that to where it is used then you have made things simpler and more explicit.
It might look odd to you (and unfortunately your reviewers) at first. That is because you (and they) are not used to it.
More cat facts
While were here, and having fixed the implementation …
iex(7)> CatFacts.fact()
{:ok,
"When a cat drinks, its tongue - which has tiny barbs on it - scoops the liquid up backwards."}
iex(8)> CatFacts.fact()
Wait? What does scooping up liquid backwards even mean? Let’s try another one.
iex(16)> CatFacts.fact()
{:ok,
"Isaac Newton invented the cat flap. Newton was experimenting in a pitch-black room. Spithead, one of his cats, kept opening the door and wrecking his experiment. The cat flap kept both Newton and Spithead happy."}
Citation needed and I doubt it, though I do feel better having read that.
Even if true, it still would not not be my favourite cat fact. My favourite is one that I read on an information board at The Highland Wildlife Park: the decline in Scottish wildcat numbers was reduced during the First World War because conscription reduced the gamekeeper population.
PS Just saw another cat fact in the news as I was writing this: approval for releasing Scottish wildcats being into The Cairngorms has been granted from The Highland Wildlife Park.
-
There’s some awkward terminology around all this which is probably not important. I should really say test double but I’ve always found that an awkward phrase. See XUnit patters for definitions. It is common in Elixir Land (as other places) to use Mock for Test Doubles so I will just stick with that here; being more correct would also be more confusing. ↩
-
It’s a hard trap to avoid. I’ve been meaning to write something about reviewing more effectively but I doubt I could better Dan Munckton or Chelsea Troy. ↩
-
Cat fact. ↩