Localized external services

Mike Burns

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!