Testing external APIs in Ruby

Note this article was published first on the asquera blog, see the post there.

Writing web services or applications that consume APIs is a common part of web development. Be it either a web service that is developed internally or by using an existing API from a 3rd party.

When writing tests against an external API one can face a number of issues

  • tests fail due to connectivity issues
  • service has a limited hit rate and responds with errors after a while
  • service does not exist yet or is incomplete
  • authentication is not possible or access is restricted
  • no development / staging server available
  • communication with the API leads to high payload or has slow response times
  • interacting with the API directly might lead to side effects in the service

To avoid these issues we want our tests to not hit any 3rd party API while still respecting the API functions available to us. Furthermore our test suite needs to pass in a repeatable manner, fast and without any side effects.

When we develop against an external API a common way to communicate with it is by using a dedicated library from either a 3rd party or by writing one ourselves. Typically there is already a ruby library available to us if the development is for a known platform such as twitter. But even when it's for an internal API it makes sense to group the functionality into a single library to provide reusability or to offer others a convenient way to interact with our API.

There are a couple of solutions available that differ depending on the context and the requirements of the API.

Example

To illustrate the different ways on how to test an API we use the following example. We use a client library to request a list of todos from an external service. First we assume there is some client that has a method to return a list of todos.

class ApiClient
  def fetch_todos
    # sends a HTTP request and returns a JSON response
    ...
  end
end

Then our initial test might look like the following:

require 'spec_helper'
describe 'ToDo API request' do
  it 'returns a list of todos' do
    client = ApiClient.new

    response = JSON.parse(client.fetch_todos)

    expect(response).to be_an_instance_of(Hash)
    expect(response['todos']).to be_an_instance_of(Array)
    expect(response['todos'].size).to > 0
  end
end

In this example an ApiClient instance is created and a list of todos is requested. The specific implementation is not important. Let's assume the ApiClient can be configured correctly and it sends a request to the external service over HTTP and responds with a JSON string.

Stubs and Mocks

The ruby community has a strong focus on testing as part of the development cycle. Among the most often used techniques are stubs and mocks. These are usually used when a class under test interacts with other parts of the system and depends on their outcomes, e.g. writing to a file, sending data to a queue etc. From the perspective of the class under test we are more interested in the interaction with the other entities as long as they behave as expected.

Stubs and mocks are a good way to accomplish this. To apply this to the above example we might want to encapsulate the client in a wrapper object, for example a class called TodoRepository.

class TodoRepository
  def initialize(client)
    @client = client
  end

  def get_todos
    @client.fetch_todos
  end
end

There are some good reasons why we want to encapsulate a client, especially from a 3rd party:

  • it minimizes the dependency & interaction with the API client, especially if it's a 3rd party library
  • if only a subset of the client functionality is used by our code we only expose what is really needed
  • use of the more general client can become more specific to our domain by not exposing details of the client. Instead of handling hashes or strings directly we can introduce domain specific objects, e.g. a ToDo class
  • our code becomes more robust against changes in the client, the wrapper acts as a safeguard
  • we gain control over logical aspects of the client, for example if the client sends a request when instantiating it, we can delay this
  • it becomes easier to test, it allows us replace the client

The example above can be adjusted to the following version where the client method is stubbed.

require 'spec_helper'
describe TodoRepository do
  it 'returns a list of todos' do
    todos  = [{ 'subject' => 'A to do for today' }]
    client = ApiClient.new
    allow(client).to receive(:fetch_todos).and_return(todos)

    response = TodoRepository.new(client).get_todos

    expect(response).to eq todos
  end
end

This is a partial stub, it creates a concrete intsance of the ApiClient class and stubs the method fetch_todos which returns a list of hashes. This might not be feasible for example if a HTTP connection is opened in the class constructor already. The TodoRepository.get_todos method could also return a list of Todo objects to be more domain specific. It might also handle client specific exceptions in a consistent manner and throw domain specific ones.

Another way is to use a test double (mock) as the client.

require 'spec_heler'
describe TodoRepository do
  it 'returns a list of todos' do
    todos  = [{ 'subject' => 'A to do for today' }]
    client = double('client', :fetch_todos => todos)

    response = TodoRepository.new(client).get_todos

    expect(response).to eq todos
  end
end

This injects the mock into the TodoRepository object. It requires that the public methods (its interface) which are accessed conforms to the one from the ApiClient class. If they differ it can lead to passing tests while the real client would fail, therefore it is necessary to update the client's interface.

As we can see stubs and mocks are useful when we are able to isolate the client. Sometimes this cannot be easily done, for example if the use of the client library is spread over a lot of different locations or we have to deal with an existing code base where changes cannot be easily introduced or refactoring takes a lot of time. If we develop the API client ourselves stubs and mocks are a good way to test the interaction within our application, but we still would like to test the interaction with the external API directly and therefore need other techniques as well.

Using stubs and mocks for a 3rd party client might become a tedious task, especially if the behavior is too complex or too many public API functions are used. Then mocking the client or stubbing its methods can lead to a lot of effort and maintenance work. There are a couple of projects specifically designed to offer fake implementations of existing libaries for use in test environments, e.g. see fakeredis or pusher-fake. These fake implementations behave exactly as the original clients but can be used in tests without depending on external services.

WebMock

Another approach to stub requests and responses is to use the WebMock gem. It is specifically designed to stub HTTP requests in Ruby and works with all common HTTP libraries and test frameworks. WebMock exchanges specific HTTP requests that matches certain criteria, e.g. request type, URL, query arguments, headers and returns a defined HTTP response. Instead of sending a HTTP request to the external service, a defined response is returned locally. Stubbed requests work on the communication layer where HTTP requests and responses are handled.

The first thing we can do when using the WebMock framework is to disable all net connections but the localhost.

# in spec_helper.rb
require 'webmock/rspec'
WebMock.disable_net_connect!(:allow_localhost => true)

This raises exceptions for all net connections, still allows communcation via localhost and is a good way to find out what requests are send to external services in our tests. An exception also provides details of how the specific request can be stubbed.

WebMock::NetConnectNotAllowedError:
[...]
You can stub this request with the following snippet:

stub_request(:get, "http://www.example.com/todos").
    with(:headers => {
           'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
           'Content-Type'=>'application/json',
           'User-Agent'=>'Faraday v0.9.1'
         }).
    to_return(:status => 200, :body => "", :headers => {})

This stubs the HTTP request (type GET) for the URL http://www.example.com/todos. Let's stub the request in the test of our example.

describe ApiClient do
  before do
    body = '[{ "subject": "a todo for you" }]'
    response = { :body => body, :status => 200, :headers => {} }
    stub_request(:any, /www.example.com/).to_return(response)
  end

  it 'returns a list of todos' do
    client = ApiClient.new(:host => 'www.example.com')
    response = client.fetch_todos

    expect(response).to eq response
  end
end

This maps the HTTP request of the ApiClient when it calls the host www.example.com to the stub and returns the defined response. The :any request type means all request types are stubbed (POST, PUT, DELETE, etc). This technique also provides a good way to check error responses. It can be complemented by a list of fixtures for different requests, where a stubbed request loads a JSON string from a file and returns it. A comprehensive and informative example of how an API can be tested with stubbed requests and fixtures is the twitter gem. Furthermore stubbing requests can also be used to build a client that acts correctly when an API function returns different status codes for various query arguments or supports different formats.

WebMock is a powerful library when developing a client against an (external) API or when the code has a lot of dependencies to different services. In the former case it makes no sense to stub the client directly, because it is the object under test. In other cases stubbing everything might be tedious or error prone, because a lot of complexity is involved to produce a response. Therefore it can be advantageous to stub HTTP requests with WebMock and to serve fixtures.

VCR

Another popular library when testing HTTP dependencies is the VCR gem. VCR records the responses of HTTP requests once and replays them later when the same request happens again. The responses along with the requests are stored in so-called cassettes. The first time a cassette is recorded the HTTP request hits the external API. Once the recording is stored the same HTTP request will replay the recording and serve the response.

This allows a couple of things:

  • improves test speed with recordings
  • allows to run the tests repeatedly and offline (assuming no new requests are made)
  • contains the same headers and body as the real request
  • reflects HTTP communication most closely

VCR has support for all common HTTP libraries and test frameworks and can be easily configured.

# spec_helper
require 'vcr'

VCR.configure do |c|
  c.cassette_library_dir = 'fixtures/.cassettes'
  c.hook_into :webmock
end

The given configuration stores the cassettes in fixtures/.cassettes and uses WebMock to stub the HTTP requests with the recordings. It integrates well with WebMock and supports metadata information useful when specifying tests.

To test our previous example with VCR add a metadata argument to the test description:

require 'spec_helper'

describe ApiClient, :vcr => true do
  it 'returns a list of todos' do
    client = ApiClient.new(:host => 'www.example.com')
    response = JSON.parse(client.fetch_todos)

    expect(response).to be_an_instance_of(Hash)
  end
end

When the test runs for the first time our ApiClient hits the real external API, records the response and when running subsequent tests replays it.

VCR comes with a couple of features which are very useful when developing a client for an external API. One useful feature is to see if there are any unused recordings. To raise an error and see which recordings are not used, instead of setting :vcr => true provide the argument :vcr => { :allow_unused_http_interactions => false }. It lists all HTTP requests for which a recording exists but are not called from the test suite.

VCR supports different record modes that define how requests are recorded and replayed. The default mode :once will replay previously recorded requests, record new ones for which no cassette is available, but will raise an error if there is a new request for which a cassette already exists. The latter case can happen if different test cases stub the same request again and is a good indicator where to group tests to use the same setup. The mode :new_episodes will record all new HTTP requests as well. The :none mode rejects all new HTTP recordings not previously recorded and will raise an error otherwise.

Sometimes an external API changes, for example a path changes or the response differs. In these cases the previously recorded VCR cassettes would still pass all tests. It is therefore good practice to regularly update the cache and record the requests again. One way to do this is to specify the option re_record_interval in the configuration:

VCR.configure do |c|
  # ...
  c.default_cassette_options = {
    re_record_interval: 60 * 30  # in seconds
  }
end

Another option is to set the record mode to :all temporarily which re-records everything.

When the VCR cassettes are checked into the version control systems such as git, no sensitive data (username, password) should be stored inside the recordings. A good way is to filter sensitive data from the recordings and have them replaced with real values before sending any requests. For example during the VCR configuration set the following filter entries:

VCR.configure do |c|
  # ...
  c.filter_sensitive_data('<USERNAME>') { ENV['API_USERNAME'] }
  c.filter_sensitive_data('<PASSWORD>') { ENV['API_PASSWORD'] }
end

This configuration replaces <USERNAME> and <PASSWORD> with values from environment variables. It is suggested to use enclosing brackets for better indication of which variables need to be replaced. If everything is set up correctly the cassettes contain the placeholders instead of the user's credentials.

VCR is a good option when hitting the external API from the test suite is not a problem, e.g. staging server. If the client or the API is still in development, VCR recordings might be a good choice between running tests in a fast and repeatable manner while still recognizing changes.

Fake Service

The last option described here is how to use a fake service to test an external API. A fake service is similar to a mock in a way where all requests are stubbed. But instead of stubbing requests inside the tests a small web application is started and HTTP requests are redirected to it. In this way the fake service mimics the external API in its behavior and responses, substituting the real service with a version running locally. This means building a fake application takes a bit more development effort and maintainance work then stubbing requests, but the tests become cleaner. It also works nicely with the other described options.

A fake application might be a good fit for the following reasons

  • only a small subset of the external API is used
  • the external API is stable enough (e.g. versionised), consistent and well understood
  • the API is well documented with samples, but not available fully or still in development
  • external API is a RESTful service
  • adjusting the existing test suite means a lot of effort, due to extensive calls to external APIs
  • test data needs to be set up and you want to have control over it

We use the web framework Sinatra to write a fake application. It is easy to set up and offers a simple DSL for writing a web application. In combination with fixtures a web service can serve responses that are read from file in a convenient way.

require 'sinatra/base'

class FakeTodoService < Sinatra::Base
  get '/todos' do
    get_response 'todos.json'
  end

  private

  def get_response(filename, status_code = 200)
    content_type :json
    status status_code
    load_fixture(filename)
  end

  def load_fixture(filename)
    File.open(File.join('fixtures', filename), 'r').read
  end
end

The fixture for the /todos API endpoint looks like

{
  "todos": [ {
    "subject": "a todo from a fixture"
  } ]
}

This defines an application (FakeTodoService) with a single resource path /todos (as a GET request) that returns the content of the JSON file todos.json. Mimicking the paths and resource handling of the external API in this way enables us to define fixtures for all API endpoints. Instead of matching the public interface of a client, a fake service matches the API interface of the service. Defined in this way the client used in our application does not need any modifications or adjustments to communicate with our fake service. But this requires a good understanding of the external API either by having good documentation or well defined responses. With Sinatra we are also able to parse URI queries or HTTP headers accordingly, e.g. to support pagination or setting headers such as Content-Type to request different document types. To use the fake application in our tests we need to configure RSpec accordingly.

# in spec_helper.rb
RSpec.configure do |c|
  # [..]
  c.before(:each, :api => :external) do
    stub_request(:any, /www.example.com/).to_rack(FakeTodoService)
  end
end

This redirects all HTTP requests that would hit www.example.com to our fake application and only activates the service when tests are marked with the :api metadata tag. By marking tests with metadata we ensure that only those tests are run against the fake service. They are also useful to group tests with similar requirements.

Our test example then looks as follows:

require 'spec_helper'
describe 'ToDo API request', :api => :external do
  it 'returns a list of todos' do
    client = ApiClient.new(:host => 'www.example.com')

    response = JSON.parse(client.fetch_todos)

    expect(response).to be_an_instance_of(Hash)
    expect(response['todos']).to be_an_instance_of(Array)
    expect(response['todos'].size).to > 0
  end
end

This returns the JSON response from the fake application when calling the /todos path. As you can see this version matches very closely our initial test.

There are a couple of things to be aware of when introducing a fake service

  • it increases the maintainance overhead
  • the fake service can become quite complex in itself, a problem similar to mock objects
  • extending the scope / usage of the client might require unobvious adjustments in the fake service
  • when the external API changes, the expected behavior of the external API and the actual behavior of the fake service might differ, resulting in false positives
  • when it's easier and cleaner to stub a request in the tests WebMock is often the better choice, especially if the API request returns different responses for different query arguments
  • when using JRuby it might slow down the initial start up time

The main advantage of a fake service is that it can get introduced at any time and the existing test suite does not require all too many modifications. Once in place refactorings are easier to do and can be later substituted with stubbed requests via WebMock. It can be mixed easily with the other approaches described above, for example while the majority of API tests use the fake service, some requests can still be stubbed with WebMock or the other way around. It is a useful tool for certian situations.

Conclusion

Writing applications that use external APIs is a common use case when developing services. Depending on the context, complexity and control in regard to the service and the client we have different options of how to test the API.

Developing an internal client for an external API offers the chance to write it in a way where we can easily mix WebMock, VCR or a fake service. On the other hand if we use a 3rd party client to communicate with an external service it is best to encapsulate the functionality and only expose what is really needed. Testing external APIs also becomes easier when the used client is a popular library that is already extensively tested. In some cases we have specific mock libraries available to us that have the exact same API and mimick the functionality without relying on HTTP communication directly. A fake service is useful when it is easy to serve defined responses as for RESTful APIs or it's easier to introduce than to adjust the existing test suite. Alternatively VCR can be used if the logic of the external API is more complex and if it's ok to send HTTP requests every now and then.