Custom assertions with ExUnit

Any unit testing library makes it easy to build your own custom assertions on top of the base assertions: just write functions. Often, though, you lose important information like appropriate line numbers, output that highlights structure differences, and so on. ExUnit makes it fairly easy to keep those, but you need a little background knowledge.

This article explains ExUnit's workings, and describes some helper functions that make it easier to create and test custom assertions.


ExUnit failure handling is built on the ExUnit.AssertionError structure. As of April 21, 02021, that structure looks like this:

  @no_value :ex_unit_no_meaningful_value

  defexception left: @no_value,
               right: @no_value,
               message: @no_value,
               expr: @no_value,
               args: @no_value,
               doctest: @no_value,
               context: :==            

As far as I know, there's no guarantee this structure won't change in the future. But I'm willing to bet it won't, at least not in a way that breaks custom assertions.

Here's an assertion failure. I've annotated particular lines with the four AssertionError fields you'll need to understand:

If you're curious about the field values for any given assertion failure, an easy way to examine them is to take advantage of the fact that assert_raise returns the AssertionError structure it checks:

  test "a sample failure" do
      fn -> assert f(1) == [1, 2, 3, 4] end)
    |> IO.inspect

The result:

  args: :ex_unit_no_meaningful_value,
  context: :==,
  doctest: :ex_unit_no_meaningful_value,
  expr: {:assert, [line: 22],
   [{:==, [line: 22], [{:f, [line: 22], [1]}, [1, 2, 3, 4]]}]},
  left: [1, 3, 2, 4],
  message: "Assertion with == failed",
  right: [1, 2, 3, 4]

Working with AssertionError fields

Suppose you're testing a function that should return an error tuple. I'd use a plain old assert:

  test "produces error tuple 1" do
    actual = function_under_test_1(tag: 5)
    assert actual == {:error, 5}

That's a perfectly fine test, and it plays nicely with ExUnit's colorizing of differences:

But let's have this simple example stand in for a more complicated check of just part of a data structure. That is, we want an assertion that's used like this:

  test "produces error tuple" do
    actual = function_under_test(tag: 5)
    assert_error_content(actual, 5)        ######## <<<<<

Its implementation might look like this:

  def assert_error_content({:error, content}, expected) do
    assert content == expected

  def assert_error_content(input, _) do
    flunk("The #{inspect input} value is not an error tuple")

And here's a test failure given by the second of those functions:

That's OK... but it grates to have the actual value crammed into the error message like that. It would stand out better in a "left:..." line:

That can be accomplished by having assert_error_content catch the AssertionError from flunk and change the :left field:

  def assert_error_content(input, _) do
    try do
      flunk("Expected an error tuple")
    rescue ex in ExUnit.AssertionError ->
      reraise %{ex | left: input}, __STACKTRACE__

That's too much code to put in every custom assertion, so I generalized it to elaborate_flunk:

  def assert_error_content(input, _) do
    elaborate_flunk("Expected an error tuple", left: input)

You can import elaborate_flunk from the flow_assertions package, specifically this module.

Getting the "code:..." line right

Here's a test that will fail because the function's result is an error tuple that has the wrong content:

  test "produces error tuple" do
    actual = function_under_test(tag: 1)
    assert_error_content(actual, 5)

The failure will come from the first implementation of assert_error_content, which, again, is this:

  def assert_error_content({:error, content}, expected) do
    assert content == expected

Here's the failure:

The line numbers in the failure are correct, but the code and error message don't have anything obvious to do with line 41:

    assert_error_content(actual, 5)

We can add an error message to assert like this:

    assert content == expected,
      "Error tuple has the wrong content"

Surprisingly, the new message causes the "code:..." line to also change:

To see why that is, we can inspect the two AssertionErrors. Here's the one from the original assert, the one without an explicit error message:

  expr: {:assert, [line: 82], [ {:==, [line: 82], [{:content, [line: 82], nil}, {:expected, [line: 82], nil}]} ]}, 
  message: "Assertion with == failed", 

Here's the one from the assert with the explicit message:

  expr: :ex_unit_no_meaningful_value, 
  message: "Error tuple has the wrong content",

Adding a message also changes the expr field to ex_unit_no_meaningful_value. That instructs failure formatting code to look at the stack trace, find the topmost entry that belongs to the test module (in this case, CustomTest), read the file name (.../custom_test.exs) and line number (41) out of that entry, and then read the source at that line (assert_error_content(actual, 5)).

(Note: it's a little confusing that you change the value of the :expr field to change what's printed after "code:", but that's the way it is.)

Putting the fields back

I didn't call it out in the previous section, but adding an explicit message to assert also marks the :left and :right fields as having no value, so they're not printed.

In this case, that's not what we want, but it's easy to add them back by using elaborate_flunk or a new function, elaborate_assert, producing this:

Here's the version that uses elaborate_assert:

  def assert_error_content({:error, content}, expected) do
    elaborate_assert(content == expected,
      "Error tuple has the wrong content",
      left: content, right: expected)

Keeping the message, but not the "code: ..." line

Above, we overrode the :message and, as a side effect, changed what was printed as the source assertion (on the "code:" line). If we'd been OK with the message, we could have changed only the :expr field. You could do that with try/catch, but I prefer the more concise adjust_assertion_error:

  def assert_error_content({:error, content}, expected) do
      fn -> assert content == expected end,
      expr: ExUnit.AssertionError.no_value)

Notice that ExUnit code uses ExUnit.AssertionError.no_value/0 in preference to the literal ex_unit_no_meaningful_value, so you might as well too.

The meaning of :left and :right

The nice thing about failure output that has "left:" and "right:" lines is that it gives you the choice about which to use as the expected value and which as the actual value. (Many unit test frameworks are derived from 1989's SUnit, put the expected value as the first argument of an assert_equal function.)

"Left" and "right" become more awkward when your assertions aren't symmetric the way assert a == b is, and when you're only interested in part of the function-under-test's return value.

I suggest that you decide "left" means "the interesting part of the actual value" and "right" is always about what's expected. That's the opposite of the SUnit convention, but Elixir is a left-to-right language like English (think |> pipelines). In such languages, time flows from left to right. Creating a result happens before checking it, so the textual order should be the same.

Aside: flow-style assertions

Except for assert_raise, assertions aren't required to return any particular value. I like having them return their first argument so that they can be put in a pipeline:

  VM.ServiceGap.accept_form(params, @institution)
  |> ok_content
  |> assert_valid
  |> assert_changes(id: 1,
                    in_service_datestring: @iso_date_1,
                    out_of_service_datestring: @iso_date_2,
                    reason: "reason")

If you like that style, you can use defchain instead of def:

  import FlowAssertions.Define.Defchain 
  defchain assert_fields(kvs, list_or_map) do 

Testing custom assertions

If your assertions are complicated, you probably want to test them.

Testing cases where the code passes the assertion is easy:

test "..." do 
  my_assertion(1, 2) 

Or, if you're using flow-style assertions:

test "..." do 
  assert my_assertion(1, 2) == 1 

Using assert_raise to capture and check assertion failures is a bit cumbersome, so I offer assertion_fails:

describe "`assert_fields` with just a list of fields" do 
  test "how failure is reported" do 
    map = %{field1: 1} 
    checks = [:field1, :missing_field] 
    assertion_fails(                         ####### 
      "Field `:missing_field` is missing", 
      [left: map, right: checks], 
      fn -> assert_fields(map, checks) end) 

Tip 1: Although I'm usually a fan of test-driven design, I find I get better failure output from custom assertions by first trying them out as bare failing assertions, like:

test "how failure is reported" do 
  map = %{field1: 1} 
  checks = [:field1, :missing_field] 
  assert_fields(map, checks)           ########### 

... then examining the actual failure content to see if I like it:

... and only wrapping the assertion in assertion_fails when I'm happy. I'm not good at visualizing what an assertion failure will look like. I have to actually look at it.

Tip 2: As the above example shows, the "code:" line often won't contain literal values, but rather variable names. Err on the side of including :left and :right values. Sometimes, as with changeset assertions, I'll add the larger structure (the changeset) as a second :message line.

Tip 3: I will sometimes have complicated assertion failure messages, ones that span several lines. Exact string matches don't work well. For that reason, the first argument to assertion_fails can be a regular expression.

But even a regular expression is annoying if what you want to check is several small chunks of text embedded in a lot of other text. To avoid hard-to-read regular expressions that have to deal with line breaks, etc., take advantage of the fact that :message is just another field in an AssertionError and that the second argument to assertion_fails is a keyword list that allows duplicate keys. Like this:

  assertion_fails(~r/First bit/, 
    [message: ~r/Second bit/,      ########## 
    left: 5], 
    fn -> 
      refute_fields(%{"a" => [], "b" => 2}, ["a", "b"]) 

I hope this has been useful.