Groping test tools and their effect on object oriented design
17 February 2013
A “Groping” test tool allows you to break encapsulation, and access private members of an object.
Tools like TypeMock Isolator exist in the realm of statically compiled languages
to allow you to do this in testing. In Ruby, 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 applied software design best practices like SOLID, 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!
expect(subject.published_at).to eq 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)
expect(subject.published_at).to eq 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!