Testing API Requests with XHR and sinon.js
- 5/31/2015
- ·
- #howto
- #javascript
- #sinon
- #testing
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. Several factors make these higher-value tests:
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. XHR models HTTP requests. 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:
- client request is sent
- server responds
- client callback is invoked with
(err, json
) - client callback runs assertions
- 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.