Unroll Test Loops
Be wary of the temptation to DRY up testing code. As you’ll see in this episode, a refactoring that might add value to to application code can actually make your tests less useful!
Video transcript & code
Today I have a small suggestion about testing style, drawn from a real-life pairing session.
Here’s some test code for an Account
class.
It sets the check_in_reminder_hour
attribute to a succession of different test values. Each time, it verifies that the check_in_reminder_time
string is generated in a particular expected format.
RSpec.describe Account, type: model do
describe "check_in_reminder_time_attribute" do
it "translates check_in_reminder_hour to a string" do
account = Account.new
account.check_in_reminder_hour = 3
expect(account.check_in_reminder_time).to eq("3:00AM")
account.check_in_reminder_hour = 17
expect(account.check_in_reminder_time).to eq("5:00PM")
account.check_in_reminder_hour = 0
expect(account.check_in_reminder_time).to eq("12:00AM")
end
end
end
When I see code like this, often I’ll be tempted to rewrite it with a loop. Something like this:
RSpec.describe Account, type: model do
describe "check_in_reminder_time_attribute" do
it "translates check_in_reminder_hour to a string" do
account = Account.new
[[3, "3:00AM"], [17, "5:00PM"], [0, "12:00AM"]].each do
|hour, expected_time|
account.check_in_reminder_hour = hour
expect(account.check_in_reminder_time).to eq(expected_time)
end
end
end
end
This change DRYs up the repetition in the original test.
But I’ve learned to resist this temptation, and I want to talk about why.
If and when this test fails, the first thing we’re going to see is a line number for the failure.
If click through to see where the test failed, what do we see?
Well what we don’t see is exactly which combination of input and expectation resulted in a failure. For that, we have to refer back to the test failure message.
In some cases, when the test failures are less communicative than this one, we may have to drop some diagnostics in to see which iteration of the loop we’re on when it fails.
RSpec.describe Account, type: model do
describe "check_in_reminder_time_attribute" do
it "translates check_in_reminder_hour to a string" do
account = Account.new
[[3, "3:00AM"], [17, "5:00PM"], [0, "12:00AM"]].each do
|hour, expected_time|
p hour
p expected_time
account.check_in_reminder_hour = hour
expect(account.check_in_reminder_time).to eq(expected_time)
end
end
end
end
And what if we want to temporarily narrow the test to just the failing scenario?
We have to do some finicky editing of the loop data.
RSpec.describe Account, type: model do
describe "check_in_reminder_time_attribute" do
it "translates check_in_reminder_hour to a string" do
account = Account.new
[[17, "5:00PM"]].each do
|hour, expected_time|
p hour
p expected_time
account.check_in_reminder_hour = hour
expect(account.check_in_reminder_time).to eq(expected_time)
end
end
end
end
Compare that to the original version of our test.
RSpec.describe Account, type: model do
describe "check_in_reminder_time_attribute" do
it "translates check_in_reminder_hour to a string" do
account = Account.new
account.check_in_reminder_hour = 3
expect(account.check_in_reminder_time).to eq("3:00AM")
account.check_in_reminder_hour = 17
expect(account.check_in_reminder_time).to eq("5:00PM")
account.check_in_reminder_hour = 0
expect(account.check_in_reminder_time).to eq("12:00AM")
end
end
end
If we know the failure was on this line, then we know immediately that it was for the “hour 17” case.
If we want to narrow it to that case, we don’t even have turn our brains on. We can do this kind of narrowing without thinking.
RSpec.describe Account, type: model do
describe "check_in_reminder_time_attribute" do
it "translates check_in_reminder_hour to a string" do
account = Account.new
# account.check_in_reminder_hour = 3
# expect(account.check_in_reminder_time).to eq("3:00AM")
account.check_in_reminder_hour = 17
expect(account.check_in_reminder_time).to eq("5:00PM")
# account.check_in_reminder_hour = 0
# expect(account.check_in_reminder_time).to eq("12:00AM")
end
end
end
And that’s today’s tip in a nutshell: don’t be too eager to DRY up duplication in tests, especially if the refactoring involves a loop. It’s more valuable to have a test that consists of straight-line code, so we can focus our brainpower on why it’s failing, not on how the test works. The more concrete and immediate our input values values are to the scene of the failure, the lower the friction in addressing the issue.
Happy hacking!
Responses