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 let
s 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)
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.