Reputation: 7314
I have a method that uses DateTime.now to perform a search on some data, I want to test the method with various dates but I don't know how to stub DateTime.now nor can I get it working with Timecop ( if it even works like that ).
With time cop I tried
it 'has the correct amount if falls in the previous month' do
t = "25 May".to_datetime
Timecop.travel(t)
puts DateTime.now
expect(@employee.monthly_sales).to eq 150
end
when I run the spec I can see that puts DateTime.now gives 2015-05-25T01:00:00+01:00
but having the same puts DateTime.now within the method I'm testing outputs 2015-07-24T08:57:53+01:00
(todays date).
How can I accomplish this?
------------------update---------------------------------------------------
I was setting up the records (@employee, etc.) in a before(:all)
block which seems to have caused the problem. It only works when the setup is done after the Timecop do
block. Why is this the case?
Upvotes: 19
Views: 6185
Reputation: 2773
TL;DR: The problem was that DateTime.now
was called in Employee
before Timecop.freeze
was called in the specs.
Timecop mocks the constructor of Time
, Date
and DateTime
. Any instance created between freeze
and return
(or inside a freeze
block) will be mocked.
Any instance created before freeze
or after return
won't be affected because Timecop doesn't mess with existing objects.
From the README (my emphasis):
A gem providing "time travel" and "time freezing" capabilities, making it dead simple to test time-dependent code. It provides a unified method to mock Time.now, Date.today, and DateTime.now in a single call.
So it is essential to call Timecop.freeze
before you create the Time
object you want to mock. If you freeze
in an RSpec before
block, this will be run before subject
is evaluated. However, if you have a before
block where you set up your subject (@employee
in your case), and have another before
block in a nested describe
, then your subject is already set up, having called DateTime.new
before you froze time.
What happens if you add the following to your Employee
class Employee
def now
DateTime.now
end
end
Then you run the following spec:
describe '#now' do
let(:employee) { @employee }
it 'has the correct amount if falls in the previous month', focus: true do
t = "25 May".to_datetime
Timecop.freeze(t) do
expect(DateTime.now).to eq t
expect(employee.now).to eq t
expect(employee.now.class).to be DateTime
expect(employee.now.class.object_id).to be DateTime.object_id
end
end
end
Instead of using a freeze
block, you can also freeze
and return
in rspec before
and after
hooks:
describe Employee do
let(:frozen_time) { "25 May".to_datetime }
before { Timecop.freeze(frozen_time) }
after { Timecop.return }
subject { FactoryGirl.create :employee }
it 'has the correct amount if falls in the previous month' do
# spec here
end
end
Off-topic, but maybe have a look at http://betterspecs.org/
Upvotes: 15
Reputation: 6555
I ran into several problems with Timecop and other magic stuff that messes with Date, Time and DateTime classes and their methods. I found that it is better to just use dependency injection instead:
Employee code
class Employee
def monthly_sales(for_date = nil)
for_date ||= DateTime.now
# now calculate sales for 'for_date', instead of current month
end
end
Spec
it 'has the correct amount if falls in the previous month' do
t = "25 May".to_datetime
expect(@employee.monthly_sales(t)).to eq 150
end
We, people of the Ruby world, find great pleasure in using some magic tricks, which people who are using less expressive programming languages are unable to utilize. But this is the case where magic is too dark and should really be avoided. Just use generally accepted best practice approach of dependency injection instead.
Upvotes: 1
Reputation: 4517
Timecop should be able to handle what you want. Try to freeze the time before running your test instead of just traveling, then unfreeze when you finish. Like this:
before do
t = "25 May".to_datetime
Timecop.freeze(t)
end
after do
Timecop.return
end
it 'has the correct amount if falls in the previous month' do
puts DateTime.now
expect(@employee.monthly_sales).to eq 150
end
From Timecop's readme:
freeze is used to statically mock the concept of now. As your program executes, Time.now will not change unless you make subsequent calls into the Timecop API. travel, on the other hand, computes an offset between what we currently think Time.now is (recall that we support nested traveling) and the time passed in. It uses this offset to simulate the passage of time.
So you want to freeze the time at a certain place, rather than just travel to that time. Since time will pass with a travel as it normally would, but from a different starting point.
If this still does not work, you can put your method call in a block with Timecop to ensure that it is freezing the time inside the block like:
t = "25 May".to_datetime
Timecop.travel(t) do # Or use freeze here, depending on what you need
puts DateTime.now
expect(@employee.monthly_sales).to eq 150
end
Upvotes: 3