Unit Testing Higher Order Functions in Elixir
So one question we’ve had with regards to Elixir unit testing is “how do we test functions which take functions as arguments?” This article aims to illustrate the approach we’ve adopted that doesn’t require mocks, stubs, spies or any library other than ExUnit.
Pure vs Impure Functions?
Just a brief reminder of the difference between a pure and an impure function.
Pure functions are:
“The function always evaluates the same result value given the same argument value(s). The function result value cannot depend on any hidden information or state that may change while program execution proceeds or between different executions of the program, nor can it depend on any external input from I/O devices.” — Wikipedia
defmodule Play do
def multiply(a, b), do: a * b
def add(a, b), do: a + b
end
iex> Play.multiply(2, 2)
4
iex> Play.add(5, 6)
11
No matter what we provide to these functions it will always return the same result based on that input.
So if a pure function has the above rules therefore an impure function is everything else. A higher order function is can be indeterministic within its execution as the function argument can produce unknown results.
Let’s show some examples.
defmodule Play do
def multiply(a, b), do: a * b
def ap(a, function), do: function.(a)
endiex> Play.ap("Hello", &IO.puts/1)
Hello
:ok
iex> Play.ap([1, 2, 3, 4], &Enum.random/1)
3
Here we have IO.puts
and Enum.random
which are non-deterministic within our function Play.ap
. These are side effects and are difficult to test when using functions as parameters. Due to these side effects we should only test the logic of .ap
, not the functionality that is provided by the other functions.
So how do we test functions like Play.ap
?
Unit Testing
We will create a pipeline with multiple functions as parameters that we want to test; a simple rule runner. It will take a list of records, a list of functions to validate each record and then calls an output function on each.
defmodule Example do
def runner(records, validators, output_function) do
records
|> apply_validators(validators)
|> Enum.each(output_function)
end defp apply_validators(records, validators) do
for d <- data, v <- validators, v.(d), do: d
end
end
Now in JavaScript you would be required to import a spy library, e.g. sinon, on top of your testing framework of choice, and spy as callbacks. With native message passing in Elixir and ExUnit assertions we can build spies out of the box.
Let’s create our tests.
defmodule ExampleTest do
use ExUnit.Case, async: true describe "Example rule runner" do
test "Don't send with any false rules" do
Example.runner(
[1, 2, 3, 4, 5],
[(fn _x -> false end)],
&no_results_expected/1)
end test "Send with a true rule" do
Example.runner(
[1, 2, 3, 4, 5],
[&is_even/1],
some_successful_function)
end
end
end
We’ll start with these 2 test cases. We will enforce false with the false rules test and we will check if our numbers are even with the second.
Next we will create the functions that we’ll spy with.
defmodule ExampleTest do# ... defp no_results_expected _result do
# fail on error
end defp result_expected result do
# assert for success
end defp is_even m do
# assert for calling
rem(m, 2)
end
# ...end
Ideally we would not assert/refute within the callback as that makes context a bit more difficult with the assertions away from our tests. So how do we assert/refute then?
Let’s use the message mailbox!
defmodule ExampleTest do# ... defp no_results_expected _result do
send self(), {:error, result}
end defp result_expected result do
send self(), {:ok, result}
end defp is_even m do
send self(), {:condition, m}
rem(m, 2)
end# ...
end
Here we will send the message into our local mailbox with an atom key so for us to assert against. Finally we need to update our tests to collect the messages and assert their response.
defmodule ExampleTest do# ... test "Don't send with any false rules" do
Example.runner(
[1, 2, 3, 4, 5],
[(fn _x -> false end)],
&no_results_expected/1)
refute_received {:error, _}
end test "Send with a true rule" do
Example.runner(
[1, 2, 3, 4, 5],
[&is_even/1],
&result_expected/1)
# assert all 5 conditions are tested
assert_received {:condition, _}
assert_received {:condition, _}
assert_received {:condition, _}
assert_received {:condition, _}
assert_received {:condition, _} # assert that we have received 2 even numbers
assert_received {:ok, _}
assert_received {:ok, _}
end
end# ...
end
With ExUnit’s *_received functions we can assert and refute on what we have received.
Now you could tidy the tests up by reducing the frequency of assertions but here we highlight that is_even is called 5 times and we do have 2 even numbers.
Here is our test in full:
defmodule ExampleTest do
use ExUnit.Case, async: true describe "Example rule runner" do
defp no_results_expected result do
send self(), {:error, result}
end defp result_expected result do
send self(), {:ok, result}
end defp is_even m do
send self(), {:condition, m}
rem(m, 2)
end test "Don't send with any false rules" do
Example.runner(
[1, 2, 3, 4, 5],
[(fn _x -> false end)],
&no_results_expected/1) # Refute that we have received any error messages
refute_received {:error, _}
end test "Send with a true rule" do
Example.runner(
[1, 2, 3, 4, 5],
[&is_even/1],
&result_expected/1)
# assert all 5 conditions are tested
assert_received {:condition, _}
assert_received {:condition, _}
assert_received {:condition, _}
assert_received {:condition, _}
assert_received {:condition, _} # assert that we have received 2 even numbers
assert_received {:ok, _}
assert_received {:ok, _}
end
end
end$> mix test --trace
* test Example rule runner Send with a true rule (3.0ms)
* test Example rule runner Don't send with any false rules (13.4ms)
Let’s prove that the error condition will be called. We will update “Don’t send with any false rules” to be true
test "Don't send with any false rules" do
Example.runner(
[1, 2, 3, 4, 5],
[(fn _x -> true end)],
&no_results_expected/1)
end
Then run the test.
$> mix test
* test Example rule runner Send with a true rule (3.0ms)
* test Example rule runner Don't send with any false rules (13.4ms)1) test Example rule runner Don't send with any false rules (ExampleTest)
test/example_test.exs:20
Unexpectedly received message {:error, 1} (which matched {:error, _})
stacktrace:
test/example_test.exs:22: (test)
One final confirmation, let’s confirm that we are clearing the message queue with assert_received and add a 6th assertion.
test "Send with all true rules" do
Example.runner([1, 2, 3, 4, 5], [&check_condition/1], &email_expected/1)
assert_received {:condition, _}
assert_received {:condition, _}
assert_received {:condition, _}
assert_received {:condition, _}
assert_received {:condition, _}
assert_received {:condition, _} # 6th is_even assertion
assert_received {:ok, _}
assert_received {:ok, _}
end1) test Example rule runner Send with a true rule (ExampleTest)
test/example_test.exs:26
No message matching {:condition, _} after 0ms.
Process mailbox:
{:ok, 1}
{:ok, 2}
{:ok, 3}
{:ok, 4}
{:ok, 5}
stacktrace:
test/example_test.exs:33: (test)
Adding the 6th assertion attempts to find a message that is not present so the test fails as expected.
So that’s it! I hope I’ve illustrated a good example to unit test your impure functions. Next why not try adding a new test to confirm that we are calling multiple validators? Is Enum.uniq
required after the list comprehension?
Thanks for reading.
-Aaron