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;
};
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`);
});
For this to run, the following dependencies are required:
npm i --save express axios
And it can be run using:
node server.js
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}
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,
});
});
});
To run the above, first of all, add the required dependencies:
npm install --save-dev jest moxios supertest
Run tests with:
npx jest
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
andresponse
. Status will be the status in theaxios
fetch response andresponse
will be thedata
in theaxios
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;
};
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`);
});
For a walkthrough of the example of the above, see Setting up Express and Redis with Docker compose.
To get it up and running:
- clone https://github.com/HugoDF/express-supertest-moxios
- have Docker Community Edition running
- run
docker-compose up
Once the app is running, we can do the following:
- Store some data:
curl http://localhost:3000/store/my-key\?some\=value\&some-other\=other-value
Success
- Retrieve that data:
curl http://localhost:3000/my-key
{
"some": "value",
"some-other": "other-value"
}
Testing strategy π
We have a decision to make here:
- Mock Redis
- 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({});
});
});
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)