Rails - Testing a method that uses DateTime.now
Asked Answered
S

3

19

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?

Sclerosed answered 24/7, 2015 at 7:27 Comment(4)
Your code should work (just remember to call Timecop.return when you are done). Can you please paste the body of the monthly_sales method?Appurtenant
the method is irrelevant other than it calls DateTime.nowSclerosed
Then I'm afraid I can't help you.Appurtenant
@Sclerosed Have you had a chance to look at my answer?Accomplice
C
15

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/

Cyclone answered 28/7, 2015 at 16:7 Comment(3)
If expect(employee.now).to eq t passes then Timecop is working properly. Have you tried your spec inside a Timecop.freeze block?Cyclone
it works inside a freeze block but only when the data is setup within itSclerosed
In that case you're not creating the DateTime in monthly_sales, but in initialize? If so, try to set up Timecop in before and after hoos. I've updated my answer.Cyclone
A
3

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
Accomplice answered 27/7, 2015 at 16:13 Comment(1)
both of these still failSclerosed
R
1

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.

Rhodium answered 2/8, 2015 at 18:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.