Groping test tools like TypeMock exist in the realm of statically typed 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
#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
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!