A Battle-Tested Strategy for Automated API Testing

Akambi Fagbohoun
SSENSE-TECH
Published in
8 min readMay 9, 2019

A step by step look at how to efficiently automate Node.js API tests using Mocha, Chai, SuperTest, and Nock.

Introduction

SSENSE is powered by a large and complex collection of HTTP API microservices. The interdependencies between all our APIs makes it critically important for us to ensure the health and homogeneity of all endpoints. Small bugs or inconsistencies in one API can have a domino effect on multiple services. While a smaller and more independent API might be able to rely on manual HTTP client tools like Postman and Insomnia, the scale at which SSENSE operates affords us no such liberties. We have therefore implemented an API testing strategy that is automated, comprehensive, and ensures that every endpoint satisfies its business requirements. In this article, we will walk you through our testing strategy and demonstrate how you could implement something similar. In doing so, we will cover the following topics:

  • How API endpoint testing can be automated.
  • The API testing tools SSENSE uses and their respective roles and responsibilities.
  • How authentication and external dependencies can be managed.

What is API Testing?

Contrary to unit testing where we test the technical functions of our application, with API testing, we focus on the behavior of the API when called with different inputs. For example, we can test how an API behaves if any required parameter is not passed, or if a mandatory authorization is missing. Here are some interesting things to check after calling an API :

  • Is the response status 200? (for HTTP APIs)
  • Does the Content-type header field have a specific value?
  • Is the schema of the response body as expected?
  • Is the response payload valid?
  • Are specific keys within the payload valid?
  • Is the response latency below 200ms?

How to Automate API Endpoint Tests

  1. To test an API, we first need a testing framework that will help organize and execute the tests. At SSENSE, based on some preliminary research, we decided to use Mocha. Mocha is a well-known testing framework that runs on Node.js.
  2. We also need an HTTP client to make requests and run assertions against our API endpoints. At SSENSE, we use Supertest, a lightweight library with intuitive assertions built for testing Node.js HTTP servers.
  3. For some of the more fine-grained assertions, we found Supertest to be limiting and decided to supplement its assertions with Chaia more complete assertion library which lets you toggle between BDD style ‘expect/should’ assertions and more traditional ‘assert’ statements.
  4. Finally, as our APIs depend on various external resources, we’ve integrated ‘Nock’ to mock all external API dependencies. Nock works by overriding Node’s http.request and http.ClientRequest functions. It intercepts every request and either returns fake data (if defined) or makes a real HTTP request.

Now, let’s write our first API test. Tests are conventionally kept in a tests folder. As API testing is a form of functional testing, we’ll create a subdirectory tests/functional/api and a test file per API endpoint. As for naming conventions, it is recommended to use the convention YourModelTest.js. In this article, we’ll test a user API that returns user-specific information, so we’ll put our test in UserTest.js. Imagine we have the following code.

// app.js
const express = require('express');
const app = express();
const bodyParser = require('body-parser');
// parse application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({
extended: false
}))
// parse application/json
app.use(bodyParser.json())
app.get('/users', function(req, res) {
res.status(200).json([{
name: 'john'
}]);
});
app.put('/users/:userId', function(req, res) {
res.status(200).json(req.body);
});
app.listen(3000, function() {
console.log('App listening on port 3000!');
});
module.exports = app;

We can write a test to assert that our API returns a JSON dictionary with 17 characters. Next, install all required packages with the following command:

npm install supertest chai mocha nock — save-dev

then create the file test/functional/api/userTest.js with this code:

// test/functional/api/userTest.js
const request = require('supertest');
const app = require('../../../app.js');
describe('GET /users', function() {
it('should return a json of 17 characters', function(done) {
request(app).get('/users')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect('Content-Length', '17')
.expect(200)
.end(function(err, res) {
if (err) throw err;
done();
});
});
});

After setting up the application with the API host, we only need a relative URL to call our API endpoint. Passing a full URL is not necessary.

describe() and it() are two functions provided by Mocha that help us structure our API tests.

describe() is used to group test cases. The first argument is the name of the test group, and the second is a callback function which contains a list of test cases.

it() is used for an individual test case. The first argument is a string explaining what we are expecting from the API. The second argument is a callback function which contains our actual test.

request(app).get(‘/user’) initializes Supertest with the API endpoints, and makes a call to the endpoint /user. Supertest is a high-level abstraction of Superagent, a Node.js HTTP client. It extends Superagent’s interface with an .expect() function that helps assert whether or not the API response matches a list of expectations. Here, we anticipate that the API response code will be 200 (Successful). If an API returns an error, such as any response code other than a 2XX, the error is sent to a callback function if one is defined.

.expect() assertions that fail will throw an error. As we passed a callback to the .end() method, the error is caught and sent to the callback function. In order to fail the test case, we throw the same error again. We can also pass it to Mocha’s done() function to fail the test.

.expect interfaces:

.expect(status[, fn])
.expect(status, body[, fn])
.expect(body[, fn])
.expect(field, value[, fn])
.expect(function(res) {})

The Supertest expect function is a polymorphic function that can be used to :

  • Assert a response’s status code and/or its body text to match a string, regular expression, or parsed body object.
  • Assert header field values to match strings or regular expressions.
  • Assert complex expectations. To achieve this, we pass a callback function that receives the response object and runs custom assertions. If an assertion fails, the function needs to throw an error.

Expectations are run in the order in which they are defined. Any change at an .expect() step persists into the next step. If we modify the response body or a header field in any Supertest expect callback function, the change will be present in the next .expect() method.

Asserting Complex Expectations

With Supertest’s .expect() function, we can validate the HTTP status or run an assertion on the whole API response body. But if we need custom assertions, such as validating the response format or value, we’ll need to use Chai’s expect() assertion. Here’s how:

// test/functional/api/userTest.js
const request = require('supertest');
const expect = require('chai').expect;
const app = require('../../../app.js');
describe('GET /users', function() {
it('should return a user with name and email', function(done) {
request(app).put('/users/1')
.set('Accept', 'application/x-www-form-urlencoded')
.send({
name: "Akambi",
email: "akambi.fagbohoun@ssense.com",
})
.expect(200)
.end(function(err, res) {
expect(res.body.name).to.equal("Akambi");
expect(res.body.email).to.equal("akambi.fagbohoun@ssense.com");
done();
});
});
});

Managing Authentication and Cookies

To test an API that needs an authenticated session, we need to call our login endpoint to get an authenticated cookie session. Here is how to get the cookie from an API that needs a username and password :

const request = require('supertest');
const expect = require('chai').expect;
const app = require('../../../app.js');
describe('Auth required', () => {
let Cookies;
it('should create user session for valid user', function() {
return request(app)
.post('/login')
.set('Accept', 'application/json')
.send({
"email": "akambi.fagbohoun@ssense.com",
"password": "password"
})
.expect('Content-Type', /json/)
.expect(200)
.then((res) => {
expect(res.body.email).to.equal('akambi.fagbohoun@ssense.com');
// Save the cookie to use it later to retrieve the session
Cookies = res.headers['set-cookie'].pop().split(';')[0];
});
});
});

Once we have the cookie, we need to pass it to each request that requires an authenticated session.

it('should get user orders if authenticated user', function(done) {
request(app)
.get('/orders')
// Use the cookie to retrieve the session.
.set('Cookie', Cookies)
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200)
.end(function(err, res) {
expect(res.body.length).to.equal(2);
done();
});
});

If we need to test multiple endpoints that require an authenticated session, we can call the supertest.agent() function to get the agent that executes the requests, to which we can bind an authenticated cookie session. This will create a persistent context that will be reused for each request. Here is an example demonstrating how to persist a cookie through multiple tests cases:

describe('Auth required', () => {
const agent = request.agent(app);
it('should create user session for valid user', function() {
return agent
.post('/login')
.set('Accept', 'application/json')
.send({
"email": "akambi.fagbohoun@ssense.com",
"password": "password"
})
.expect('Content-Type', /json/)
.expect('set-cookie', /connect.sid/)
.expect(200)
.then((res) => {
expect(res.body.email).to.equal('akambi.fagbohoun@ssense.com');
// Save the cookie in the agent.
// Use it later to retrieve the session.
agent.jar.setCookie(res.headers['set-cookie'][0]);
});
});
it('should get user orders if authenticated user', function(done) {
agent
.get('/orders')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200)
.end(function(err, res) {
expect(res.body.length).to.equal(2);
done();
});
});
});

Dealing with External Dependencies

For some endpoints, we are dependent on external services. A rule of thumb for testing is to be able to predict the behavior of every system being tested. We therefore have to mock all HTTP requests to the external services that our endpoints depend on. To mock HTTP requests, we need an HTTP server mocking library. As explained earlier, we chose Nock for this purpose. Let’s assume we need to test an endpoint that calls GitHub to get all public user repositories. Let’s see how we can test this endpoint.

The Code

const express = require('express');
const app = express();
const rp = require('request-promise');
app.get('/user/:username/repos', function(req, res) {
const username = req.params.username;
const options = {
url: `https://api.github.com/users/${username}/repos`,
json: true,
headers: {
"User-Agent": "dummyapp" // Your Github ID or application name
}
};
rp(options)
.then((response) => {
res.status(200).json(response);
})
.catch((err) => {
res.status(500).send(err.message);
});
});

The Test

const request = require('supertest');
const expect = require('chai').expect;
const app = require('../../../app.js');
const nock = require('nock');
describe('User public repositories', () => {
beforeEach(async () => {
nock.cleanAll();
});
it('should get user public repositories', function() {
const githubRepos = {
repo1: 'sum-lib'
};
nock('https://api.github.com')
.get('/users/akambi/repos')
.reply(200, githubRepos);
return request(app)
.get('/user/akambi/repos')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200)
.then(response => {
console.log(response.body);
expect(response.body).to.deep.equal(githubRepos);
});
});
});

To test our API, we first import all necessary packages. Before calling our API endpoint, we clean all existing Nock mocks, mock the external call to GitHub service with Nock, then check if its response matches the data returned by the fake call to GitHub.

Run the Tests

Congrats! You now know how to write functional tests for your API. Let’s run the tests and see the results. To do this, open your package.json file and add a new npm script test:functional:api as follows :

{
"name": "nock-tests",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test:functional:api": "NODE_ENV=test mocha - timeout 5000 - recursive \"tests/functional/api/**/*.js\" - bail",
},
"author": "",
"license": "UNLICENSED",
"devDependencies": {
"chai": "4.0.2",
"mocha": "3.4.2",
"nock": "9.0.13"
}
}

Now, in our terminal, we run the command npm run test:functional:api to see the test result.

API Testing Automated!

By now, you should have a clear understanding of how to automate API tests using Supertest, Mocha, and Chai. Since your HTTP API is provided to your clients, it is imperative to ensure that such an interface is secure, meets all business needs, and has acceptable response times. Not only is automating API tests the safer option, it also saves you precious time that you might have previously spent routinely inspecting the health of your endpoints.

Editorial reviews by Hugo Pelletier, Deanna Chow, Liela Touré & Prateek Sanyal

Want to work with us? Click here to see all open positions at SSENSE!

--

--