In Progress
Unit 1, Lesson 21
In Progress

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