On a recent project, we used Jest to orchestrate the tests across our web app, which uses a combination of Puppeteer, SuperTest, and Enzyme for testing. Here’s an overview of how we wired up and tested each part of the app, the tools/libraries we used, and the orchestration we did to run all of the tests together.
It’s worth noting that our Express API and our React app live in the same repository. Your results may vary with your setup.
1. Jest
Jest is a very popular JavaScript testing library that handles everything you could want in a test suite, including test running and test discovery. Each of our test suites is described with a jest.config.js
file. These files label the test suite, describe where to find the tests, and outline what transformations Jest should use (like compiling tests with TypeScript). In addition to a config for each of the test suites, there’s a fourth config file to orchestrate running the tests together.
2. Integration Tests with Puppeteer
These are the highest level tests we have in our app. They run using Puppeteer to interact with the browser in the same way that a user would while clicking through the app.
The Jest Puppeteer library does the heavy lifting to configure this test suite. The integration test suite launches the backend so it can use the API and browse to the React app.
In this spirit of keeping these full integration tests, we had the tests use a real database. We connected to a test database instead of our regular database, and we cleared the databases after testing and after each test.
The rest of the configuration for this test suite exists in two files: one jest.config.js
file and one jest-puppeteer.config.js
file. Ours looks like this:
module.exports = {
launch: {
headless: process.env.HEADLESS !== 'false',
args: [
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-first-run',
'--no-sandbox',
'--no-zygote',
]
},
server: {
command: 'npm run start',
port: process.env.TEST_PORT || '9090',
launchTimeout: 10000,
debug: false || process.env.CI
}
};
The launch section describes details for launching the web browser used for testing. The HEADLESS
environment variable disables headless mode so that we can inspect and debug the tests while they’re running. The args
are an assortment of command line flags to speed up the rendering and execution of the tests. The server section describes options for launching our server to use for testing. We configure a port for tests so we can run our tests at the same time we have our development server up. Jest Puppeteer also supports running a server separately and directing the tests to use that server.
Some of the test failures can be ambiguous. Still, our integration tests are the most valuable tests we have, and Jest Puppeteer is one of the best integration testing libraries I’ve used.
3. API Tests with SuperTest
These tests simulate making requests against our API server in the same way that a web app would.
We use SuperTest to run our API tests. SuperTest takes an instance of a node server, makes HTTP requests to the server, and tests the response it gets back. Because these tests are making requests against the server, they have a similar database setup to the integration tests.
SuperTest requires an easily-accessible instance of your application. When we first set up the app, we were configuring our express app and starting listening in the same file. Afterward, we set up one file to configure and export the express app, and we set up another file to import the app, set up the database connection, and start listening with the server.
The config file for this suite is very simple, so I won’t be providing it here. Here’s an example of an API test with SuperTest configuration. This example includes a few calls to our database service, but those aren’t necessary to use SuperTest.
it('should be able to create a new player for an org', async done => {
const superApp = supertest(app);
const { token, user } = await createUserGenerateToken();
await Organization.create({ name: 'Sample Org', logoLink: '', users: [user] }).save();
const result = await superApp
.post('/api/players')
.set('Cookie', [`jwt=${token}`])
.send({ nickname: 'Jarek', accounts: { twitch: 'wojonatior' } });
expect(result.status).toEqual(200);
expect(result.body).toHaveProperty('id');
expect(result.body.nickname).toBe('nshue');
expect(result.body.twitchUsername).toBe('nshue');
const org = await Organization.findOneOrFail({ name: 'Sample Org' }, { relations: ['players'] });
expect(org.players.length).toEqual(1);
expect(org.players[0].nickname).toEqual('nshue');
done();
});
4. React Component Tests with Enzyme
The last suite we have is for unit testing our React components. These tests run our React components’ sandboxed circumstances to ensure they meet our requirements.
We use Enzyme to simulate mounting our components in a React app and feeding them props based on the circumstances we want to test. From there we can test that the React component renders the content we expect.
I won’t talk about Enzyme too much because there are many other guides on how to set up and effectively use Enzyme as a React testing tool, but I’ll include an example below. The config file for this suite is very simple, so I won’t be providing it here.
it('Toggles displaying the links contained in the group', () => {
const links = [
{ location: 'spin.atomicobject.com', title: 'Blog'},
{ location: 'atomicobject.com/team', title: 'Team'},
];
const wrapper = mount(
<NavLinkGroup groupName="platforms" links={links}/>
);
expect(wrapper.find('.nav-link-group')).toHaveLength(1);
expect(wrapper.find('.grouped-link')).toHaveLength(0);
wrapper.find('.nav-link-group').simulate('click');
expect(wrapper.find('.grouped-link')).toHaveLength(2);
});
5. Putting it All Together
We have three independent test suites, but we can use another jest.config
to run all of our tests as one suite. Altogether, we have four separate jest.config.js files — one for each test suite and one to run all of our suites together.
I didn’t mention this earlier, but each suite-specific config file uses the name
and displayName
properties to identify the test suite. Our jest.all.config.js
looks like this:
module.exports = {
projects: [
'./jest.config.js',
'./integration/jest.config.js',
'./app/jest.config.js'
]
};
This file is really simple; it references each of the projects to run together. Since this config file lives alongside the API config file, we’ll need to specify which config file to use when we run the tests from the command line. The command to run the Jest All config looks like this:
jest -c jest.all.config.js
Setting up test suites can be a frustrating and tedious part of setting up a project, especially since it’s something you only work on once in a while. We put a lot of work into getting these suites to play together. Hopefully, it will save you some frustration.