February 10

A cleaner way to organize tests using ExUnit’s named setup

  • Home
  • >>
  • A cleaner way to organize tests using ExUnit’s named setup

The dev team at Vendorful takes code quality seriously. We invest in writing tests for every bug fix and new feature being implemented and in peer-reviewed merge requests. Good developers learn from experience that investing time into good test coverage actually allows them to move faster as a team in the long run. In addition to having good test coverage, writing human-readable code and tests is equally important. Personally, my feeling of having to work with a neatly nested and organized test suite that is set up, but extremely difficult to follow is almost comparable to having to walk through a wild maze to find something that is not very worthwhile. Unfortunately, when you're coding, you have to deal with this every time.

The Maze Runner

Better as a movie than an approach to testing code.

Since I came from a Ruby on Rails background, I spent years using RSpec to write my tests. While coding with Elixir and using ExUnit to write tests nowadays, I caught myself finding ways to nest RSpec "context" blocks inside each other. Let's look at a simple example: you wanted to build a chef robot to prepare dinners based on the leftover food in your fridge. Depending on what's available in your kitchen, it'll choose how to prepare the appropriate dishes.

Nested contexts in RSpec

This is how I would write TDD tests for this scenario in RSpec.

ruby
  describe "make_dish_from_left_over" do
    context "with dishes that are served cold" do
    # before: set up cold dishes
    # ...
    # end
    context "seasoning available" do
      # before: set up seasoning
      # ...
      # end
      expect(add_seasoning).to be_truthy
    end
    context "extra protein available" do
      # before: set up extra protein
      # ...
      # end
      expect(add_protein).to be_truthy
    end
  end

  context "with dishes that are served hot" do
      # before: set up hot dishes
      # ...
      # end
    context "microwave available" do
      # before: set up microwave
      # ...
      # end
      expect(warm_up_by_microwave).to be_truthy
    end
    context "microwave NOT available" do
      # before: set up stove top
      # ...
      # end
      expect(warm_up_by_stove_top).to be_truthy
    end
  end
end

As you can imagine, if these tests are filled up and more nested contexts are added, such as microwave settings depending on size of dishes, stove top heat amount and time, etc... they will eventually become too long and too complicated to read and follow. This readability problem is then compounded for other developers who haven't worked on this area of the robot. But there's another issue in moving from Ruby to Elixir; there is no way to write nested "contexts" like this in Elixir's ExUnit — and there is a good reason for that.

ExUnit Documentation

By forbidding hierarchies in favor of named setups, it is straightforward for the developer to glance at each describe block and know exactly the setup steps involved.

With ExUnit, what you can (and will want to) do is to use named setups.

What are named setups?

If you haven't come across this, it's not surprising. The documentation for named setups in ExUnit is kind of buried somewhere in the middle of ExUnit.Case docs. Named setups are simply configurations of tests used to put together test data for the tests that follow. Each setup is actually just a function that configures the test data and returns the variables to be used in the tests themselves. If you are familiar with writing tests in ExUnit then these configuration functions are exactly the setup blocks. For example:

elixir
defp setup_cold_dishes(context) do
temp = 35
size = if context.size, do: size, else: :small
dish = set_temperature(temp)
|> set_size(size)
|> set_time_in_fridge(:15_hours)
|> build_dish


%{temp: temp, size: size, cold_dish: dish}
end

Note that a context variable is passed in this setup function so that the previous configurations can be accessed in this setup as context.size in this case. You can also grab the size variable out of this context right in the function signature like so:

elixir
defp setup_cold_dish(%{size: size}) do
end

How do I use it in the tests?

elixir
describe "make COLD dishes from left over" do
  setup [:setup_cold_dish]
  it "", context do
    ...
  end
end

Let's make it even nicer by combining the condition "seasoning available":

elixir
describe "make COLD dishes from left over when seasoning available" do
  setup [:setup_cold_dish, :set_seasoning]
  it "", context do
    assert add_seasoning
  end
end

Passing variables to other setup functions

Note that if you have multiple setup functions in sequence like this, the latter ones on the right can read the "context" variables returned by the previous ones on the left. So :set_seasoning can actually access temp, size and cold_dish variables from :setup_cold_dish, but we just not doing anything with them in this scenario.

Putting the pieces together

Now let's write the whole thing with combined conditions!

elixir
describe "make COLD dishes when seasoning available" do
  setup [:setup_cold_dish, :set_seasoning]
  it "", context do
    assert add_seasoning
  end
end

describe "make COLD dishes when extra protein available" do
  setup [:setup_cold_dish, :set_protein]
  it "", context do
    assert add_protein
  end
end

describe "make HOT dishes when microwave available" do
  setup [:setup_hot_dish, :set_microwave]
  it "", context do
    assert use_microwave
  end
end

describe "make HOT dishes when stove top available" do
  setup [:setup_hot_dish, :set_stove_top]
  it "", context do
    assert use_stove_top
  end
end

Conclusion

The tests are now flattened, much cleaner and easier for other developers (and, of course, for me at a later time) to read and follow at a glance. And the beauty of it is that there are no more nested context/describe blocks, no more mazes but with shared setup functions and cleaner code!

Although the above example scenario is  very simple to demonstrate the basic functionalities of named setups, I hope you can build on these ideas and apply them to your test suite when you need it.


Tags

elixir, software development