How to Test Shared Behavior in Elixir

Implementing functionality similar to RSpec’s shared examples

Create a new mix project if you want to follow along:

$ mix new calculator
$ cd calculator

Suppose you have a Calculator module and a sum_list function:

defmodule Calculator do
  def sum_list(list) do
    list |> Enum.reduce(0, &(&1 + &2))
  end
end

That’s straightforward enough. You can also write the reduce line more verbosely if you want:

list |> Enum.reduce(0, fn x, y -> x + y end)

And follows a test case to exercise the above functionality (not accounting for edge cases):

defmodule CalculatorTest do
  use ExUnit.Case

  test "sums a list of integers" do
    assert Calculator.sum_list([1, 2, 3, 4]) == 10
  end
end

As you might expect, it passes successfully:

$ mix test

.

Finished in 0.02 seconds
1 test, 0 failures

Now here’s the picture: you are training your Elixir skills and you want to implement an alternative version of the code without deleting what you already have, and using the same tests as guides.

Also, you want to stick to the default ExUnit testing framework and avoid using a library like ESpec just to obtain this feature. How might you do that? Copy, paste and modify the test? No.

Let’s figure out a way to share tests between two or more modules.

Passing arguments

The first step in that direction is to pass the module that we want exercised as an argument to the test. If you are not aware, modules are represented by atoms in Elixir, which means they can be freely passed around like any other ordinary value.

Fortunately, ExUnit provides that feature out of the box. Here is the refactored test:

defmodule CalculatorTest do
  use ExUnit.Case

  @moduletag calculator: Calculator

  test "sums a list of integers", %{calculator: calculator} do
    assert calculator.sum_list([1, 2, 3, 4]) == 10
  end
end

Allow me to explain: @moduletag is a module attribute, and module attributes are evaluated at compile-time. The test macro is able to “capture” this value and pass it to the real test function, which is dynamically defined under the hood by the same macro. How exactly that happens is beyond the scope of this post, so just trust ExUnit and assume that it does the right thing.

As you can see, we are passing a keyword list to the @moduletag attribute:

@moduletag calculator: Calculator

Which is the same as doing this:

@moduletag [calculator: Calculator]

In a similar vein to its Ruby cousin, we can omit brackets in Elixir when doing method calls

That keyword list goes all the way down to the test method and we can pattern match against a map to extract what we want: calculator. ExUnit compiles all tags down to a map, therefore we don’t pattern match against a keyword list.

After this step, everything should work just as before:

$ mix test

.

Finished in 0.02 seconds
1 test, 0 failures

NOTE: @moduletag options are passed to all tests of a module. You can also pass individual options to each test using the @tag attribute

Sharing the tests

Now that we gleaned what we need from ExUnit, let’s start sharing our examples! One way to accomplish that is by extracting our tests into a separate module and using the module where we want; in this way, we can customize options passed to each one:

defmodule CalculatorTest do
  use CalculatorSharedTests, calculator: Calculator
end

defmodule AltCalculatorTest do
  use CalculatorSharedTests, calculator: AltCalculator
end

To leverage the use feature, let’s define a __using__/1 macro to return the AST of the code that we want evaluated in each target module during compile-time. It goes like this:

defmodule CalculatorSharedTests do
  defmacro __using__(options) do
    quote do
      use ExUnit.Case

      @moduletag unquote(options)

      test "sums a list of integers", %{calculator: calculator} do
        assert calculator.sum_list([1, 2, 3, 4]) == 10
      end
    end
  end
end

What did we just do? Correct, we just transferred logic from CalculatorTest to __using__/1. Moreover, the code gets transformed into an AST by the quote/1 function.

It’s code that generates code

As for the options argument, it already enters the macro as an AST, so we just unquote it to mix up its instructions into the final AST:

@moduletag unquote(options)

Had we not used unquote, the compiler would have assumed options is a function call associated with the runtime, and it would all blow up!

Back to our tests, everything should still be good if we re-run them:

$ mix test

1) test sums a list of values (AltCalculatorTest)
     test/calculator_test.exs:28
     ** (UndefinedFunctionError) function AltCalculator.sum_list/1 is undefined (module AltCalculator is not available)

.

Finished in 0.05 seconds
2 tests, 1 failure

Ops, we have one success and *one failure, *but that’s exactly what we want!

It just means CalculatorTest works the same, while our new module (AltCalculator) still has to be implemented. The good news is that we can use exactly the same tests to guide us on our way home.

Polishing up

I’m still not happy with that code. What if we need another set of shared examples? There will be lots of boilerplate to repeat, such as __using__/1, quote/1, unquote/1, etc. How can we make it more DRY?

Let’s picture how our functionality ought to be used. How about this?

defmodule CalculatorSharedTests do
  import SharedTestCase

  define_tests do
    test "sums a list of integers", %{calculator: calculator} do
      assert calculator.sum_list([1, 2, 3, 4]) == 10
    end
  end
end

defmodule CalculatorTest do
  use CalculatorSharedTests, calculator: Calculator
end

defmodule AltCalculatorTest do
  use CalculatorSharedTests, calculator: AltCalculator
end

This would be the easiest-to-read implementation I can think of, so let’s make it real!

First of all, we must define a SharedTestCase module with a define_tests/1 macro. Here’s the skeleton:

defmodule SharedTestCase do
  defmacro define_tests do
    quote do
      # Code here
    end
  end
end

Now we need to fill in define_tests/1 with the quoted definition of a using/1 macro in order to reach our final goal:

use CalculatorSharedTests, calculator: Calculator

Or to put it another way, we need a macro defining another macro! I know, this is mind-bending, but let’s proceed:

defmodule SharedTestCase do
  defmacro define_tests(do: block) do
    quote do
      defmacro __using__(options) do
        block = unquote(Macro.escape(block))

        quote do
          use ExUnit.Case

          @moduletag unquote(options)
          unquote(block)
        end
      end
    end
  end
end

And that’s it! Take some time to read and analyze the code.

I won’t explain it thoroughly, but the reason why we are using Macro.escape/2 is because we are under two levels of quoting, and the block needs to be made available down at the second level. The first level wraps our original AST into code that can be used further down:

# Macro.escape "escapes" any **value** into an AST
block = unquote(Macro.escape(block))

This is possible because ASTs are represented as tuples in Elixir. When the compiler does the first pass, it understands there’s a second quoting level and the code gets transformed into something like this:

# Yeah, this ends up unquoting an "AST of an AST" to get
# back just an AST.
block = unquote(AST_OF_AST_TUPLE)

When the compiler does the second pass (after injecting and actually evaluating the __using__ macro), unquote unwraps the value back into the original AST, which is exactly what we want.

If our line had been just unquote(block), the compiler would have looked for a “block” variable and there would have been errors. Instead, we are inserting our literal AST tuple into the template as a “local variable”.

NOTE: We can’t use the pipe operator in this specific situation: block = block |> Macro.escape |> unquote. To understand why, imagine that our code expands like the following ERB template: block = unquote(<%= Macro.escape(block) %>). Using the pipe macro would confuse the context and force the compiler to evaluate the line differently.

And what is that do: block argument doing in define_tests? Turns out we are pattern matching against a keyword list to extract our “block”. In Elixir, there are no “blocks” such as in Ruby, instead they are pure syntactic sugar for keyword lists. This code here:

some_macro do
  1 + 1 = 2
end

Is exactly the same as this one:

some_macro([do: 1 + 1 = 2])

Instead of being evaluated right away, the value 1 + 1 = 2 enters through the macro as an AST like the following:

{:=, [line: 11], [{:+, [line: 11], [1, 1]}, 3]}

When you see do..end in Elixir code, rest assured it is a macro. Even def, which we use to define methods, is a macro itself!

The final code

And here is the final code in all of its glory:

defmodule Calculator do
  def sum_list(list) do
    list |> Enum.reduce(0, &(&1 + &2))
  end
end

defmodule SharedTestCase do
  defmacro define_tests(do: block) do
    quote do
      defmacro __using__(options) do
        block = unquote(Macro.escape(block))

        quote do
          use ExUnit.Case

          @moduletag unquote(options)
          unquote(block)
        end
      end
    end
  end
end

defmodule CalculatorSharedTests do
  import SharedTestCase

  define_tests do
    test "sums a list of integers", %{calculator: calculator} do
      assert calculator.sum_list([1, 2, 3, 4]) == 10
    end
  end
end

defmodule CalculatorTest do
  use CalculatorSharedTests, calculator: Calculator
end

defmodule AltCalculatorTest do
  use CalculatorSharedTests, calculator: AltCalculator
end

There may be a lot to digest, especially if you are not familiar with the language. Our final product is as clean to read as good’n’old Ruby code.

As a plus, you can also extend each test module with specific examples in addition to the shared ones! And remember, you still have a failing test to make pass!

Conclusion

This code can be reused in other Elixir projects: you can pass any parameters and customize shared tests to your liking, much like RSpec’s shared examples. That said, it may not be as full-featured as the latter.

The point herein wasn’t to explain all aspects of the code, otherwise it would get very lengthy and tiring in no time. If you want to learn more about metaprogramming, take a look at this awesome link.

I hope this post encourages you to explore Elixir’s features further, just like I am doing. If you know about any libraries or alternative methods to implement this, please share it in the comments.

Happy TDD’ing!

We are hiring new talents. Do you want to work with us? become@codeminer42.com