If you are building software in JavaScript that issues http
requests for any reason, you will have code that depends on the responses of these requests. The code that makes those requests has an external dependency that makes unit tests harder to write.
If you are using mocha
as a test runner, this is where sinon
comes in. It is a full-featured stubbing library for unit testing in JavaScript. It helps you unit test code with external dependencies by allowing you to change the behavior of functions under test.
If you are using jest
, it comes with its own stubbing features. The best practices here will have sinon specific examples, but the principles apply to any stubbing engine.
This guide assumes you already know the basics of chai
and sinon
. This will provide tactical advice on how to use the two modules together while avoiding some common pitfalls.
Sinon is most useful to avoid relying on external dependencies in unit tests. So, imagine we want to test the get
method of this simple api client. It has some special error handling for known response statuses that we want to test:
module.exports = class ApiClient {
constructor(httpClient, apiKey) {
this.httpClient = httpClient;
this.apiKey = apiKey;
this.isApiKeyValid = true;
}
get(endpoint, callback) {
// When unit testing, you probably don't want this line of code to issue
// real http requests.
// This API's uptime would be a hard dependency for your unit test.
this.httpClient.get(endpoint, {apiKey: this.apiKey}, (err, response) => {
if (err) { return callback(err); }
if (response.status >= 500) {
return callback(new Error('INTERNAL_SERVER_ERROR'))
}
if (response.status == 403) {
this.isApiKeyValid = false;
return callback(new Error('AUTH_ERROR'))
}
return callback(null, response);
})
}
}
Sinon will throw a very helpful error if the method you attempt to stub doesn’t exist. It is best practice to stub the method you expect to use on the same type of object you use in your code. This will avoid writing unit tests that pass if the code is using non-existent methods:
const request = require('request');
const sinon = require("sinon");
it('issues the request', function() {
// Throws an error because `request.gettt` does not exist
sinon.stub(request, 'gettt')
// Work because request.get is a valid function.
sinon.stub(request, 'get')
...
})
Common pitfall: Tests that create completely fabricated objects using sinon.stub()
with no arguments can allow tests to pass on code with hard-to-catch typos that lead to bugs.
Add the sinon-chai
module to the mix in order to use expectation syntax with sinon
stubs. Without sinon-chai
the expectation can be asserted awkwardly as shown below:
it('issues the request', function(done) {
sinon.stub(request, 'get').yields(null, {});
apiClient = new ApiClient(request, 'api-key');
apiClient.get('/endpoint', (err, response) => {
expect(request.get.calledOnce).to.be.true
done(err);
})
})
On failure, chai
will tell us that it “expected false to be true”, which doesn’t provide much context.
Common pitfall: This can make tests harder to maintain for people who didn’t write the original code or test.
With sinon-chai
, one can use the same expectation chaining that makes the expect syntax nice to read along with and is better failure reporting:
const request = require('request');
const sinon = require("sinon");
const chai = require("chai");
const sinon = require("sinon");
const sinonChai = require("sinon-chai");
chai.use(sinonChai);
const expect = chai.expect;
it('issues the request', function(done) {
sinon.stub(request, 'get').yields(null, {});
apiClient = new ApiClient(request, 'api-key');
apiClient.get('/endpoint', (err, response) => {
expect(request.get).to.have.been.calledOnce
done(err);
})
})
If this fails, sinon-chai
will tell us that it “expected request.get to be called once” which is a more accurate explanation of why the test failed.
Always use a sandbox to store your stubs and spies for easy cleanup. Instead of having to remember to restore each individual stub, the whole sandbox can be restored at once. This will ensure that changes in one test will not bleed over to downstream unit tests:
describe('TestModule', function() {
beforeEach('setup sandbox', function() {
this.sandbox = sinon.sandbox.create();
this.sandbox.stub(request, 'get');
});
...
afterEach('restore sandbox' function() {
this.sandbox.restore();
});
})
This strategy will avoid the common pitfall where stubs and spies remain in effect and alter the behavior of unrelated tests.
If you have any global test setup helpers/infrastructure, consider adding the sandbox restoration to a global afterEach
if this.sandbox
is set to avoid the test failures that are hard to debug. This can happen if stubs are not cleaned up after a test:
//Global test helper file
afterEach('restore sandbox', function() {
if(this.sandbox) { this.sandbox.restore(); }
}
yields
for asynchronous interfacesIn many cases, the external dependency will use an asynchronous interface. To test many different results, create the stub once in the beforeEach
and use the yields
method in your specific test to scope it to that specific case:
const ApiClient = require('./ApiClient');
const request = require('request');
const sinon = require('sinon');
const chai = require('chai');
const sinonChai = require('sinon-chai');
// Allows us to use expect syntax with sinon
chai.use(sinonChai);
const expect = chai.expect;
describe('ApiClient#get', function() {
beforeEach('create ApiClient instance', function() {
this.sandbox = sinon.sandbox.create();
this.sandbox.stub(request, 'get')
this.apiClient = new ApiClient(request, 'api-key');
});
afterEach('restore stub', function() {
this.sandbox.restore();
}
it('yields the request error if the request fails', function(done) {
let requestError = {some: 'error'}
// Respond with a node-style callback error
request.get.yields(requestError);
this.apiClient.get('/posts', (err, response) => {
// Ensure the function was called with expected parameters
expect(request.get).to.have.been.calledWith('/posts', {apiKey: 'api-key'});
// Check that the error is the same object that was yielded.
expect(err).to.equal(requestError);
return done();
});
it('yields INTERNAL_SERVER_ERROR when the response status is 500', function(done) {
// Respond with a 500 to simulate a server error
request.get.yields(null, {status: 500});
this.apiClient.get('/posts', (err, response) => {
// Ensure the function was called with expected parameters
expect(request.get).to.have.been.calledWith('/posts', {apiKey: 'api-key'});
// Check that the error is the right string.
expect(err).to.equal('INTERNAL_SERVER_ERROR');
return done();
});
it('yields an AUTH_ERROR when the response status is 403', function(done) {
request.get.yields(null, {status: 403}); // Respond with a 403
this.apiClient.get('/posts', (err, response) => {
// Ensure the function was called with expected parameters
expect(request.get).to.have.been.calledWith('/posts', {apiKey: 'api-key'});
// Check that the error is the right string.
expect(err).to.have.property('message', 'AUTH_ERROR')
// Test for publicly visible side effects
expect(this.apiClient.isApiKeyValid).to.equal(false);
return done();
});
});
Using yields
avoids the common pitfall of creating extra stubs just to act as callbacks for already stubbed methods.
With the tips above, you and your team can better utilize sinon
to write unit tests for code with external dependencies while avoiding the common pitfalls listed above!
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowIn this article, you’ll learn how to set up Hoppscotch and which APIs to test it with. Then we’ll discuss alternatives: OpenAPI DevTools and Postman.
Learn to migrate from react-native-camera to VisionCamera, manage permissions, optimize performance, and implement advanced features.
SOLID principles help us keep code flexible. In this article, we’ll examine all of those principles and their implementation using JavaScript.
JavaScript’s Date API has many limitations. Explore alternative libraries like Moment.js, date-fns, and the new Temporal API.