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:

  1. We want to do it - isolate all side-effects in tests, so tests are faster and less reliant on the infrastructure
  2. 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 @specs - 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!

end of the article

Tags: elixir

This article was written by me – Paweł Świątkowski – on 02 Jul 2024. I'm on Fediverse (Ruby-flavoured account, Elixir-flavoured account). Let's talk.