6.2 supertest and HTTP-level tests

Testing at the HTTP-level means that we’re not tying our tests to Express’ req, res and next constructs. This can also be useful since tests written using the same technique can serve as integration or end to end tests.

This strength of using supertest, which is essentially a http client that supports passing a connect-based app, is also a weakness, since it’s rather easy to test past module boundaries.

Leveraging SuperTest to write integration level tests

Testing is a crucial part of the software development process. It helps to catch bugs, avoid regressions and to document the behaviour of a piece of software.

Express is one of the most widespread libraries for building backend applications in JavaScript. What follows is a summary of how to set up an efficient unit testing strategy for such an application as well as a couple of situations you may be faced with when attempting to test.

A simple Express app

Say we have an Express set of route handlers like the following:

hugo.js:

const express = require('express');
const axios = require('axios');

const hugo = (router = new express.Router()) => {
  router.get('/hugo', async (request_, res) => {
    const {data: userData} = await axios.get(
      'https://api.github.com/users/HugoDF'
    );
    const {blog, location, bio, public_repos} = userData;
    return res.json({
      blog,
      location,
      bio,
      publicRepos: public_repos
    });
  });
  return router;
};

Hitting /hugo would some JSON data pulled from my GitHub profile:

curl http://localhost:3000/hugo
{"blog":"https://codewithhugo.com","location":"London","bio":"Developer, JavaScript.","publicRepos":39}

Testing strategy

Testing is about defining some inputs and asserting on the outputs.

Now if we skip the chat about what a unit of test is, what we really care about with this API is that when we hit /hugo we get the right response, using jest here’s what a test suite might look like:

06.02-hugo.test.js

const express = require('express');
const axios = require('axios');

const moxios = require('moxios');
const request = require('supertest');

const hugo = (router = new express.Router()) => {
  router.get('/hugo', async (request_, res) => {
    const {data: userData} = await axios.get(
      'https://api.github.com/users/HugoDF'
    );
    const {blog, location, bio, public_repos} = userData;
    return res.json({
      blog,
      location,
      bio,
      publicRepos: public_repos
    });
  });
  return router;
};

const initHugo = () => {
  const app = express();
  app.use(hugo());
  return app;
};

describe('GET /hugo', () => {
  beforeEach(() => {
    moxios.install();
  });
  afterEach(() => {
    moxios.uninstall();
  });
  test('It should fetch HugoDF from GitHub', async () => {
    moxios.stubRequest(/api.github.com\/users/, {
      status: 200,
      response: {
        blog: 'https://codewithhugo.com',
        location: 'London',
        bio: 'Developer, JavaScript',
        public_repos: 39
      }
    });
    const app = initHugo();
    await request(app).get('/hugo');
    expect(moxios.requests.mostRecent().url).toBe(
      'https://api.github.com/users/HugoDF'
    );
  });
  test('It should 200 and return a transformed version of GitHub response', async () => {
    moxios.stubRequest(/api.github.com\/users/, {
      status: 200,
      response: {
        blog: 'https://codewithhugo.com',
        location: 'London',
        bio: 'Developer, JavaScript',
        public_repos: 39
      }
    });
    const app = initHugo();
    const res = await request(app).get('/hugo');
    expect(res.body).toEqual({
      blog: 'https://codewithhugo.com',
      location: 'London',
      bio: 'Developer, JavaScript',
      publicRepos: 39
    });
  });
});

To run the above, first of all, add the required dependencies:

npx jest src/06.02-hugo.test.js
 PASS  src/06.02-hugo.test.js
  GET /hugo
    ✓ It should fetch HugoDF from GitHub (85ms)
    ✓ It should 200 and return a transformed version of GitHub response (12ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total

We’re leveraging SuperTest and passing the express app to it. SuperTest’s fetch-like API is familiar and is await-able.

moxios is a package to “mock axios requests for testing”. We’re able to run our unit tests in watch mode without flooding the upstream REST API. moxios needs to be installed and uninstalled, we do this before and after each test respectively. This is to avoid an edge case where one failing test can make others fail due to moxios isn’t torn down and re-set up properly if the error occurs before moxios.uninstall is called.

The stubRequest method should be passed 2 parameters:

  • The first is what is going to be intercepted, this can be a string (which will need to be a full URL), or a Regular Expression.
  • The second parameter is a response config object, the main keys we use are status and response. Status will be the status in the axios fetch response and response will be the data in the axios fetch response.

Testing a less simple Express app

Let’s say we have an app that’s a blob store, backed by Redis (a simple key-value store amongst other things):

06.02-blob-store.test.js:

const express = require('express');

const blobStore = (redisClient, router = new express.Router()) => {
  router.get('/store/:key', async (req, res) => {
    const {key} = req.params;
    const value = req.query;
    await redisClient.setAsync(key, JSON.stringify(value));
    return res.send('Success');
  });
  router.get('/:key', async (req, res) => {
    const {key} = req.params;
    const rawData = await redisClient.getAsync(key);
    return res.json(JSON.parse(rawData));
  });
  return router;
};

Testing strategy

We have a decision to make here:

  1. Mock Redis, pass a fake instance
  2. Don’t mock Redis, pass a real instance and run a server

To not mock Redis would mean running a full Redis instance and setting up some test data before each test suite. This means you’re relying on some sort of ordering of tests and you can’t parallelise without running multiple Redis instances to avoid data issues.

For unit(ish) tests, that we want to be running the whole time we’re developing, this is an issue. The alternative is to mock Redis, specifically, redisClient.

Where Redis gets mocked

We set up our tests so we can pass an arbitrary redisClient object where we can mock the methods themselves in 06.02-blob-store.test.js:

const express = require('express');
const request = require('supertest');

const blobStore = (redisClient, router = new express.Router()) => {
  router.get('/store/:key', async (req, res) => {
    const {key} = req.params;
    const value = req.query;
    await redisClient.setAsync(key, JSON.stringify(value));
    return res.send('Success');
  });
  router.get('/:key', async (req, res) => {
    const {key} = req.params;
    const rawData = await redisClient.getAsync(key);
    return res.json(JSON.parse(rawData));
  });
  return router;
};

const initBlobStore = (
  mockRedisClient = {
    getAsync: jest.fn(() => Promise.resolve()),
    setAsync: jest.fn(() => Promise.resolve())
  }
) => {
  const app = express();
  app.use(blobStore(mockRedisClient));
  return app;
};

describe('GET /store/:key with params', () => {
  test('It should call redisClient.setAsync with key route parameter as key and stringified params as value', async () => {
    const mockRedisClient = {
      setAsync: jest.fn(() => Promise.resolve())
    };
    const app = initBlobStore(mockRedisClient);
    await request(app).get('/store/my-key?hello=world&foo=bar');
    expect(mockRedisClient.setAsync).toHaveBeenCalledWith(
      'my-key',
      '{"hello":"world","foo":"bar"}'
    );
  });
});

describe('GET /:key', () => {
  test('It should call redisClient.getAsync with key route parameter as key', async () => {
    const mockRedisClient = {
      getAsync: jest.fn(() => Promise.resolve('{}'))
    };
    const app = initBlobStore(mockRedisClient);
    await request(app).get('/my-key');
    expect(mockRedisClient.getAsync).toHaveBeenCalledWith('my-key');
  });
  test('It should return output of redisClient.getAsync with key route parameter as key', async () => {
    const mockRedisClient = {
      getAsync: jest.fn(() => Promise.resolve('{}'))
    };
    const app = initBlobStore(mockRedisClient);
    const response = await request(app).get('/my-key');
    expect(response.body).toEqual({});
  });
});

This yields the following tests output.

npx jest src/06.02-blob-store.test.js
 PASS  src/06.02-blob-store.test.js
  GET /store/:key with params
    ✓ It should call redisClient.setAsync with key route parameter as key and stringified params as value (30ms)
  GET /:key
    ✓ It should call redisClient.getAsync with key route parameter as key (4ms)
    ✓ It should return output of redisClient.getAsync with key route parameter as key (5ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total

Testing middleware

Given a simple middleware that encodes the HTTP request referrer as base64 in the req.referer property.

const express = require('express');
const request = require('supertest');

function encodeReferer(req, res, next) {
  if (req.headers.referer) {
    req.referer = Buffer.from(req.headers.referer).toString('base64');
  }

  next();
}

We can create an initMiddleware function that mounts this middleware and a route that reflects req.referer in order to test it.

const initMiddleware = () => {
  const app = express();
  app.use(encodeReferer);
  app.use('/', (req, res) => {
    res.send(req.referer);
  });
  return app;
};

We can then write the following tests to check the functionality.


test('encodeReferer should base64 referer if set', async () => {
  const app = initMiddleware();
  const res = await request(app)
    .get('/')
    .set('Referer', 'codewithhugo.com');
  expect(res.text).toEqual('Y29kZXdpdGhodWdvLmNvbQ==');
});

test('encodeReferer should work fine if referer is not set', async () => {
  const app = initMiddleware();
  const res = await request(app).get('/');
  expect(res.text).toEqual('');
});

Which yields the following test output.

npx jest src/06.02-middleware.test.js
 PASS  src/06.02-middleware.test.js
  ✓ encodeReferer should base64 referer if set (37ms)
  ✓ encodeReferer should work fine if referer is not set (4ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total

This section went through how one might use supertest to write HTTP-level tests for endpoints and middleware.

The next section looks at Jest’s coverage tools.

Jump to table of contents