Testing APIs in Ruby: An overview

Having written a number of API clients in Ruby, I’ve often run into the problem of testing them. We can assume that the API will return the results specified in their documentation, but we’d still like to see what our API client returns; this may differ considerably from what the API itself returns.

The last thing we want to do is hit the actual endpoints: this is slow, may incur rate limiting, the server may be down at test time, endpoints may change, and it’s just not cool to hammer an external service.

This is a little round-up of approaches I’ve had success with.


WebMock is a fantastic gem for stubbing HTTP endpoints, which lets us assert that our API client sends a correct request to a server. We can also specify what the request will return and test our API client that way.

Here’s a simple example of how this might work:

stub_request(:get, "example.com/posts/1")
  .to_return(body: "{ 'id': '1' }", status: 200)

expect(ApiPost.get(1).id).to eq 1

There’s nothing wrong with this, but it quickly becomes unwieldy as the response grows in size. This can be rectified by using fixtures rather than specifying the JSON inline. Just dump actual API responses into JSON files and read them:

stub_request(:get, "example.com/posts/1")
  .to_return(body: file_fixture('posts/1.json').read, status: 200)

expect(ApiPost.get(1).id).to eq 1

For most cases, this will suffice. In some cases, though, you may want to run your fake server alongside the app in development, which isn’t possible with all of the stubbing logic in the specs.


VCR is a really cool approach. Instead of defining API responses yourself, VCR will let them go through the first time and record the response. The next time your spec runs, VCR will simply play back the previous response.

The result is really clean tests, with no manual stubbing required:

expect(ApiPost.get(1).id).to eq 1

VCR is really nifty and it may work for you, but I’m not really a fan of this approach. In cases where the API changes drastically, we’ll need to clear all existing “cassettes”, rerun specs, and pray that the endpoints still exist. What if post 1 was deleted? You’ll need to retrieve a different post and fix all relevant cassettes.

Besides, as far as I’m aware, we still can’t use our VCR cassettes alongside the app in development.

Fake Server

A third approach involves building your own little fake server to return expected responses. This allows us to sidestep any need to stub things and allows us to run our app against it in development if necessary.

I’ve previously built these with plain Rack, but the boilerplate routing logic can become pretty messy. Instead, I’d like to suggest a little gem named Cuba. Cuba gives us a simple DSL to define routes and responses, which is really all we need. It also doesn’t pull in a bunch of unnecessary dependencies.

Building the client

Let’s build a simple example. Below is our our ApiPost class. I’ve left out any error checking that you’d obviously want to use in a real class.

require 'net/http'
require 'json'

class ApiPost
  DEFAULT_ENDPOINT = 'https://schembri.me/api'.freeze

  attr_reader :id, :title, :content

  def self.get(id)
    json = JSON.parse(Net::HTTP.get(endpoint('posts', id)),
                      symbolize_names: true)

    new json

  private_class_method def self.endpoint(*path)
      [ENV.fetch('API_ENDPOINT', DEFAULT_ENDPOINT), *path].join('/')

  def initialize(attributes = {})
    @id = attributes[:id].to_i
    @title = attributes[:title]
    @content = attributes[:content]

Nothing too weird here: .get retrieves the post from the endpoint and returns an ApiPost object. Note that we first try to retrieve the endpoint from an environment variable, API_ENDPOINT, and use DEFAULT_ENDPOINT if it is not found.

Grabbing a fixture

OK, let’s define a post fixture. Normally I’d go to grab an actual API response at this point, but since we don’t have one, here’s a simple JSON file, dumped in my test app at fixtures/posts/1.

  "id": "1",
  "title": "Our post",
  "content": "Such content, very post"

Building the fake server

Next, let’s build the fake server with Cuba. I recommend taking a look at the documentation to better understand Cuba’s API when necessary, but it’s pretty simple. I’ve placed this in servers/fake_api_server.rb.

require 'cuba'

Cuba.define do
  def read_fixture(*path)
    File.read [File.dirname(__FILE__), '..', 'fixtures', *path].join('/')

  on get do
    on 'posts/:id' do |id|
      res.write read_fixture('posts', id)

# If this file is loaded from the command line, start the server
Rack::Handler::WEBrick.run(Cuba) if $PROGRAM_NAME == __FILE__

The code is pretty self-explanatory: we define a little helper to read a fixture file and define a route which returns the content of our posts/:id fixture. The :id portion of the route will be replaced with whatever we actually request, just like in Rails.

The last line is interesting, though. This is what lets us run the server simply by executing ruby servers/fake_api_server.rb in the command line.

Let’s check if everything is working as expected.

# the & just lets the server run in the background.
# I'd usually run it in a separate tmux pane or something.
> ruby servers/fake_api_server.rb &
[1] 30978
> curl "localhost:8080/posts/1"
::1 - - [26/Aug/2018:10:54:28 CEST] "GET /posts/1 HTTP/1.1" 200 79
- -> /posts/1
  "id": "1",
  "title": "Our post",
  "content": "Such content, very post"

Great! We now have a working fake server. Assuming we have a rails app that we want to run and hook up to the fake server, we can do this: API_ENDPOINT=http://localhost:8080 bundle exec rails server.

The RSpec helper

Let’s define a helper method to set up our server in the testing environment. This would usually go in some file under spec/support/helpers/, but for the sake of demonstration I’m just shoving it directly into spec/spec_helper.rb.

def with_fake_server(example)
  # Set the API endpoint to the fake server,
  # saving the current value for later
  old_api_endpoint = ENV['API_ENDPOINT']
  ENV['API_ENDPOINT'] = 'http://localhost:8080'

  # Boolean to check whether the server has started. This will
  # be flipped later.
  server_started = false

  # Start the server in a new thread, so we don't block execution
  # and can actually run our tests in this thread.
  Thread.new do
    require_relative '../servers/fake_api_server.rb'
      Logger: WEBrick::Log.new(File.open(File::NULL, 'w')),
      AccessLog: [],
      StartCallback: -> { server_started = true }

  # Wait until we know the server is ready
  sleep(0.1) until server_started

  # Run our tests

  # Switch the API_ENDPOINT back to what it was before
  ENV['API_ENDPOINT'] = old_api_endpoint

The comments should explain most of the weird stuff going on here. In the WEBrick.run call, we disable all logging to give us a cleaner test output; you might want to comment these lines out when debugging a failing test.

The main point of interest here is in the StartCallback. We pass this a lambda which is called as soon as the server starts up. If we don’t explicitly wait until the server is started up, execution of the current thread (our specs) will just carry on, and probably finish before the server ever starts up. I haven’t found a better way to deal with this than simply polling a variable, but it works just fine.

For the sake of speed, I would recommend running this in an around block rather than in each individual spec.


Time to make a little test.

require 'api_post'

RSpec.describe ApiPost do
  around &method(:with_fake_server)

  describe '.get' do
    it 'returns a valid ApiPost object' do
      api_post = ApiPost.get(1)

      expect(api_post.id).to eq 1
      expect(api_post.title).to eq 'Our post'
      expect(api_post.content).to eq 'Such content, very post'

That’s pretty much all there is to it. No stubbing needed, and we have a server which can run in development when necessary.

You can find the example project using this code on GitHub.


There are tons of ways to test external services in Ruby, but these are a few that worked for me. Let me know if there’s anything I can improve here, or if you found this post useful!