raphael_turtle
raphael_turtle

Reputation: 7314

Rails - Testing a method that uses DateTime.now

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

Answers (3)

amiuhle
amiuhle

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

EugZol
EugZol

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

jkeuhlen
jkeuhlen

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

Related Questions