Unit Testing Higher Order Functions in Elixir

Aaron
everydayhero engineering
5 min readJan 18, 2017

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)
end
iex> 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, _}
end
1) 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

--

--