How to Test Shared Behavior in Elixir

Implementing functionality similar to RSpec’s shared examples

Thiago Araújo Silva
The Miners
6 min readMar 29, 2017

--

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

Suppose you have a Calculator module and a sum_list function:

lib/calculator.ex

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

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

test/calculator_test.ex

As you might expect, it passes successfully:

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:

test/calculator_test.ex

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:

Which is the same as doing this:

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:

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:

test/calculator_test.ex

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:

This code ends up injected in "CalculatorTest" and “AltCalculatorTest"

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:

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:

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?

Import makes methods and macros of “SharedTestCase” available to our module

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:

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:

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

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:

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:

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:

Is exactly the same as this one:

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

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:

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!

--

--

Thiago Araújo Silva
The Miners

Full Stack Developer. Interested in computer science and the craft of programming directed towards practical purposes.