Video

Want to see the full-length video right now for free?

Sign In with GitHub for Free Access

Notes

If your application uses third-party APIs, it can be complicated to write good, flexible tests that don't depend on the service being present (or don't touch production data). In this Weekly Iteration, Joël Quenneville and Chris Toomey examine strategies for establishing the boundaries between your code and external services and the tradeoffs of these approaches.

Ways to test third party API integration

There are four broad approaches to testing interactions with a 3rd party API:

1) Stub methods on the adapter 2) Swapping the "backend" 3) Stub HTTP (WebMock, directly stub HTTParty, VCR) 4) Real HTTP requests to fake services

These approach options form a matrix:

matrix of testing approaches

The Adapter Pattern

The Adapter Pattern is a way to create an interim interface between two pieces of code. In our case, we are going to use adapters around code we don't own with code we do own. An example of an adapter would be a PaymentGateway that we could use to submit credit card payments. The gateway would in turn send requests to our chosen payment service. In the event we decide to change credit card processors (say from Stripe to PayPal), the only part of our code that would change would be the adapter, as all of our payment-processing code would continue to make payment requests through the public interface of the gateway.

Here's an example of an adapter around a price-quoting service for an e-commerce application. We don't control the server that provides the prices to us, so we've wrapped the code that communicates with that service in QuoteService:

class QuoteService
  UnhandledResponseError = Class.new(StandardError)

  cattr_accessor :base_url_endpoint

  self.base_url_endpoint = ENV.fetch("QUOTE_SERVICE_BASE_URL_ENDPOINT")

  def self.fetch(item)
    new(item).fetch
  end

  def initialize(item)
    @item = item
  end

  def fetch
    response = HTTParty.post(
      self.class.base_url_endpoint + "/prices",
      body: post_parameters.to_json,
      format: :json
    )

    case response.code
    when 422
      InvalidQuote.new
    when 200..299
      price = response.fetch("pricesRequest").fetch("regularPrice")
      Quote.new(trip_id: item.id, value: price)
    else
      raise UnhandledResponseError
    end
  end

  private

  attr_reader :item

  def post_parameters
    # ..
  end
end

Stubbing Methods on the Adapter

Use when:

  • Unit testing an object that uses the adapter

Don't use when:

  • Feature testing
  • Integration testing

One way to avoid communicating with the external service in a unit test is to stub network-touching methods on the adapter around the service.

require "rails_helper"

describe ShoppingCart do
  it "calculates tax" do
    item = create(:item)
    shopping_cart = ShoppingCart.new(item)

    allow(QuoteService).to receive(:fetch).and_return(100)

    expect(shopping_cart.tax).to eq 10
  end
end

In this example the stubbing helps us keep the test focused on the class we're testing (the ShoppingCart), and to avoid concerns about collaborators. We control QuoteService, so we can assume it gets covered by its own unit tests and integration tests elsewhere. It's important that the stubbing is only on the adapter layer and does not occur on the system under test.

Stubbing HTTP

Use when:

  • Feature testing
  • You want to test up to the HTTP layer (including adapters and "backends")

Don't use when

  • You need to stub a lot of endpoints. This gets messy.

Sometimes in a feature test you want to exercise your adapter collaborator and get a canned response to your external HTTP request. This could be done by stubbing your HTTP library (HTTParty, Net::HTTP, etc.) on your own, using WebMock to stub responses to requests, or recording a real HTTP request/response with VCR and then replaying that response in future test executions.

In this example, HTTP requests are stubbed using WebMock:


require "rails_helper"

describe ShoppingCart do
  it "calculates tax" do
    item = create(:item)
    shopping_cart = ShoppingCart.new(item)

    stub_request("http://quote-service.com/prices").and_return(
      { pricesRequest: { regularPrice: 100 } }.to_json
    )

    expect(shopping_cart.tax).to eq 10
  end
end

Using VCR and recording responses can be handy if you want to anticipate change. Before the test happens with your provider, you can write VCR responses that mimic the as-yet-unreleased API response and test against it even when that API doesn't exist yet in the wild.

Swapping the "backend"

Use when:

  • You don't own the "backend" and can assume it works
  • You want to capture extra information (such as SMS messages)
  • Ideal for feature specs where we want to exercise as much of the system as possible

Sometimes it's necessary to have more interaction with requests and responses than VCR or other simpler stubbing techniques can provide. An example of this would be a test for two-factor authentication where the first request triggers the delivery of an SMS message, and then the contents of that SMS message in turn gets used in a second request to authenticate the user:

feature "signing in" do
  scenario "with two factors" do
    create(:user, password: "password", email: "user@example.com")

    visit root_path
    click_on "Sign In"

    fill_in :email, with: "user@example.com"
    fill_in :password, with: "password"
    click_on "Submit"

    last_message = FakeSMS.messages.last
    fill_in :code, with: last_message.body
    click_on "Submit"

    expect(page).to have_content("Sign out")
  end
end

We can create a FakeSMS service to provide SMS messages for the test:

class FakeSMS
  Message = Struct.new(:from, :to, :body)

  cattr_accessor :messages
  self.messages = []

  def initialize(_account_sid, _auth_token)
  end

  def messages
    self
  end

  def create(from:, to:, body:)
    self.class.messages << Message.new(from: from, to: to, body: body)
  end
end

And enable our Sms adapter, which normally talks to Twilio in production, to instead use our SMS fake in development:

class Sms
  cattr_accessor :client

  client = Twilio::Client.new

  # sms methods
end

# in our spec_helper we replace Twilio with our fake
Sms.client = FakeSMS.new

Swapping the service

Use when:

  • Feature testing
  • Creating your own sandboxes
  • You want to test all the way through the HTTP layer
  • You want custom behavior, webhooks, etc. on the service

Don't use when:

  • Unit testing

In a feature test it may be desirable to make real HTTP requests, but to make those requests against a small fake client application instead of the external service. We use this practice at thoughtbot to perform tests against GitHub-dependent features, standing up a small Sinatra application that responds to GitHub API requests. We tell our Github adapter class to use the fake GitHub instead of the real GitHub for testing. Here's an example of how we do this on Upcase.

Building a fake can follow these steps:

  • Build as Sinatra app
  • Package your fake as gem if you want to reuse (or develop it in separate repo)
  • Include a Rackfile if you want to deploy to Heroku
  • Spin the fake up with capybara-discoball
  • Use ENV and cattr_accessor in adapter to set API base URI

Extra Resources