I recently had an interesting bug that I want to share.
We had an age method in the User model, that was implemented like this:
def age return unless birthday now = Time.now.utc.to_date now.year - birthday.year - (birthday.to_date.change(year: now.year) > now ? 1 : 0) end
And the following test for it:
describe :age do it 'should calculate age on exact date' do user = record(birthday: '2000-10-10') Timecop.freeze(Date.parse('2010-10-10')) do user.age.should == 10 end end it 'should calculate age on next date' do user = record(birthday: '2000-10-10') Timecop.freeze(Date.parse('2010-10-11')) do user.age.should == 10 end end it 'should calculate age on prev date' do user = record(birthday: '2000-10-10') Timecop.freeze(Date.parse('2010-10-9')) do user.age.should == 9 end end end
Suddenly, on 2013-07-14, I received a Circleci email that my last commit broke the specs. While investigating I found out that it failed in a place completely unrelated to my latest changes. It failed with an ArgumentError: invalid date. WTF?!
Investigating I found that we had a typo in one of our fixtures, that went like this:
triton: name: Triton birthday: <%= 501.days.ago %> ...
Notice the days instead of years that were ment to be used. And 501 days before 2013-07-14 is 2012-02-29, a leap year extra day, oops ;)
The age implementation tried to do .change(year: 1) to 2012-02-29 which produced an invalid date 2013-02-29. Apparently Date#change wasn't smart enough to take care of that:
> d = Date.parse('2012-02-29') => Wed, 29 Feb 2012 > d.change(year: 1) ArgumentError: invalid date
I changed tirton's age to 501 years, and added the following test:
it 'should not fail when birthdate is on feb-29' do user = record(birthday: '2012-02-29') Timecop.freeze(Date.parse('2013-05-01')) do user.age.should == 1 end end
and fixed the implementation to be like so:
def age return unless birthday now = Time.now.utc.to_date diff = now.year - birthday.year diff - (diff.years.since(birthday) > now ? 1 : 0) end
ActiveSupport's Date#since is smarter then #change and handles invalid dates properly:
> d = Date.parse('2012-02-29') => Wed, 29 Feb 2012 > 1.year.since(d) => Thu, 28 Feb 2013
Also note that there will be no problem with user fixtures on Feb-29 of the next leap year 2016-02-29. The x.years.ago that is used for birthdays will work just fine.