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.
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.