Integrating with an external API is almost a guarantee in any modern web app. To effectively test such integration, you need to stub it out. A good stub should be easy to create and consistently up-to-date with actual, current API responses. In this post, we’ll outline a testing strategy using stubs for an external API.

Integrating with an External API

Our example app is a stock Rails 3.2 app. Let’s imagine that we’re assigned the following story:

In order to find interesting links for the intellectually curious As a user I want to see the latest Hacker News posts on the homepage

This story involves integrating content from an external site. Hacker News doesn’t have an API, but they do have an RSS feed that we can use instead.

The Testing Strategy

A typical implementation of our story will look something like this:

A request for the homepage is routed by Rails to a controller. The controller asks a Post model for recent posts. The Post model asks a Hacker News library for recent posts. The Hacker News library gets the latest posts from the Hacker News RSS feed.

To test this we’ll:

Start with an end-to-end integration test. I like to use RSpec, so we’ll use RSpec Rails’ request specs. vcr will be used to record the actual Hacker News HTTP request and response. Skip controller specs. Directly specifying the controller won’t gain us much, because it will be thin and completely exercised by the request spec. Mock out the Hacker News library in the Post model’s specs. Since we already have an integration test, there’s no need to perform another mini-integration test between our domain model and its collaborator. Specify the Hacker News library directly. Instead of mocking, we’ll again use vcr to record the actual Hacker News HTTP request and response. This will give us the ability in the future to change our HTTP client without breaking our specs.

Starting at the Outside with a Request Spec

Our request spec uses capybara to simulate a homepage request. We tell vcr to record any HTTP requests by using its RSpec metadata support.

spec/requests/homepage_spec.rb

require 'spec_helper' describe 'The homepage', :vcr do before do visit '/' end it 'displays recent posts from Hacker News' do hacker_news_links = all '#hn .post a' hacker_news_links.should_not be_empty hacker_news_links.each do |link| link[:href].should match(%r{http://news\.ycombinator\.com}) end end end

The first time this spec is run, vcr will record the actual Hacker News HTTP request and response and save it to the filesystem. Future spec runs will then reuse this recorded HTTP interaction. Since we don’t know the latest posts on Hacker News at the time this spec is first run, we only specify an expected DOM structure.

$ rspec spec/requests/homepage_spec.rb F Failures: 1) The homepage displays recent posts from Hacker News Failure/Error: visit '/' ActionController::RoutingError: No route matches [GET] "/" # ./spec/requests/homepage_spec.rb:5:in `block (2 levels) in <top (required)>' Finished in 0.05392 seconds 1 example, 1 failure

This failing spec guides us to the following controller and view implementation.

config/routes.rb

Sample::Application.routes.draw do root to: 'posts#index' end

app/views/posts/index.html.haml

#hn - @recent_posts.each do |post| .post = link_to post.title, post.url

app/controllers/posts_controller.rb

class PostsController < ApplicationController def index @recent_posts = Post.recent end end

The second run of the request spec goes further than the first, moving us to the domain model.

$ rspec spec/requests/homepage_spec.rb F Failures: 1) The homepage displays recent posts from Hacker News Failure/Error: visit '/' NameError: uninitialized constant PostsController::Post # ./app/controllers/posts_controller.rb:3:in `index' # ./spec/requests/homepage_spec.rb:5:in `block (2 levels) in <top (required)>' Finished in 0.07371 seconds 1 example, 1 failure

Mocking Collaborators in the Domain Model Spec

Instead of programming our controllers and views to the interface of our Hacker News library, we’ll introduce a domain model. The domain model is where we’ll consolidate our app’s business logic. It also gives us another level of indirection, allowing us in the future to change how we get Hacker News content without having to change our controllers and views.

Our domain model won’t directly make any HTTP requests. Instead, it will collaborate with a separate Hacker News library that will be responsible for making HTTP requests and parsing RSS. This library acts as an adapter layer, allowing our domain model to define its relationship to the outside world in its own terms. With an integration test already in place, we can use mocking to test our domain model in isolation.

spec/models/post_spec.rb

require 'spec_helper' describe Post do describe '.recent' do before do posts = [ { title: 'Post #1', url: 'http://example.com/1' }, { title: 'Post #2', url: 'http://example.com/2' }, { title: 'Post #3', url: 'http://example.com/3' } ] HN::Post .should_receive(:recent) .and_return(posts) @recent_posts = Post.recent end it 'returns recent posts' do @recent_posts.should have(3).posts end it 'sets their title' do @recent_posts.each do |post| post.title.should be end end it 'sets their url' do @recent_posts.each do |post| post.url.should be end end end end

lib/hn/post.rb

module HN class Post end end

app/models/post.rb

class Post def self.recent posts = HN::Post.recent posts.collect do |post| new post end end attr_accessor :title, :url def initialize(attributes) self.title = attributes[:title] self.url = attributes[:url] end end

Our domain model is now specified but a re-run of our request spec reminds us we still have work to do. Mocking allowed us to implement our domain model by faking its collaborator, but we still have to implement that collaborator. Whenever you mock, you must have a higher-level test that actually tests the full integration between all your objects.

$ rspec spec/requests/homepage_spec.rb F Failures: 1) The homepage displays recent posts from Hacker News Failure/Error: visit '/' NoMethodError: undefined method `recent' for HN::Post:Class # ./app/models/post.rb:3:in `recent' # ./app/controllers/posts_controller.rb:3:in `index' # ./spec/requests/homepage_spec.rb:5:in `block (2 levels) in <top (required)>' Finished in 0.066 seconds 1 example, 1 failure

We can now drop back down and spec out the Hacker News library.

Black-Box Testing the Library

Our Hacker News library will be responsible for getting the latest posts from Hacker News. Like in our request spec, we’ll use vcr to record the actual HTTP request and response to Hacker News. This keeps our spec accurate. Future runs of this spec will then reuse the recorded HTTP interaction. This keeps our spec fast. Stubbing at the HTTP level instead of mocking out our HTTP client, gives us the ability to change our HTTP client later (granted it’s one of vcr ‘s supported HTTP clients).

spec/lib/hn/post_spec.rb

require 'spec_helper' describe HN::Post do describe '.recent', :vcr do before do @posts = HN::Post.recent end it 'returns recently submitted posts from Hacker News' do @posts.should_not be_empty @posts.each do |post| post.should have_key(:title) post.should have_key(:url) post[:url].should match(%r{http://news\.ycombinator\.com}) end end end end

typhoeus and Ruby 1.9.3’s RSS library work together to request and parse the Hacker News RSS feed.

lib/hn/post.rb

require 'rss' module HN class Post def self.recent response = Typhoeus::Request.get 'http://news.ycombinator.com/rss' feed = RSS::Parser.parse response.body feed.items.collect do |item| { title: item.title, url: item.comments } end end end end

With our library specified, we can move back up to our request spec, which should now be passing.

$ rspec spec/requests/homepage_spec.rb . Finished in 0.1785 seconds 1 example, 0 failures

Staying in Sync with External APIs

In our request and library specs, we used vcr to record an actual HTTP request to Hacker News and then in future spec runs reused that recorded HTTP interaction. The specs were accurate and fast but, if the Hacker News RSS feed changes, our specs still continue to pass. We need to keep our stubs up-to-date.

Fortunately vcr can be configured to automatically re-record “out-of-date” HTTP interactions.

spec/support/vcr.rb

VCR.configure do |config| config.hook_into :webmock config.cassette_library_dir = 'spec/fixtures/vcr_cassettes' config.configure_rspec_metadata! config.preserve_exact_body_bytes { true } config.default_cassette_options = { re_record_interval: 1.week } end

vcr stores a timestamp alongside every recorded HTTP interaction. The above default_cassette_options configuration tells vcr to re-record all HTTP interactions that are older than 1 week.

Summary

Depending on an external API in your test suite can easily lead to tests that sometimes pass and sometimes fail. To make your tests run consistently, stub out external APIs. Ensure your stubs are accurate by creating them from actual API responses. And finally, remember to routinely verify that your stubs are up-to-date with the current state of the external API.