A Field Guide to Unit Testing: Readability


THE UNIT TESTING SERIES

  1. A Field Guide to Unit Testing: Overview
  2. A good test is trustworthy
    • Test isolation (and what is dependency)
    • Definition of a unit
    • Testing scenarios
  3. A good test is maintainable
    • Testing one thing at a time
    • Use setup method, but use it wisely
  4. A good test is readable
    • Test description
    • Test pattern

A good test is readable

Although it is one of the three important pillars in a unit test, readability usually gets less attention than it deserves. The situation is even more dismal in tests. Why do we care if a test looks pretty or not? You may wonder. Tests are not even production code, so it is good enough if does it job, right?

Let's see a famous quote from Robert C. Martin:

[The] ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code.

When we write code that is readable, we are doing everyone–including our future selves–a big, big favor.

Coding is like writing, in many aspects. A good piece of writing has a clear, easy-to-follow narrative in a well-organized structure. It can even be elegant and enjoyable. Our goal for the code is the same. If a piece of code has high readability, it would not only greatly improve our mood, but also enhances maintainability.

Test as documentation

Better yet, since tests are describing the behavior of the subject (and validating it) it is naturally a documentation. If we do it right, we can achieve "test as documentation"–creating documentation as we write tests. This "documentation" will be inherently valid and up-to-date because it actually fails if the production code isn't what it describes. What a bargain!

So what do we do to make our tests readable, instead of a messy blob of code that no one dares/bothers to look at?

Turns out, it's really quite easy. There are two patterns we can follow are instantly achievable yet very effective. The first is about test description, the second code arrangement.

Test description

The purpose of a test's name/description is to describe what this test does. Duh. However, to make it readable, it does require some brain. Let's see an example first.

In our restaurant that only serves French toast, the Server class has two functions: take_order and serve. We can name our test cases as below:

it '#take_order' do
  # ...
end

it '#serve' do
  # ...
end

That's often the case you see in the wild (including our own codebase at PicCollage :P). Well, what's wrong with it? Unmistakably, it tells us what the subject of this unit test is. The problem is that it is the only information it gives us. We don't know what behavior it is expected to have. How many scenarios are there? How does it handle errors? To get the answers, we will need to dive into the test code, and we can't even be sure that the answer will be there.

Another problem that this kind of naming has is that it often violates the "test one thing at a time" principle that we discussed in the last article. The fact is, naming a test case like this (with only the subject) not only introduces readability issues, but also makes the codes less maintainable.

The "good" way of naming a test

When we write a test name, our goal is to make it clear what this subject is, what scenario we are testing, and what the expected result is. If we somehow make the three elements into our test description, whoever that reads the tests will be able to understand the subject instantly, without having to be bothered with the technical details. Even people with no experience in the underlying technology can understand it, because a test description is plain English.

A brief recap of the three elements:

  • Subject
  • Scenario
  • Outcome

And here is the format we can loosely follow

[subject]_[scenario]_[outcome]

Going back to our restaurant, we can have our tests for Restaurant::Server class written like this:

#  [subject]    [scenario]                [outcome]    
it '#take_order when dish is French toast passes the order to Cook' do
  # ...
end

#  [subject]    [scenario]                    [outcome]    
it '#take_order when dish is not French toast returns an error message' do
  # ...
end

#  [subject][outcome]    
it '#serve returns dish as a string' do
  # ...
end

Here we've made two adjustments:

  1. Separate the test for #take_order in two, because there are two scenarios for this subject.
  2. Include the subject, the scenario, the expected outcome in the description, instead of just the subject.

🍰, right?

If we zoom in on the third test, though, we'll find that the "scenario" part is missing.

Why? Simply because we don't need it. Let's see the actual implementation:

class Restaurant::Server
  def serve(dish)
    "Serving delicious #{decorated_dish} yo!"
  end
end

Given that, the test description '#serve returns decorated dish as a string' is self is clear and self-explanatory enough.

I brought this up as a reminder that the Subject + Scenario + Outcome combo is just a guideline (and a really nice one), not a hard rule to obey. Our ultimate goal, we should all remember, is to make our tests as readable as they can be. That said, if you don't have a good reason to not follow this guideline, stick to it for now, and your teammates would all love you.

So that took care of the first part of this article. Now here's another thing we can do to make our tests readable: follow a test pattern.

The 3A test pattern

There are three elements you can find in every test. They are conveniently named 3A–Arrange, Act, Assert.

Arrange is where we set up the necessary environment for the subject under test to do its work.

Act is where the subject under test does its work.

Assert is where we validate the correctness of the result produced in Act.

If we write our tests following this Arrange --> Act --> Assert order, the resulting code would be highly readable.

Let us look at Restaurant::Cook's make_french_toast method as an example.

it '#make_french_toast with everything prepared returns French toast as string' do
  # [Arrange]
  # Make sure the subject is isolated from all its dependencies.
  Restaurant::StorageRoom.stub(:get).with('egg') { 'egg' }
  Restaurant::StorageRoom.stub(:get).with('bread') { 'bread' }

  # [Act]
  result = Restaurant::Cook.new.make_french_toast

  # [Assert]
  expect(result).to eq 'French toast is here. Ugh.'
  end
end

We don't necessarily have to insert the [Arrange], [Act], and [Assert] comments. My habit is to use an empty line to separate the three parts, like below:

it '#make_french_toast with everything prepared returns French toast as string' do
  Restaurant::StorageRoom.stub(:get).with('egg') { 'egg' }
  Restaurant::StorageRoom.stub(:get).with('bread') { 'bread' }

  result = Restaurant::Cook.new.make_french_toast

  expect(result).to eq 'French toast is here. Ugh.'
  end
end

Another 🍰.

Double trouble for doubles!

It's not happily ever after once we learn the Arrange -> Act -> Assert structure. Some frameworks, including our friend RSpec, does not syntactically allow us to follow this structure when dealing with mock objects. Let's see what that means:

# Test for Restaurant::Server, using RSpec
describe '#take_order' do
  it 'when dish is French toast passes the order to Cook' do
    # [Arrange and Assert]
    server = Restaurant::Server.new
    expect_any_instance_of(Restaurant::Cook).to receive(:make_french_toast)

    # [Act]
    server.take_order('French toast')
  end
end

In this example, the line

expect_any_instance_of(Restaurant::Cook).to receive(:make_french_toast)

serves both as an arrangement (stubbing an external call) and an assertion (it is expected to be called). This is the 2-in-1 nature of mock objects, and frameworks like RSpec do not separate the two functions in the syntax, with reasons of conciseness I would guess.

If we are not familiar enough with the concept of mocks, we will be surprised to see no assertions at the end of tests. Even worse, we might find ourselves (or others) unintentionally hiding assertions inside the tests because we can easily use throw mocks around as a setup and forget that they come with expectations.

But the difference between stub objects and mock objects is another topic for another day. The question at hand is: Is there any way that we can fit the mock objects inside the Arrange -> Act -> Assert pattern?

Yes we can

Use another framework. Yup. RSpec is not the only Ruby test framework that we can use, you know. Another popular Ruby test framework, also the built-in framework in Rails, is Minitest. Minitest provides the mock syntax that we want: we can separate the stub function and the assertion function from a Minitest::Mock object. Let's see how we can rewrite the above test case with Minitest:

# Test for Restaurant::Server, using Minitest

require 'minitest'
require 'minitest/mock'

describe '#take_order' do
  test 'when dish is French toast passes the order to Cook' do
    # [Arrange]
    server = Restaurant::Server.new
    mock   = Minitest::Mock.new
    mock.expect(:make_french_toast, nil) # second arg is return value, which is nil here.

    Restaurant::Cook.stub(:new, mock) do
        # [Act]
      server.take_order('French toast')
    end

    # Assert
    assert_mock mock
  end
end

See how it beautifully follows the pattern? The intention of this test has become much clearer here–we don't have to remember that the assertion actually lies in the setup section, we only have to look at the final line and know that it is testing the behavior (sending a correct outgoing message to another class).

Admittedly, this is more verbose than the RSpec version. It sacrifices brevity for readability. But If you ask me, I say it's worth it.

That's it

In this article, we have learned how to make our tests readable. First we looked at what a responsible test description looks like, and then we looked into the test structure to see how we can follow the 3A test pattern.

This article also concludes the A field guide to unit testing series. If you have not read the previous articles, go read them (spoilers: they're good!) If you have read through all of them already, I'm impressed.

As always, any feedback and/or questions is strongly encouraged/welcome. Reach out to me through this form: https://codecharms.me/contact. See you in the next article!


  • Find me at