The dev team at Vendorful take code quality seriously. We invest in writing tests for every bug fix and new feature being implemented and in well reviewed merge requests. As any good developer should know from their past experience that with good test coverage they can actually move faster as a team in the long run. However, 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 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, every time.
Coming from Ruby on Rails background, I used to use 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 find a simple example: you wanted to build a chef robot to prepare you dinners base on the left over 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.
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 all filled up and more nested contexts are added, such as: microwave settings depending on size of dishes, stove top heat amount and time, etc... it will become too long and too complicated to read and follow, especially for other devs who haven't worked on this area of the robot. Also, there is no way to write nested "contexts" like this in Elixir's ExUnit - and there is a good reason for that.
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. - ExUnit documentation
With ExUnit, what you can, and want to do is to use named setups.
What are named setups?
I have to admit that the documentation for named setup in ExUnit
is kind of buried somewhere in the middle of ExUnit.Case
documentations. Named setups are simply configurations of tests used to put together test data for the following tests. 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:
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:
defp setup_cold_dish(%{size: size}) do
end
How do I use it in the tests?
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":
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 later 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!
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 for me at a later time) to read and follow at a glance. And the beauty of it is: no more nested context/describe blocks, no more mazes but with shared setup functions and cleaner code!
Although the above example scenario is kept very simple to demonstrate the basic functionalities of named setups, but I hope you could get the idea and apply it to your test suite when you need it.
Top comments (0)