Checking Efx - testable effects for Elixir
by Paweł Świątkowski
02 Jul 2024
Mocking in Elixir is always a hot topic, mainly because of people coming from different other technologies having different expectation. The most “cannonical” solution, Mox, gives a lot of security with regard to what the fake implementation returns, but requires a bit of a ceremony from the programmer. Other solutions like Mock, Rewire or Bypass offer more ad-hoc mocking, but t the price of the tests heving to be synchronous. Then there’s Mimic, which offers ad-hoc mocking and supports async tests.
In this busy area, a new library appeared very recently. It’s called Efx and its description does not even mention mocking:
A library to declaratively write testable effects
It immediately cought my attention, because it seems to implement what I described as “purity injection” some time ago. As a programmer, you write a default impure implementation of the code, but you mark it as an “effect”. Then in tests you replace a stateful implementation with something more predictable (you “inject purity”) and you can enjoy nice, stable and fast tests.
This post is a result of taking the library on a quick testing round. I decided to use it to the exact same problem as in the “purity injection” post. To remind, when you create a new order in your e-commerce application, you need to assign it a number. The number needs to be unique within a tenant and it should not contain ambiguous characters. The naive implementation, with which we started, looked like this:
defmodule OrderNumber do
def generate(tenant_id) do
candidate =
:crypto.strong_rand_bytes(4)
|> Base.encode32(padding: false)
|> replace_ambiguous_characters()
query =
from o in Order,
where: o.tenant_id == ^tenant_id and o.order_id == ^candidate
if Repo.exists?(query), do: generate(tenant_id), else: candidate
end
end
Now, let’s change this code to support effects in Efx’s terminology. We will have two: one for generating a random string and another to check if the sequence has already been used.
defmodule Commerce.OrderNumber do
use Efx
def generate(tenant_id) do
candidate =
generate_random()
|> replace_ambiguous_characters()
case check_existence(candidate, tenant_id) do
true -> generate(tenant_id)
false -> candidate
end
end
@spec generate_random :: String.t()
defeffect generate_random() do
:crypto.strong_rand_bytes(4)
|> Base.encode32(padding: false)
end
@spec check_existence(String.t(), integer()) :: boolean()
defeffect check_existence(candidate, tenant_id) do
import Ecto.Query
from(o in Commerce.Order,
where: o.tenant_id == ^tenant_id and o.number == ^candidate)
|> Commerce.Repo.exists?()
end
defp replace_ambiguous_characters(string) do
string
|> String.replace("I", "1")
|> String.replace("O", "0")
end
end
Both generate_random/0
and check_existence/2
effects are compiled to a regular functions, which are called inside
generate/1
. There’s nothing too fancy in it, so let’s see about testing it. In the first test we want to check if the
ambiguous characters are replace, so we want our RNG to generate a sequence with such characters:
defmodule Commerce.OrderNumberTest do
use Commerce.DataCase, async: true
use EfxCase
alias Commerce.OrderNumber
test "replace Os and Is" do
bind(OrderNumber, :generate_random, fn -> "A5O0I1" end)
bind(OrderNumber, :check_existence, fn _, _ -> false end)
assert OrderNumber.generate(1) == "A50011"
end
end
The bind/3
function replaces the implementation of effects from a default one to a fake one. Now our generate_random/0
function returns A5O0I1
strings, which then replaces the ambiguous characters. We also need to bind
the implementation of
check_existence
here, even though we actually know that the database is empty so the check would pass. There are two
reasons to do so:
- We want to do it - isolate all side-effects in tests, so tests are faster and less reliant on the infrastructure
- We have to do it - because Efx forces us to
If we don’t bind
the check_existence
, we would get an error:
** (RuntimeError) Mock for function check_existence/2 missing
(efx 0.1.11) lib/efx_case/mock.ex:95: EfxCase.Mock.get_fun/3
(efx 0.1.11) lib/efx_case/mock_state.ex:66: anonymous fn/5 in EfxCase.MockState.call/4
(elixir 1.16.0) lib/agent/server.ex:16: Agent.Server.handle_call/3
(stdlib 5.2) gen_server.erl:1131: :gen_server.try_handle_call/4
(stdlib 5.2) gen_server.erl:1160: :gen_server.handle_msg/6
(stdlib 5.2) proc_lib.erl:241: :proc_lib.init_p_do_apply/3
Last message (from #PID<0.405.0>): {:get_and_update, #Function<1.67167648/1 in EfxCase.MockState.call/4>}
After this is done, we can test more elaborate cases:
test "use first candidate when existence check is false" do
bind(OrderNumber, :generate_random, fn -> "ABC" end)
bind(OrderNumber, :generate_random, fn -> "123" end)
bind(OrderNumber, :check_existence, fn _, _ -> false end)
assert OrderNumber.generate(1) == "ABC"
end
test "use second candidate when first existence check is true" do
bind(OrderNumber, :generate_random, fn -> "ABC" end, calls: 1)
bind(OrderNumber, :check_existence, fn _, _ -> true end, calls: 1)
bind(OrderNumber, :generate_random, fn -> "123" end)
bind(OrderNumber, :check_existence, fn _, _ -> false end)
assert OrderNumber.generate(1) == "123"
end
Here we bind
the effects few times. The second test shows calls: 1
option passed, which makes sure that bind
only binds
once. After that, the next bound implementation is used.
Finally, just to be sure, we may want to write a test actually calling the database:
test "check against actual database" do
bind(OrderNumber, :generate_random, fn -> "ABC" end, calls: 1)
bind(OrderNumber, :check_existence, {:default, 2})
bind(OrderNumber, :generate_random, fn -> "123" end)
%Commerce.Order{tenant_id: 1, number: "ABC"}
|> Commerce.Repo.insert!()
assert OrderNumber.generate(1) == "123"
end
Binding with {:default, arity}
lets us call the original implementation. This has to be done fully intentionally,
not cutting corners.
We have succesfully tested our order number generator in a much cleaner way, compared to my original post, without passing dependencies as an argument. Overall I think this is a very promising approach that, aside for improving using fake implementation in tests, nudges the developer in a nice way to:
- Think about side-effects when writing the code - you need to wrap them in
defeffect
- Keep the number of impure functions in the module as low as possible - you have to always bind them all
- Use
@spec
s -defeffect
requires them; although there are no type checks whether a fake implementation adheres to the spec, at least having to define it might help to visually identify problems.
I think Efx is a great addition to Elixir ecosystem and I salute the author. Nice job!