Philipp Tessenow
– Thoughts of a Software Engineer –


Compile-time checked Test Subjects in Elixir

2023-06-15

When writing tests in Elixir, we often have subjects (in describe or test blocks) that reference functions

describe "UserHandler.delete_users/2" do
  test "deletes the user if they exist" do
    # ...
  end
end

This reads nice, but has problems:

A better way to reference functions in test subjects

A better approach is to reference the actual function, not just a string.

describe inspect(&UserHandler.delete_users/2) do
  # ...
end

By referencing the actual module and function, we make sure it exists. If anyone renames the function, or changes its arity, the compiler will tell us. No more manual fixing tests that became out of sync with the code base over time.

With this simple change, we gain another valuable benefit: Editor support. Many code editors allow us to jump to function definitions. This makes it easy to jump from a test to the tested function. It goes the other way around too: Editors often allow to jump from a function definition to its uses. This allows to find where a function is tested.

But there is a downside (dissappointed gasp from the audience). It doesn't read as nice. The inspect call clutters the test subject, making it less readable. describe inspect is just too much of a prefix before actually useful content.

Ideally, we could give functions directly to describe

describe &UserHandler.delete_users/2 do
  # ...
end

That's neat, right?! But sadly, ExUnit doesn't like it much

== Compilation error in file test/demo.exs ==
** (ArgumentError) describe name must be a string, got: &UserHandler.delete_users/2
    (ex_unit 1.15.0-rc.2) lib/ex_unit/callbacks.ex:751: ExUnit.Callbacks.__describe__/4

Maybe we need to propose this idea to ExUnit - describe and test should not only allow String inputs, but also functions or modules.

Macros to the rescue!

We can give inspect a better name, just for test purposes by defining a macro in our test_helper.exs

@doc ~S"""
Inspects an expression in a test.

Useful for test descriptions to make sure the tested function exists and is displayed nicely in test output.
"""
defmacro f(expr) do
  quote do
    inspect(unquote(expr))
  end
end

The much shorter name f (for function) makes our tests read much nicer!

describe f(&UserHandler.delete_users/2) do
  # ...
end

It has the same meaning (the f macro is just doing an inspect), but we get compile-time errors/warnings and editor support we wanted.

I'm using this pattern in my open source library for quite a while now and it works great.

cheers!
tessi