I’ll admit it - I’ve faked it. Sometimes, you just can’t wait for a service to finish and you just want to fake a satisfactory response. There are lots of techniques for doing this: stubs, mocks, spies, and fakes. A full fake object will require more up-front effort than a quick stub, but they can be more reusable, reliable, and fail-proof.
Keeping your tests local
Probably the first reason every developer encounters that drives them to test doubles of any kind is an external service. Writing tests that interact with a live server is bad for a number of reasons: it’s slow, it’s hard to setup your test, and your tests will likely interact with each other (or other developers’ tests). You’ll also eventually run into that frustrating situation where server downtime results in a development blackout.
Overriding methods
Ruby is extremely flexible with its class definitions, to the point that you’re allowed to reopen and append (or redefine) methods at any point. I’ve seen lots of projects where this is used to simply white out pieces of the application that developers don’t want running in tests:
# The production code
# app/models/event.rb
class Event < ActiveRecord::Base
before_validation :geocode
# ...
private
def geocode
geo = GeoKit::Geocoders::MultiGeocoder.geocode(address)
if geo.success
self.lat, self.lng = geo.lat, geo.lng
else
self.errors.add(:address, 'Unable to identify your location.')
false
end
end
end
# The override
# test/support/geocoding.rb
module GeoKit
module Geocoders
class MultiGeocoder < Geocoder
def self.geocode(location)
loc = GeoLoc.new
loc.lat = 1
loc.lng = 1
loc.success = true
loc
end
end
end
end
That little snippet lets you hit the ground running. Your tests don’t need to hit an external server, and you can test that a latitude and longitude is assigned when the record is created. However, you can’t test the negative case, and if you want to write tests for geo-spatial search, you’re out of luck.
Using stubs
If you need different geocoded results in different tests, a straight-up override won’t do it for you. At this point, a developer might turn to stubs and mocks:
describe Event do
it "should geocode a valid location" do
loc = GeoLoc.new
loc.lat = 100
loc.lng = 200
loc.success = true
GeoKit::Geocoders::MultiGeocoder.
stubs(:geocode).
with('123 Happy Street').
returns(loc)
event = Factory.build(:event, :address => '123 Happy Street')
event.save
event.lat.should == loc.lat
event.lng.should == loc.lng
end
it "should add an error message with an invalid location" do
loc = GeoLoc.new
loc.success = false
GeoKit::Geocoders::MultiGeocoder.
stubs(:geocode).
with('123 Sad Lane').
returns(loc)
event = Factory.build(:event, :address => '123 Sad Lane')
event.save
event.should_not be_valid
end
end
You’ll probably need to stub out geo locations several times throughout the test suite, so wrapping these up in helper methods helps:
describe Event do
it "should geocode a valid location" do
loc = build_geoloc(:lat => 100, :lng => 200, :success => true)
stub_geoloc!('123 Happy Street' => loc)
event = Factory.build(:event, :address => '123 Happy Street')
event.save
event.lat.should == loc.lat
event.lng.should == loc.lng
end
it "should add an error message with an invalid location" do
loc = build_geoloc(:success => false)
stub_geoloc!('123 Sad Lane' => loc)
event = Factory.build(:event, :address => '123 Sad Lane')
event.save
event.should_not be_valid
end
end
However, this leads to a few new problems:
- Most of the time you create an Event in a test, you don’t care about geocoding, so you’ll probably still need to add an override.
- Writing stubs like this in Cucumber tests is tricky - you need to make sure your stubs are torn down, and refactoring your implementation will noisily cause scenarios to fail. This isn’t what you want from an integration test.
- You can’t test the stubs themselves, and these methods are difficult to reuse, especially between projects.
Swappable components
One of the best pieces of programming advice I’ve ever received is this: “separate the pieces that change from the pieces that don’t.” Logic that doesn’t directly apply to your domain, such as geocoding and credit card processing, are likely to change. Which provider will you use? Do you need failover support? Adding the code (and tests) to your models is likely to increase churn and noise in the essential models of your application. Extracting these other concerns to external components helps reduce this noise. Writing these components so that the implementation can be swapped out will take you even further.
Luckily, in the case of GeoKit, this has all been done for you. GeoKit supports several Geocoders, and the list of Geocoders that will be used can be configured per-environment. The list of Geocoders is configured via the global “provider_order” setting:
GeoKit::Geocoders::provider_order = [:fake]
GeoKit will look for a camelized constant nested under GeoKit::Geocoders
for
each provider specified, so you can write a fake geocoder like this:
module GeoKit
module Geocoders
class FakeGeocoder < Geocoder
def self.geocode(location)
# return a GeoLoc instance
end
end
end
end
If you maintain a global registry of locations mapped to latitude and longitude, you can write steps like the following:
Given /^the following geolocations:$/ do |table|
table.hashes.each do |hash|
location = hash['Location']
lat = hash['Lat'].to_f
lng = hash['Lng'].to_f
GeoKit::Geocoders::FakeGeocoder.locations[location] = [lat, lng]
end
end
Because GeoKit allows you to swap out the geocoder implementation without changing your model code, there’s no test-specific code in your model, no overrides to look for, and no extra churn in your model if you decide to switch providers.
Keeping it fresh
One potential issue with the above faking strategy is that there are a number of globals involved: the GeoKit provider is specified globally, as is the registry of fake locations. This means that you’ll need a teardown phase to clear the registry. In this case, there’s little reason to swap out implementation at runtime or between tests. However, if you’re faking out something that changes live, these global won’t do.
When possible, I recommend accepting the external component as a parameter. If Event could take a GeoKit provider as a parameter, you could write tests like the following:
describe Event do
it "should geocode a valid location" do
fake_geocoder = FakeGeocoder.new('123 Happy Street' => [100, 200])
event = Factory.build(:event, :address => '123 Happy Street',
:geocoder => fake_geocoder)
event.save
event.lat.should == 100
event.lng.should == 200
end
end
There’s no teardown required. The FakeGeocoder
is discarded after the test
executes, so the mapped locations don’t live beyond the test.
Keeping it real
When faking out a component, make sure you adequately reproduce the actual expected behavior. When faking out payment gateways, you’ll need to cover a variety of error responses and so on. When faking out a geocoder backend, you may want to simulate timeout errors and other failures. Make sure you have enough test cases that your application will catch the edge cases that occur when using the real thing.
Apply liberally
Developers are forced to extract code into external components when it’s just not feasible to use the same code in the tests, as is the case when contacting external services. However, there are many pieces of behavior that can be extracted that don’t need to be: searching, authentication, and so on.
Although developers usually only use stubs and mocks when they need to, many developers eventually prefer the isolation, speed, and declarative nature of such doubles. If you start experimenting with fakes in place of mocks for external services, you may find that there are pieces of your code that you could swap out just to keep the churn down in your models, or to keep your tests focused on what they’re testing.
How about you? Have you ever…faked it?
What’s next
If you found this useful, you might also enjoy: