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.