When testing your front end code, you may need to test more complex behavior such as making asynchronous requests. However, it is bad practice to make actual requests in a test, so you have to find a way to test your code without sending real requests.
That’s where sinon comes in. This library provides several ways to create test-doubles, allowing you to replace functions that make requests with these doubles.
This post is the fifth in our series on setting up test tools for test-driven development in React. If you haven’t already, you may want to read our posts on chai, mocha, and test builders before going through this post.
The final version of the files we are working with are linked to in the bottom of the post, for reference.
Sinon Configuration
To get started, first add the sinon and sinon-chai plugin to your package.json:
$ npm install --save-dev sinon sinon-chai
This article will use xhr for XMLHttpRequests:
$ npm install --save-dev xhr
Then in your test file, require the sinon module:
import sinon from 'sinon';
The sinon plugin provides different types of test-doubles, depending on your needs. For this post we will cover the basics of spies, stubs, and the fake xhr and server.
Matching Libraries
The sinon-chai library allows us to structure our assertions more clearly.
So for the last setup step in your test helper, set up sinon-chai:
import chai from 'chai'; import sinonChai from 'sinon-chai' chai.use(sinonChai);
You can optionally search for and install chai-as-promised to simplify some assertions about what promises will resolve to. I usually like to make multiple assertions, so I find that this is more difficult, but returning a Promise.all-wrapped array of assertions would work there too.
Setting Up Sinon’s Sandbox
The sandbox ensures that stubs and spies get restored between each test and that we don’t continue to stub functions beyond the relevant test. It’s often a good idea to include this in test helpers generally, as shown:
beforeEach(function() { this.sinon = sinon.sandbox.create(); });
and we’ll need to add the teardown step:
afterEach(function() { this.sinon.restore(); });
As an alternative, you can set up a function to apply this specifically to tests that use sinon:
global.setupSandbox = function() { beforeEach(function() { this.sinon = sinon.sandbox.create(); }); afterEach(function() { this.sinon.restore(); }); }
You can use this in your test:
context("when I'm using sinon", function() { setupSandbox(); it('is a test', function() {}) ; });
Example: Stubbing the XHR Library.
For our example of using sinon, we will stub the XHR library. In our application code, we would be using this library to make ajax requests.
So let’s create a sample module for making requests. We’ll use es6 syntax here and the xhr request-js wrapper library. Repositories have a ‘fetch’ method that requests the index, a ‘get’ method that gets a single record by id, an ‘update’ method that sends an update via patch/put (based on your API), a ‘delete’ removes a single record, and ‘create’ uses the CREATE method to create your record. If your API isn’t RESTful, you can still use the Repository pattern. We’ll be building the fetch method for requesting the index.
In your todo_respository.js:
// todo_repository.js import xhr from 'xhr'; // instead of module.exports, in es6 we will be using export default. export default { fetch: function() {} };
An api call is classic example of why to use spies/stubs instead of calling the method directly. You never want to make an actual XMLHttpRequest in your test but you do want to ensure that your method makes the correct request to the server and appropriately handles both successful responses and errors.
In your todo_repository_spec.js:
// todo_repository_spec.js import TodoRepository from 'repositories/todo_repository'; import xhr from 'xhr'; describe('Todos Repository', function() { context('fetch', function() { it('returns a promise', function() { expect(TodoRepository.fetch()).to.have.a.property('then').that.is.a.function; }); }); });
If we run this, we’ll get an error: TypeError: Cannot read property ‘then’ of undefined
Let’s implement the full signature:
return new Promise(function(resolve, reject) { });
So the test passes, but our function doesn’t do much. You’ll want to define an API_HOST and API_PATH variable in your project to complete the next step. We’ll add another test to the “fetch” context:
it('makes a request', function() { const path = `${API_HOST}${API_PATH}/todos`; TodoRepository.fetch(); expect(xhrStub).to.have.been.calledWith(path); });
Our first error is that xhrStub isn’t defined. if you get an error about API_HOST or API_PATH not being defined, you should define them or replace them with appropriate strings. This is the first place we’ll be using sinon in the tests.
We’ll add a test stub to the test:
import xhr from 'xhr'; const xhrStub = this.sinon.stub(xhr, 'get');
Let’s see what happens next. If you’re following along, you probably get an error or test failure: AssertionError: expected get to have been called with arguments matching /api/v1/todos
So, it doesn’t seem that we’re calling the method, or the arguments don’t match. Let’s add the call within the promise constructor callback.
We’ll setup xhr to call resolve with the result:
xhr.get(`${API_HOST}${API_PATH}/todos`, resolve);
We may want to express other requirements, such as that we use the json option (set to true, include authentication headers, etc).
Mocking an XMLHttpRequest
The alternative here comes into play as we test how the repository processes responses from the server. Because the xhr library uses node-style callbacks, we’re wrapping it in a Promise to provide a “better” API. In order for this to work, we have to translate the error state from the request.
Let’s add another test or two to examine that:
it('properly resolves promise with response body', function() { return TodoRepository.fetch().then(function(response) { expect(response).to.be.an('array'); }); });
We get our first error because XMLHttpRequest isn’t defined in node, the environment that mocha runs our test in; If we had run this test in a browser, it would likely have made the internet request and then our request might fail for a number of reasons.
Instead, though, we get: TypeError: xhr.open is not a function
We’ll need to supply an XMLHttpRequest mock implementation to xhr. In some cases the http request library you use will use the global XMLHttpRequest request library, and you’ll need to override the global value in your test.
xhr.XMLHttpRequest = sinon.useFakeXMLHttpRequest(); //setup xhr global.XMLHttpRequest = sinon.useFakeXMLHttpRequest(); //override global, not required for xhr lib.
I recommend that you restore the original so that your tests will work in all environments:
global.mockXMLHTTPRequest = function() { globalXHR = global.xhr; xhrXHR = xhr.XMLHttpRequest; beforeEach(function() { global.XMLHttpRequest = sinon.useFakeXMLHttpRequest(); xhrXHR = sinon.useFakeXMLHttpRequest(); }); afterEach(function() { global.XMLHttpRequest = globalXHR; xhr.XMLHttpRequest = xhrXHR; }); }
On to the next error: Error: Timeout of 2000ms exceeded. For async tests and hooks, ensure “done()” is called; if returning a Promise, ensure it resolves.
Now that we’re mocking the the request, we need to create a “server” to handle the response. Sinon provides a tool for this, also, called ‘fakeServer’.
We’ll set the server up like this:
global.fakeServer = function(options={}) { const {key='server', ...serverOptions} = options; mockXMLHTTPRequest(); beforeEach(function() { const server = this[key] = global.sinon.fakeServer.create(); Object.assign(server, serverOptions); }); afterEach(function() { this[key].restore(); }); }
Because the server is fairly complex, we’ll expose its API as an options object we’ll pass to fakeServer. We can use defaults, later, if we need, for the project this way, also.
Next, we’ll need to set up our test to use the fakeServer:
fakeServer();
We’ll also have to set up the response. We’ll add this to our test, before the return statement:
this.server.respondWith("GET", /todos$/, [200, { "Content-Type": "application/json" }, JSON.stringify(['item']);
Also, trying to manually stringify a javascript value can lead to errors, so double-check your payload string if you get an unexpected error.
We’ll need to save off TodoRepository.fetch() as request:
const request = TodoRepository.fetch();
We’ll add the server response call: this.server.respond();
We’ll change the return value to: return request.then(...);
The next error we’ll get is AssertionError: expected null to be an array
xhr uses a node-style callback, so just passing the resolve callback as the response callback won’t work. We’ll have to process the callback arguments somewhat.
We’ll start with the happy path:
xhr.get(`${API_HOST}${API_PATH}/todos`, {json: true}, function(err, resp, body) { resolve(body); });
We’ll also have to test the error response. I’ll include a complete test here:
it('handles an error', function() { const request = TodoRepository.fetch(); this.server.respond(); return request.then(function() { throw new Error('The promise should reject, not resolve'); }, function(response) { expect(response).to.have.a.property('error', 'not found'); }); });
We’re omitting the server response to replicate a 404, the default behavior of the server when no response has been registered.
Initially, we get an error: Error: The promise should reject, not resolve
We’ll have to do something about the error. We’ll make the following change to the xhr callback:
if (!err && resp.statusCode === 200) resolve(body); else if (resp.statusCode === 404) reject({ error: 'not found', statusCode: resp.statusCode }); else reject({ error: err, statusCode: resp.statusCode })
Now, if there’s an error or a statusCode 404, we’ll reject the callback with it.
Other Sinon Capabilities
Spies
Spies are like stubs in that they allow us to get information about calls we make to methods, but they can wrap individual methods, also, or provide filler methods. Unlike stubs, spies allow us to call the underlying method, but also allow us to see what the calls and returns look like.
More on stubs
Stubs allow us to dictate that certain callbacks will be called, to call an arbitrary callback, to return different values for arbitrary calls, or as a function of the call number. I encourage you to look at the sinon docs for the full range of capabilities.
Timers
sinon also provides stubs for the the built-in timing functions to arbitrarily control time within your tests. If you’ve used timecop for ruby or pytimecop for python, this allows you to similarly test your code over time.
Some libraries keep copies of setTimeout and setInterval so you may need to reset those values. Specifically, we find this has specific implications for lodash, for example.
Questions?
In conclusion, once your application becomes more complex, using a tool like sinon gives you several different ways to test those behaviors.
If you have any follow-up questions about setting up front-end testing, feel free to check out our past posts or hit us up on Twitter.
View the final version of todo_repository_spec.js
import TodoRepository from 'repositories/todo_repository.js'; import xhr from 'xhr'; import config from 'config'; import sinon from 'sinon'; const { API_HOST, API_PATH } = config; describe('Todos Repository', function() { context('fetch', function() { fakeServer(); it('returns a promise (or thenable)', function() { expect(TodoRepository.fetch()).to.have.a.property('then').that.is.a.function; }); it('makes a request', function() { const xhrStub = this.sinon.stub(xhr, 'get'); const path = `${API_HOST}${API_PATH}/todos`; TodoRepository.fetch(); expect(xhrStub).to.have.been.calledWithMatch(path); expect(xhrStub).to.have.been.calledWithMatch(path, { json: true }); }); it('properly resolves promise with response body', function() { this.server.respondWith("GET", /todos$/, [200, { "Content-Type": "application/json" }, JSON.stringify(['item'])]); const request = TodoRepository.fetch(); this.server.respond(); return request.then(function(response) { expect(response).to.be.an('array'); }); }); it('handles an error', function() { const request = TodoRepository.fetch(); this.server.respond(); return request.then(function() { console.log(arguments); throw new Error('The promise should reject, not resolve'); }).catch(function(response) { expect(response).to.have.a.property('error', 'not found'); expect(response).to.have.a.property('statusCode', 404); }); }); }); });
View the final version of todo_repository.js
import xhr from 'xhr'; import config from 'config'; const { API_HOST, API_PATH } = config; export default { fetch: function() { return new Promise(function(resolve, reject) { xhr.get(`${API_HOST}${API_PATH}/todos`, { json: true, "Content-Type": 'application/json' }, function(err, resp, body) { if(!err && resp.statusCode === 200) resolve(body); else if(resp.statusCode === 404) reject({ error: 'not found', statusCode: resp.statusCode }); else reject({ error: err, statusCode: resp.statusCode }) }); }); } }