Testing API Requests with XHR and sinon.js

This is the first article in a two-part series on API testing. If you’re in a hurry, skip on over to the sample project on Github; everyone else, read on!

Clients are nothing without their servers, but necessity doesn’t always make for an easy relationship. Security considerations and the molasses-slow evolution of web standards across the major browsers mean that most applications are still interacting with the HTTP world through the venerable channels of the XMLHttpRequest API.

We rarely interact with XHR directly, though, preferring a variety of wrappers and client libraries to replace its less-than-obvious structure with something more familiar. That might be using Backbone.sync (which in turn delegates to jQuery), or Angular’s $http; we might simply consume it through a 3rd-party service’s client. If we’re making requests, though, odds are that somewhere, somehow, we’re relying on XHR.

For application developers that ubiquity is both a blessing and a curse. On the one hand, XHR provides a common currency for nearly every outbound request. On the other, its complexity ensures that it is usually wrapped–with plenty of room for inconsistency between each wrapping implementation. Not only are we wedded to XHR; we’re attached to the wrappers’ details as well.

The strategy

This presents a difficult choice for testing. We can stub each wrapper and write unit tests around out own applications (now we have a bunch of unit tests), or we can find some way to test at a functional level, treating the wrapper as a black box between our own code and the XHRs that it ultimately triggers. There are several advantages to this:

  • Application logic is divorced from the request layer. Multiple transport methods (request libraries, client libraries, etc.) can coexist peacefully within a single application. This also encourages:

  • Portability. Tests at the XHR level describe the underlying logic in a standard-as-in-browsers format. If they were originally written for a Backbone app, their logic will still apply after a custom client has been swapped in for Backbone.sync.

  • Server behaviors can be described directly. Servers speak HTTP. XHRs describe HTTP. Writing test fixtures in terms of the raw status codes, headers, and bodies expected from the server makes it easy to compare tests to actual server behavior.

The downside is complexity: a quick look through Angular’s $httpBackend mock gives some idea of how involved server responses can get. The many-splendored features of the XHR API don’t make it any easier, so let’s start simple.

Sinon.fakeServer

XHR is hardly a novel problem, and the contributors to the fabulous sinon.js mocking library have saved us the trouble of reimplementing it in our tests. Instead, we can simply use fakeServer.

fakeServer works by mocking the global XMLHttpRequest object to provide predetermined response fixtures when certain requests are matched. Say we have a simple client (demo source is available on github):

function apiError (status, message) {
  var err = new Error(message);
  err.status = status;
  return err;
}

function client (path, callback) {

  var xhr = new window.XMLHttpRequest();

  xhr.addEventListener('load', function () {
    var body;
    try {
      body = JSON.parse(this.responseText);
    } catch (e) {
      return callback('Invalid JSON:', this.responseText);
    }

    if (this.status < 200 || this.status > 299) {
      return callback(apiError(this.status, body.message));
    }

    return callback(null, body);
  });

  xhr.open('get', path);
  xhr.send();
}

Not much there–just a tool for wrapping XHR outcomes in node.js’s continuation-passing style. If we want to write a simple jasmine spec for it using sinon’s server, it might look something like this:

describe('client', function () {

  var server = null;

  beforeEach(function () {
    server = sinon.fakeServer.create();
  });

  afterEach(function () {
    server.restore();
  });

  describe('responding to a generic request', function () {

    beforeEach(function () {
      var okResponse = [
        200,
        { 'Content-type': 'application/json' },
        '{"hello":"world"}'
      ];

      server.respondWith('GET', '/hello', okResponse);
    });

    it('returns correct body', function (done) {
      client('/hello', function (err, json) {
        if (err) return done(err);
        expect(json.hello).toBe('world');
        done();
      });

      server.respond();
    });
  });
});

From this test, we expect the client to translate the response to a callback. We could simply describe this as:

it('returns correct body', function (done) {
  client('/hello', function (err, json) {
    expect(json.hello).toBe('world');
    done();
  });
});

Running this test, however, we would see a 404 as the outbound XHR tries–and fails–to GET /hello from the local server. To prevent the XHR from ever making it that far, we set up a fake server and preload it with a fixed response (following [ status, headerObj, bodyStr ]) to GET /hello:

var server = null;

beforeEach(function () {
  server = sinon.fakeServer.create();
});

afterEach(function () {
  server.restore();
});

beforeEach(function () {
  var okResponse = [
    200,
    { 'Content-type': 'application/json' },
    '{"hello":"world"}'
  ];

  server.respondWith('GET', '/hello', okResponse);
});

Finally, we tell the server to respond.

it('returns correct body', function (done) {
  client('/hello', function (err, json) {
    if (err) return done(err);
    expect(json.hello).toBe('world');
    done();
  });
  server.respond();
});

There’s a subtlety in the flow of this test: we’re sending the request and response synchronously, but the request callback will resolve the test whenever it is actually invoked. In other words, the actual evaluation follows:

  1. client request is sent
  2. server responds
  3. client callback is invoked with (err, json)
  4. client callback runs assertions
  5. client callback resolves test by calling done()

Dressing it up

We’re now testing via XHR, but phew!–it’s taken a lot of boilerplate to get there. We can clean things up a bit by extracting common operations to test helpers. For instance, we can wrap fake responses from a JSON server up in their own almost-trivial methods:

function jsonOk (body) {
  return [
    200, {
      'Content-type': 'application/json'
    }, JSON.stringify(body)
  ];
}

function jsonError (statusCode, body) {
  return [
    statusCode, {
      'Content-type': 'application/json'
    }, JSON.stringify(body || {
      error: statusCode,
      message: 'an error has befallen us!'
    })
  ];
}

The behavior of each API will be slightly different, of course, but if we can contain repeated behaviors (headers, standard error codes, response format, etc) inside test helpers it can make a somewhat cumbersome test much more legible:

describe('responding to a generic request', function () {

  beforeEach(function () {
    server.respondWith('GET', '/hello', jsonOk({
      hello: 'world'
    });
  });

  it('returns correct body', function (done) {
    client('/hello', function (err, json) {
      if (err) return done(err);
      expect(json.hello).toBe('world');
      done();
    });

    server.respond();
  });
});

Conclusion

Replacing library-specific unit tests with full-on mocks of XHR is a non-trivial project. For client codebases deeply involved with one or more HTTP APIs, though, tests aimed directly at XHR breed can be clearer, flexible, and easier than tests targeted at a particular XHR wrapper. And even though it requires an intimidating volume of boilerplate up front, the ubiquity of XHR makes it easy to extract and reuse helpers across multiple tests.

Note: this article is the first of two parts in a miniseries on functional client-server testing. Next up, we’ll use similar techniques to author high-level tests around the window.fetch API.

Hey, I'm RJ! For more learnings about software and management, find me @rjzaworski or sign up for my semi-regular newsletter.

Let’s keep in touch

Send me timely updates on software, product, and process.