Bulletproof your specs - Time logic

Bulletproof your specs - Time logic

You finished working on a ticket, all specs are passing locally, and you submit a PR. But today isn’t that day, the PR fails to build and it seems not only your PR but all builds are failing on that repository. You pause the music, mutter to yourself a curse and dig in the logs to discover that a set of specs contain a hard-coded date value, which is yesterday. How to prevent this? That’s what we will discover in this post.

First line of defense

We don’t work in a perfect world, we are sometimes under some pressure and tight deadlines. We will do mistakes and that’s fine because there is an important practice in place. Yes, I’m talking about PR reviews.

Most teams have a process that a PR must have 2 approvals at least to be merged. I know that some people focus more on the implementation part and skim through the testing code but let’s pay more attention during reviews :slightly_smiling_face:

Relative dates

Before typing a fixed date, take a break and analyze the consequence of it. Will this spec fail next year, what about 5 years from now? Don’t assume that the project won’t be around after 5 years. You may be surprised :wink:

Let’s start with a relative date. It’s one of the simplest approaches and could be used for different cases.

To make things easier to understand and especially to remember, I added the following timeline:

Timeline Timeline

From the timeline, “Now” points to “2021” and of course “Now + 3 years” is pointing to “2024”.

But here is the catch, the bottom part could glide back and forth using that small handler. No matter which value you are pointing out with “Now” there will be always padding of “3 years” thanks to that handler.

With this image is still vivid in your mind, let’s study the following example:

class Ticket
  def expired?
    expiration_date > Date.today
  end
end

let(:ticket) { create(:ticket, expiration_date: Date.new(2024,01,05)) }

describe "expired?" do
  context "expiration date in the future" do
    it "retrns false" do
      expect(ticket.expired?).to eq(false)
    end
  end
end

This spec is using a hardcoded and obviously will fail after 05-01-2024 and we can rewrite it to use a relative date like this:

# if you are using Rails or ActiveSupport gem
let(:ticket) { create(:ticket, expiration_date: 3.years.from_now.to_date }

# for pure Rubyist
let(:ticket) { create(:ticket, expiration_date: Date.today.next_year(3) }

And what about time? and what can we do more with Date objects?

I invite you to check the documentation for Date and Time classes, you can find different methods. I like the following ones as they are easy to remember:

 d = Date.new(2021,1,25)
 d + 1 # it adds 1 day, 26-1-2021
 d - 1 # it removes 1 day, 25-1-2021
 t = Time.new(2021,1,25,9,30,0)
 t + 1 # 25-1-2021 9:30:01, it will add only 1 second! 
 t - 1 # 25-1-2021 9:29:59, it will removes only 1 second! 

An easy way to remember these methods is to think about adding or subtracting the smallest unit:

  • Date -> a day
  • Time -> a second (for simplicity let’s not discuss microseconds)

And with this knowledge, you can get creative and start adding months, hours, and so on.

Time control

Imagine that you can stop the time and even being able to time travel as you wish. That sounds like a fantasy and it was a source of inspiration for a lot of movies and stories, but we have that power in our code.

Being able to travel to a specific date or freeze time enables us to test some edge cases. It’s an easy approach, and it would be better shown by an example:

class Car
  # We need to check this super car each 90 days
  def need_check?
    (Time.now - last_check)/(24 * 36000) > 90 # time difference is in seconds
  end
end

let(:specific_moment) { Time.new(2021, 02, 01, 10, 30) }
let(:car) { create(:car, last_check: last_check) }
subject { car.need_check? }

before do
  allow(Time).to receive(:now).and_return(specific_moment)
end

describe "#need_check?" do
  context "less than 90 days" do
    let(:last_check) { Time.new(2021, 01, 25) }
    it "returns false" do
      expect(subject).to eq(false)
    end
  end

  context "more than 90 days" do
    let(:last_check) { Time.new(2020, 10, 1) }
    it "returns true" do
      expect(subject).to eq(true)
    end
  end

  context "at 90 days exactly" do
    let(:last_check) { Time.new(2020, 11, 3, 10, 30) }
    it "returns false" do
      expect(subject).to eq(false)
    end
  end
end

I could have used Date instead of Time but I wanted to show this approach as it’s common to find it in some projects.

The core of this approach is to stub Time#now and return a Time object. Having a Time object will guarantee that it will respond to all methods that you need to use in the implementation so no extra stubbing will be needed :wink:

Gems and options (Bonus)

In case you are wondering about which gems to use, let me share what I have seen in some projects. Most old Rails or Ruby projects may include TimeCop. But if you are using version 4.1 or later of Rails, it will be mostly using ActiveSupport::Testing::TimeHelpers.

I invite you to check those options, they are so similar and easy to understand :slightly_smiling_face:

Conclusion

We saw together two effective approaches to write better specs and I wanted to share this link with you to a discourse post as food for thought.

That’s it for today, happy coding :wave: