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
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: