DEV Community

Cover image for Testing an Express app with SuperTest, moxios and Jest
Hugo Di Francesco
Hugo Di Francesco

Posted on • Originally published at codewithhugo.com on

Testing an Express app with SuperTest, moxios and Jest

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.

Full code example can be found at https://github.com/HugoDF/express-supertest-moxios.

This was sent out on the Code with Hugo newsletter on Monday.
Subscribe to get the latest posts right in your inbox (before anyone else).

A simple Express app 🎁

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

hugo.js:

const {Router} = require('express');
const axios = require('axios');
module.exports = (router = new Router()) => {
  router.get('/hugo', async (req, 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;
};
Enter fullscreen mode Exit fullscreen mode

This would be consumed in a main server.js like so:

const express = require('express');
const app = express();
const hugo = require('./hugo');

app.use(hugo());

app.listen(3000, () => {
  console.log(`Server listening on port 3000`);
});
Enter fullscreen mode Exit fullscreen mode

For this to run, the following dependencies are required:

npm i --save express axios
Enter fullscreen mode Exit fullscreen mode

And it can be run using:

node server.js
Enter fullscreen mode Exit fullscreen mode

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

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

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:

hugo.test.js

const hugo = require('./hugo');
const express = require('express');
const moxios = require('moxios');
const request = require('supertest');

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,
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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

npm install --save-dev jest moxios supertest
Enter fullscreen mode Exit fullscreen mode

Run tests with:

npx jest
Enter fullscreen mode Exit fullscreen mode

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):

blob-store.js:

const {Router} = require('router');

module.exports = (redisClient, router = new 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;
};
Enter fullscreen mode Exit fullscreen mode

server.js:

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

// For the sake of simplicity, 
// redisClient isn't in another module
const redis = require('redis');
const {promisify} = require('util');
const client = redis.createClient(process.env.REDIS_URL);

const redisClient = {
  getAsync: promisify(client.get).bind(client),
  setAsync: promisify(client.set).bind(client)
};

const hugo = require('./hugo');
const blobStore = require('./blob-store');

app.use(hugo());
app.use(blobStore(redisClient));

app.listen(3000, () => {
  console.log(`Server listening on port 3000`);
});
Enter fullscreen mode Exit fullscreen mode

For a walkthrough of the example of the above, see Setting up Express and Redis with Docker compose.

To get it up and running:

Once the app is running, we can do the following:

  1. Store some data:
curl http://localhost:3000/store/my-key\?some\=value\&some-other\=other-value
Success
Enter fullscreen mode Exit fullscreen mode
  1. Retrieve that data:
curl http://localhost:3000/my-key
{
    "some": "value",
    "some-other": "other-value"
}
Enter fullscreen mode Exit fullscreen mode

Testing strategy 🛠

We have a decision to make here:

  1. Mock Redis
  2. Don't mock Redis

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 🤡

blob-store.test.js

const blobStore = require('./blob-store');
const express = require('express');
const moxios = require('moxios');
const request = require('supertest');

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.setAsync).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({});
  });
});
Enter fullscreen mode Exit fullscreen mode

In short we set up our tests so we can pass an arbitrary redisClient object where we can mock the methods themselves.

Parting thoughts 🦋

Testing an Express app is all about finding the boundary at which the mocking starts and where it stops.

This is an excercise in API design, how to test things in as large a unit as it makes sense to (eg. the whole endpoint),
without having to carry around the baggage of a full database/persistence layer.

For example, another approach to the Redis client tests would have been to create a mock client that maintains the
state somehow (ie writes to an object internally), and to inject/inspect that state (before and after the code under test respectively).

For the full code example, see https://github.com/HugoDF/express-supertest-moxios.

This was sent out on the Code with Hugo newsletter on Monday.
Subscribe to get the latest posts right in your inbox (before anyone else).

Cover photo Bekir Dönmez on Unsplash

Top comments (0)