Common situation: you need to hit an external API. Common solution: mock it out in your tests using Mocha or WebMock. Common result: does not match exactly what will happen.
Why
See, the issue we’ve run into with method-level mocking is that we have to know exactly which methods will be called and how Net::HTTP will be used at a low level. This brittleness becomes apparent when we move from e.g. Net::HTTP to HTTParty. The typical brittleness of refactoring, plus it doesn’t even do spies or support mocking POST request params!
Another issue is that of simply wrapping our heads around things: using FakeWeb—a Net::HTTP stub helper—requires repetitive code in our setups, files all over the filesystem with our expected results, and poor encapulation in general.
Let’s get it closer to reality and practicality using ShamRack.
What
We’ve used this for real apps. Some example services: Facebook, OAuth, OpenID, credit card processing.
How
ShamRack mounts a Rack app locally, just for your tests. It goes one further: it “mounts” it using Net::HTTP such that requests to the Rack app never hit any network. Not even the local one. It’s sneaky like that.
The example we’ll work with here is: you are writing an app that needs to display the current copyright on the Web page, at all times. You can’t trust your local server for this either; you need to hit an external date service.
This external date service has an API so let’s write a test to make sure it exists:
date_server/features/get_date.feature
:
Feature: Getting a date
Scenario: Request the current date
Given the date is "2010-05-13"
When I GET to "/date"
Then I receive a HTTP 200
And I should see "//dates/date[@now='2010-05-13']" in the response XML
The step definitions for this are fairly interesting:
date_server/features/step_definitions/api_steps.rb
:
require 'nokogiri'
Given 'the date is "$date"' do |date_string|
Date::DateStore.set_date_to(Date.parse(date_string))
end
When 'I GET to "$path"' do |path|
url = URI.parse("http://#{DATE_HOSTNAME}#{path}")
request = Net::HTTP::Get.new(url.path)
@response = Net::HTTP.new(url.host, url.port).start do |http|
http.request(request)
end
@parsed_response = Nokogiri::XML(@response.body)
end
Then 'I receive a HTTP 200' do
@response.code.to_i.should == 200
end
Then 'I should see "$xpath" in the response XML' do |xpath|
@parsed_response.xpath(xpath).should_not be_empty, "could not find #{xpath} in: #{@response.body.inspect}"
end
We parse the response using Nokogiri and check the response using
#xpath
. But we’re doing a Net::HTTP request; we need ShamRack
mounted:
date_server/features/support/env.rb
:
DATE_ROOT = File.join(File.dirname(__FILE__), '..', '..')
RAILS_ROOT = File.join(DATE_ROOT, '..')
DATE_HOSTNAME = 'example.com'
$:.unshift(File.join(RAILS_ROOT, 'vendor', 'gems', 'crack-0.1.6', 'lib'))
$:.unshift(File.join(RAILS_ROOT, 'vendor', 'gems', 'sham_rack-1.2.1', 'lib'))
$:.unshift(File.join(RAILS_ROOT, 'vendor', 'gems', 'sinatra-0.9.4', 'lib'))
require 'sham_rack'
require 'spec/expectations'
require File.join(DATE_ROOT, 'dates')
Dir[File.join(DATE_ROOT, 'lib', '*.rb')].each do |filename|
require filename
end
ShamRack.at(DATE_HOSTNAME).rackup do
run DateServer
end
After do
Date::DateStore.clear!
end
Lots of mention of Date::DateStore
now, including
#clear!
and #set_date_to
methods.
date_server/lib/date_store.rb
:
class Date::DateStore
@@the_date = nil
def self.set_date_to(a_date)
@@the_date = a_date
end
def self.current_date
Date.parse(@@the_date.to_s)
end
def self.clear!
@@the_date = nil
end
end
This class is namespaced so you don’t confuse it with anything in the main app. This bit me so watch out for it.
We can change to the date_server/
directory and run
cucumber
but it’s more fun to have a profile for it:
config/cucumber.yml
:
dates: --format progress --strict --tags ~@wip date_server/features
So now we can run: cucumber -p dates
Cool but now the date server tests are failing. Make ‘em pass with Sinatra!
date_server/dates.rb
:
require 'builder'
require 'sinatra'
require 'crack'
class DateServer < Sinatra::Base
get "/date" do
current_date = Date::DateStore.current_date
status 200
builder do |xml|
xml.instruct!
xml.dates do |dates|
dates.date :now => current_date.strftime('%Y-%m-%d')
end
end
end
end
Sweet, we have a date server! It uses XML because XML is a solution to any problem, even one that doesn’t exist.
So back in the main app we can write our Cucumber test:
features/view_home_page.feature
:
Feature: Look at the home page
Scenario: User views the home page
Given the date server is set for "2008-04-16"
When I go to the home page
Then I should see "Copyright 2008"
Most of that test uses Webrat but the setup pokes into the
Date::DateStore
.
features/step_definitions/date_server_steps.rb
:
Given 'the date server is set for "$date"' do |date_string|
Date::DateStore.set_date_to(Date.parse(date_string))
end
Well that needs the Date::DateStore
class, so load 'er up!
features/support/dates.rb
:
require 'date_server/lib/date_store'
require 'date_server/dates'
ShamRack.at(DATE_HOSTNAME).rackup do
run Sinatra::Application
end
Before do
Date::DateStore.clear!
end
The rest is making the tests pass:
config/routes.rb
:
ActionController::Routing::Routes.draw do |map|
map.root :controller => 'homes', :action => 'index'
end
app/controllers/homes_controller.rb
:
class HomesController < ApplicationController
def index
current_date = DateModel.current
render :text => "Copyright #{current_date.year}"
end
end
app/models/date_model.rb
:
class DateModel
def self.current
url = URI.parse("http://#{DATE_HOSTNAME}/date")
res = Net::HTTP.start(url.host, url.port) {|http| http.get(url.path)}
parsed_xml = Nokogiri::XML(res.body)
Date.parse(parsed_xml.root.at('date').attribute('now').value)
end
end
Yeah
That Sinatra app is re-usable now, for your other projects that need a date
server. You can bundle it as a gem, package step definitions with it, write a
hook to run the tests for it while running your test suite, or to run it when
you run script/server
. The encapsulation opens you to more possiblities!