Monday, March 05, 2007

Overriding Time.now for Rails testing

When testing in Rails or otherwise, there are times when you need to test time-sensitive, or time-related methods. Before today, I had a hard time finding a good solution to that.

Let's say that you are a person that loves your friends, and the more time that passes, the more you adore them. You'd want to test the love you give a friend grows over time. So you might have something like this:
class Friend
def love
Time.now - @when_first_met
end
end

def test_love_grows_over_time
friend = friends(:jon_lee)
love_now = friend.love

# do something here to shift time forward
# so that Time.now gives a time in the future

love_later = friend.love
assert love_later > love_now
end

A tip here from Rails Studio inserts a wrapper class around Time, and then proceeds to use their wrapper class MyTime in the rest of their application. This is certainly easier to write, but not always possible. There could be methods that uses Time.now in it that you don't want to rewrite (read: don't want to mess with) in a library that you need to use in the tests of your application. And would you really want to replace all instances of Time.now in every library that you use? Search and Replace!

In Ruby, all classes are open, and you can dynamically add methods to both objects and classes. This means that the importance of the wrappers is rather diminished--unless you don't like the idea of a class being open. Because of this example and others, I'm more convinced that design patterns are signs of weaknesses in a language.

So! I know that in Ruby, you should be able to somehow dynamically override Time.now, in order to run friend.love again. You could override Time.now completely, but accordingly, that messes up times in the Unit testing, so tests look like they end before they begin.

In my searches on google, I happen to come across this thread on Ruby talk, which in turn, had a post to Jim Weiriches's OSCON 2005 slide:

def test_warmer
Warmer.use_class(:Heater, MockHeater) do
# Here, anytime a method in Warmer references Heater
# it will get a MockHeater class instead.
end
# Here Warmer is back to normal.
end

Wow, this is pretty neat. What use_class does, is allow you to create a scope where the Heater class is replaced by MockHeater! All classes still use Heater, but Heater is actually replaced with MockHeater within this scope. When the code execute goes back outside of the block, Heater is returned to the original Heater, and not the MockHeater. He achieves this by using a class proxy. You can see the code here.

This is pretty much what I need, so that I'd be able to replace Time with a MockTime. However, as it was written, it doesn't work with Time.now, since now() is a class method. With the way it was implemented, the ClassProxy does not know anything about an object's class methods.

I originally was messing around for hours on how to dynamically add class methods, but I'll spare you the details. What ended up being way easier was just to use method_missing() and pass on any method ClassProxy doesn't know what to do with to the proxied class. So you end up with an extra private method.
class ClassProxy
attr_accessor :proxied_class

def initialize(default_class)
@proxied_class = default_class
end

def new(*args, &block)
@proxied_class.new(*args, &block)
end

private
def method_missing(method_sym, *args)
@proxied_class.send(method_sym, *args)
end
end

So now, you should be able to do this in your test:
class MockTime
def self.now
Time.now.in 2.days
end
end

def test_love_grows_over_time
friend = friends(:jon_lee)
love_now = friend.love

Friend.use_class(:Time, MockTime) do
love_later = friend.love
assert love_later > love_now
end
end

And it should pass! Tip!

5 comments:

  1. Anonymous4:49 PM

    Thanks for going over this, but I've had success with just doing this from a test method:

    Time.class_eval do
    @time = Time.parse("12:30 AM")
    def self.now
    @time
    end
    end

    Am I missing something?

    ReplyDelete
  2. Anonymous5:09 PM

    Ahh, I see what I was missing. Corrupting test times and such.

    ReplyDelete
  3. Yeah, I'm not sure corrupting test times is really that big of a deal. It may be using a sandblaster on a cracker, but it was a first foray into metaprogramming for me. I thought it was a pretty neat thing also, that someone else might be able to find some other use for it.

    ReplyDelete
  4. I realize this is a very old blog post, but just in case you haven't found IMHO the total solution to your problem (needing to get Time.now == Time.now to evaluate to true for testing purposes) I thought I would chime in.

    It sounds like you are trying to "stub" Time.now. If I were you I would use mocha (if you are still using Test::Unit) or use the fully integrated BDD framework, rSpec.

    Both mocha and rspec are hosted on Rubyforge.

    To "stub" the now class method on Time in rSpec you would do:

    Time.stub!(:now).and_return(stored_time)

    In mocha (though it has been a while since I used it so not sure of exact API) you could do:

    Time.expects(:now).returns(stored_time)

    ReplyDelete
  5. Thanks, I just heard about rSpec, and mocha is now finally on my radar thanks to you. I'll take some time to look at it and make the slow migration...

    ReplyDelete