HighlightJS

Friday, December 13, 2019

Good tests tell a story. Good stories don't follow a template.

Each word I write drops a little more of me onto the page. In time, I will be the book, the book will be me, and the story will be told. - Very Short Story

Kent Beck and Kelly Sutton have been sharing a series of very helpful byte-sized videos about the desirable properties of tests. The one on readability suggests that tests should read like a story. In it, they explain with an example and a movie analogy how a test should be a self-contained story. I'd like push that analogy of a test telling a story and the idea of self-containment further.

So, first, here's the test code as shown in the video:

RSpec.describe MinimumWage::HourlyWageCalculator do
  describe '.calculate' do
    subject { described_class.calculate(hours, rate, zip_code) }
    
    let(:hours) { 40 }
    let(:rate) { 8 }
    let(:zip_code) { nil }
    
    it "calculates the total hourly wages" do
      expect(subject).to eq(320)
    end
    
    context "zip_code is in San Francisco" do
      let(:zip_code) { 94103 }
      
      context "rate is below $15 per hour" do
        let(:rate) { 14 }
        
        it "uses $15 as the rate" do
          expect(subject).to eq(600)
        end
      end

      context "rate is above $15 per hour" do
        let(:rate) { 16 }
        
        it "uses the given rate" do
          expect(subject).to eq(640)
        end
      end
    end
  end
end

Apart from the reasons discussed in the video, I think the test fails to tell a good story for a number of reasons mostly to do with what appears to be conventional RSpec usage.

First off, Kelly begins his explanation of the story with: For this class, for this method, here's what should happen... [...] reading this aloud in my head, I'm, like, "For the 'calculate' method, it calculates the total hourly wages."

Now, I don't think talking about methods makes for a great story. Look at the output from running the tests for a moment:

MinimumWage::HourlyWageCalculator
  .calculate
    calculates the total hourly wages
  ...

I've explained elsewhere why that's not a great story, and how you could tell a better one by emphasizing behavior over structure.

Next, when Kelly moves the magic number 40 (hours/week) to a method, Kent claims that the test is less readable now than it was before. He explains that the relationship between hours and rate and the expected value is really clear if we can just see the 40 right there. I think that using a stricter definition of right there, we can vastly improve the clarity of our tests further. A DRYing process like the one Kent talks about (extracting some setup code, moving the setup to a factory, ...) is just one way we may end up moving related elements from right there to further apart. However, the boilerplate RSpec style I see in most code bases and code examples is a much more common cause of unnecessary distance between related elements relevant to a test.

Kent describes how even a 2-line test where you've gone too far with DRYing becomes completely incomprehensible unless you've read the other [n] lines of code outside the test. Let's look at how that's true for tests in this example even without the DRYing, just due to the RSpec DSL boilerplate.

it "calculates the total hourly wages" do
  expect(subject).to eq(320)
end

To understand that, you'd first have to know this:
subject { described_class.calculate(hours, rate, zip_code) }
But to understand that, you'd have to know this:
RSpec.describe MinimumWage::HourlyWageCalculator do
and this:
let(:hours) { 40 }
and this:
let(:rate) { 8 }
and this:
let(:zip_code) { nil }
Consider this DSL-light version of the same tests instead:

RSpec.describe MinimumWage::HourlyWageCalculator do
  def weekly_wage(rate:, zip_code: nil, work_hours: 40)
    MinimumWage::HourlyWageCalculator.calculate(work_hours, rate, zip_code)
  end

  it "calculates the total weekly wages" do
    expect(weekly_wage(rate: 8).to eq(320)
  end

  context "zip_code is in San Francisco" do
    let(:zip_code_in_sf) { 94103 }

    it "uses $15 as the rate when rate is below $15 per hour" do
      expect(weekly_wage(rate: 14, zip_code: zip_code_in_sf).to eq(600)
    end

    it "uses the given rate when rate is above $15 per hour" do
      expect(weekly_wage(rate: 16, zip_code: zip_code_in_sf).to eq(640)
    end
  end
end

This version eschews the multiple lets in favor of a fixture built using Inline Setup. It hides Irrelevant Information from the tests behind an evocatively named Creation Method. Besides using less space to convey the same information, I claim this alternative scores a lot better on the self-contained story dimension. Pull out any individual test (i.e. just the it block), and you still have the whole story without any reference to any external or surrounding context. For instance, look at this example:

it "uses $15 as the rate when rate is below $15 per hour" do
  expect(weekly_wage(rate: 14, zip_code: zip_code_in_sf).to eq(600)
end

Looking at it, you can tell, using Kent's own words:
  • Here's the characters (rate: $14, zip code: somewhere in SF, duration: a week)
  • Here's the action (compute the weekly wage)
  • Here're the consequences (computed wage using 600 proving the rate used was $15)
For comparison, here's the original test stripped down to the bare essentials required to tell the story of just that one case:

RSpec.describe MinimumWage::HourlyWageCalculator do
  describe '.calculate' do
    subject { described_class.calculate(hours, rate, zip_code) }

    let(:hours) { 40 }

    context "zip_code is in San Francisco" do
      let(:zip_code) { 94103 }

      context "rate is below $15 per hour" do
        let(:rate) { 14 }

        it "uses $15 as the rate" do
          expect(subject).to eq(600)
        end
      end
    end
  end
end
That's 15 lines of screen space (discounting blank lines) used up to tell the 3-line story we've just seen above in the DSL-light version.

So, let's not forget that having a DSL doesn't mean having to use a DSL. Use your testing framework’s convenience helpers sparingly. Strive to write each test as a self-contained story.