Groping test tools and their effect on object oriented design

17 February 2013

Groping test tools like TypeMock exist in the realm of statically compiled languages to allow you to bypass encapsulation and access private members of the objects you’re testing. In the Ruby world we’ve got metaprogramming and the #send method which can pretty much do the same thing, but in a deceptively simple way.

These tools have a place in our arsenal. If I’m testing a codebase that hasn’t considered object oriented best practices, it’s useful to have tools like Timecop that can give control over Ruby’s global date and time, or FakeWeb that intercepts HTTP calls. But if you’re using these tools on a fresh codebase, you’re violating object oriented best practices.

Just because you have a hammer, doesn’t mean that you should treat every problem as a nail. These ‘groping’ test tools have a tendency to be that hammer. We use encapsulation and information hiding to provide a cleaner API to consumers, but if we need to access private state from tests, then an argument can be made that the information should be available at a different scope, and that there’s a flaw in the design of the API.

Consider the testability of the following snippet. Notice the hard dependency on Time.zone.now - which is an indeterministic function that accesses a global state.

class Post < ActiveRecord::Base
  def publish!
    update_attributes(published_at: Time.zone.now)
  end

  def published?
    published_at && published_at < Time.zone.now
  end
end

To test this, we use a groping test tool - Timecop - to modify Ruby’s date and give us control. Timecop does this by using some of Ruby’s metaprogramming features.

describe Post do
  describe "#publish!" do
    it "sets the published at timestamp" do
      date = "2013-01-01".to_date
      Timecop.freeze(date)

      subject.publish!
      subject.published_at.should == date

      Timecop.return
    end
  end
end

My problem with this style of testing is that there’s no definition of intent. Reading the test, there’s no link between freezing the time and the command to publish the post. It’s implied, but it’s not clear. We’re changing something in a global state that just happens to have an effect on the published_at timestamp. It’s a side-effect. What’s more, if you forget to Timecop.return afterwards, you’ve infected the global state of DateTime with a trait that will be shared across other tests. If you’ve ever seen ridiculous profiles on run times in RSpec - that’s probably because you’ve forgotten to Timecop.return, and the Ruby global date is still in an altered state.

Allowing the dependency on time to be injected from outside of the method call gives us flexibility in testing. It gives us full control of a variable that is otherwise in a global, uncontrolled state.

Let’s refactor that first example a bit:

class Post < ActiveRecord::Base
  def publish!(clock = Time.zone)
    publish_at clock.now
  end

  def publish_at(time)
    update_attributes(published_at: time)
  end

  def published?(clock = Time.zone)
    published_at && published_at < clock.now
  end
end

Now #publish! depends on any object that responds to #now. So when we’re working in a test environment we can stub that out with OpenStruct.

describe Post do
  describe "#publish!" do
    it "sets the published at timestamp" do
      clock = OpenStruct.new(now: Time.zone.now)
      subject.publish!(clock)
      subject.published_at.should == clock.now
    end
  end
end

In production, thanks to Ruby’s default parameter values, we can still maintain an easily readable API without having to pass Time.zone in everywhere. Just call post.publish!

I far prefer this design since we’re keeping a similar level of abstraction at each method. We consider publishing to be the act of setting the published_at field, but there’s only one place that actually encapsulates that in code - in the publish_at method. This is evidence of following DRY.

While I prefer this design, there’s another level refactoring we can do in this code around the dependency on Time.zone.now and determining whether a post is published or not, but I’m going to leave that for a future post. Stay tuned!