Want to see the full-length video right now for free?
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.
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:
The [Adapter Pattern][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
[adapter-pattern]: http://www.oodesign.com/adapter-pattern.html
Use when:
Don't use when:
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.
Use when:
Don't use when
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.
Use when:
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
Use when:
Don't use when:
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][upcase-example].
Building a fake can follow these steps:
Rackfile
if you want to deploy to HerokuENV
and cattr_accessor
in adapter to set API base URI[upcase-example]: https://github.com/thoughtbot/upcase/blob/master/spec/support/fake_github.rb [capybara-discoball]: https://github.com/thoughtbot/capybara_discoball