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:
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):
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:
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:
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:
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 test1) 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?
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:
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:
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:
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!