From Unit to End-to-End Testing with Jest and Supertest
In this tutorial we covered how to write unit tests using Jest, with a step-by-step guide to creating a test case for a user service. Additionally, we explained how to use Supertest to write end-to-end tests, with an example test suite for a user component that adds, returns, logs in, and gets a user.
Introduction
As a software developer, you know the importance of writing clean and functional code. But even the best code can have bugs, leading to unhappy users and in some cases serious consequences. That’s why testing your software is crucial to ensure it works as intended.
In my previous blog post, I showed you how to create a scalable Express.js application with essential functions like adding users, getting a single user, and getting all users. However, building an application is only half the battle; you also need to verify that it works correctly. In this blog post, I’ll guide you through the process of testing your Node.js application, so you can identify and fix any issues before they become problems.
In this post, we will cover the following topics:
- Setting up a testing framework for your Node.js Express application
- Adding an error middleware for your application
- Adding a new functionality to your application
- Writing unit tests for your application’s services
- Writing end to end tests for your application
- Running and analyzing your tests, including generating test coverage reports
By the end of this post, you’ll have a solid foundation for testing your Node.js Express application and can feel confident in delivering high-quality, reliable software to your users. Let’s dive into the world of testing and ensure that our application works flawlessly.
The complete code for this Node.js Express backend can be found in the following GitHub repository link, where you can explore all the files and code used to build it.
Add new development dependencies
Before writing any tests, we need to install two dependencies: jest
and supertest
. jest
is a testing framework that allows us to easily write and run tests, including comparing expected and actual responses from our application. supertest
is a library that provides an easy-to-use interface for sending HTTP requests to our application from within our tests, allowing us to test our application's routes and handlers.
npm install --save-dev jest supertest
If you want to use eslint
for linting and babel
for code transpilation in your test files, you'll need to install two additional dependencies: @babel/plugin-transform-modules-commonjs
, ts-node
and eslint-plugin-jest
. These packages will allow you to use jest-specific linting rules and transpile your test files to CommonJS modules for compatibility with Node.js.
npm install --save-dev @babel/plugin-transform-modules-commonjs eslint-plugin-jest ts-node
Finally, to use jest
and supertest
with your Node.js application, you'll need to configure several files including .babelrc
, .eslintrc
, jest.config.ts
, and package.json
. These configuration files will ensure that jest
runs smoothly with your application and allows you to define the testing environment, test files to include or exclude, and other important settings.
Don’t forget to add a test
script to your package.json
file to run jest
and execute your tests. This will allow you to easily run your test suite with a single command and ensure that your application is functioning as expected.
// ./package.json
{
"name": "express-tutorial",
"type": "module",
"version": "1.0.0",
"description": "",
"main": "src/index.js",
"scripts": {
"start": "nodemon --inspect src/index.js",
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"body-parser": "^1.20.1",
"express": "^4.18.2"
},
"devDependencies": {
"@babel/plugin-transform-modules-commonjs": "^7.21.2",
"eslint": "^8.34.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jest": "^27.2.1",
"jest": "^29.5.0",
"nodemon": "^2.0.20",
"prettier": "2.8.4",
"supertest": "^6.3.3",
"ts-node": "^10.9.1"
}
}
// ./jest.config.ts
export default {
clearMocks: true,
collectCoverage: true,
coverageDirectory: 'coverage',
coverageProvider: 'v8',
};
// ./.eslintrc.json
{
"env": {
"jest/globals": true,
"browser": true,
"es2021": true
},
"plugins": [
"jest"
],
"extends": "airbnb-base",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"import/extensions": [
"error",
{
"js": "ignorePackages"
}
]
}
}
// ./.babelrc
{
"env": {
"test": {
"plugins": ["@babel/plugin-transform-modules-commonjs"]
}
}
}
Error middleware
Adding an error-handling middleware to your application is a great idea, as it helps catch and handle any errors that might occur during runtime.
(1) Create a new file in your project called src/middlewares/error.js
(or any other name you prefer) to define your error-handling middleware.
(2) Define a middleware function that takes four arguments: err
, req
, res
, and next
.
(3) Set the status code of the response to 500, indicating an internal server error.
(4) Create a JSON object with an error
property containing the message from the err
object.
(5) Send the JSON object as the response to the client.
// src/middlewares/error.js
export default function errorMiddleware(err, req, res, next) {
return res.status(500).send({ message: err.message });
}
(6) You can then import and use this middleware in your src/app.js
file or wherever you define your Express application:
// src/app.js
import express from 'express';
import bodyParser from 'body-parser';
import loadRoutes from './loaders/routes.js';
import errorMiddleware from './middlewares/error.js';
const app = express();
app.use(bodyParser.json());
loadRoutes(app);
app.use(errorMiddleware);
export default app;
Adding a new feature
Adding new functionality to your application is a great way to demonstrate how easy it is to maintain and scale your project structure. Here’s an example of how you could add a new endpoint to your application:
(1) Open the service
file for your user component located in the directory src/components/user/user.service.js
.
(2) Define a new function called login
that takes in the email
and password
as parameters. This function should verify the credentials and if they are correct, update the lastLogin
property for the user.
// src/components/user/user.service.js
class UserService {
constructor() {
this.users = [];
}
addUser = (user) => {
if (this.users.find((u) => u.email === user.email)) {
throw new Error('User already exists');
}
this.users.push(user);
return user;
};
getUsers = () => this.users;
getUser = (id) => {
const user = this.users.find((u) => u.id === id);
return user;
};
login = (email, password) => {
const user = this.users.find((u) => u.email === email);
if (!user) throw new Error('Wrong email');
if (user.password !== password) throw new Error('Wrong password');
user.lastLogin = new Date();
return user;
};
}
export default UserService;
(3) Open the controller
file for your user component located in the directory src/components/user/user.controller.js
.
(4) Define a new function called login
that takes in the email
and password
from the request body and passes them to the login
function in your service.
// src/components/user/user.controller.js
import User from './user.entities.js';
class UserController {
constructor(userService) {
this.userService = userService;
}
createUser = (req, res) => {
const user = new User(req.body.email, req.body.password, req.body.age);
return res.status(201).send(this.userService.addUser(user));
};
getUsers = (_, res) => res.status(200).send(this.userService.getUsers());
getUser = (req, res) => {
const { id } = req.params;
return res.status(200).send(this.userService.getUser(id));
};
login = (req, res) => {
const { email, password } = req.body;
return res.status(200).send(this.userService.login(email, password));
};
}
export default UserController;
(5) Open the router
file for your user component located in the directory src/components/user/user.router.js
.
(6) Define a new route called /login
that will be handled by the login
function in your controller.
// src/components/user/user.router.js
import express from 'express';
class UserRouter {
constructor(userController) {
this.userController = userController;
}
getRouter() {
const router = express.Router();
router.route('/:id').get(this.userController.getUser);
router.route('/').get(this.userController.getUsers);
router.route('/').post(this.userController.createUser);
router.route('/login').post(this.userController.login);
return router;
}
}
export default UserRouter;
(7) Open the entities
file for your user component located in the directory src/components/user/user.entities.js
.
(8) Add a new property called lastLogin
to the user entity to store the datetime of the user's last successful login.
// src/components/user/user.entities.js
import crypto from 'crypto';
class User {
constructor(email, password, age, lastLogin) {
this.id = crypto.randomUUID();
this.email = email;
this.password = password;
this.age = age;
this.lastLogin = lastLogin;
}
toJSON() {
return {
id: this.id,
email: this.email,
age: this.age || null,
lastLogin: this.lastLogin || null,
};
}
}
export default User;
Unit testing
Are you ready to unlock the full potential of your code and ensure its reliability? So get ready to dive in and write some unit tests!
(1) We don’t need to import Jest explicitly as it is automatically handled for us. However, we do need to import the necessary modules at the top of the test file, in this case, our User
entity and the UserService
.
// ./src/components/user/user.service.test.js
import User from './user.entities';
import UserService from './user.service';
// ...
(2) Start a describe
block with a string describing the overall test suite. In this case, it's testing the UserService
class:
// ./src/components/user/user.service.test.js
// ...
describe('UserService', () => {
// ...
});
(3) We will create two variables, userService
and usersToAdd
, to be used later in the test cases. We will initialize them in a beforeEach
block, which will run before each individual test case, ensuring that our tests start with a clean state.
// ./src/components/user/user.service.test.js
// ...
let userService;
let usersToAdd;
beforeEach(() => {
userService = new UserService();
usersToAdd = [
new User('user1@example.com', 'password123'),
new User('user2@example.com', 'password456'),
new User('user3@example.com', 'password789'),
];
usersToAdd.forEach((user) => {
userService.users.push(user);
});
});
// ...
The userService
variable is set to a new instance of the UserService
class, while usersToAdd
is an array of three User
objects. The forEach
loop then adds those three users to the userService
instance's users
array.
(4) Create a new describe
block for the addUser
method:
// ./src/components/user/user.service.test.js
// ...
describe('addUser', () => {
// ...
});
// ...
(5) Within the describe
block, create one test case using the it
function. It should test that a new user can be added to the users
array property of the userService
instance.
// ./src/components/user/user.service.test.js
// ...
it('should add a new user to the users array', () => {
// ...
});
// ...
(6) We create a new User
object and add it to the userService
instance with the addUser
method. Then, use the expect
function to make assertions about the state of the users
array:
// ./src/components/user/user.service.test.js
// ...
it('should add a new user to the users array', () => {
const user = new User('test@example.com', 'password123');
userService.addUser(user);
expect(userService.users.length).toBe(4);
expect(userService.users[3]).toBe(user);
});
// ...
And that’s it! You can follow a similar process to write the other test cases in the file.
// ./src/components/user/user.service.test.js
import User from './user.entities';
import UserService from './user.service';
describe('UserService', () => {
let userService;
let usersToAdd;
beforeEach(() => {
userService = new UserService();
usersToAdd = [
new User('user1@example.com', 'password123'),
new User('user2@example.com', 'password456'),
new User('user3@example.com', 'password789'),
];
usersToAdd.forEach((user) => {
userService.users.push(user);
});
});
describe('addUser', () => {
it('should add a new user to the users array', () => {
const user = new User('test@example.com', 'password123');
userService.addUser(user);
expect(userService.users.length).toBe(4);
expect(userService.users[3]).toBe(user);
});
it('should throw an error if a user with the same email already exists', () => {
const existingUser = new User('user1@example.com', 'password123');
expect(() => userService.addUser(existingUser)).toThrowError('User already exists');
expect(userService.users.length).toBe(3);
});
});
describe('getUsers', () => {
it('should return an empty array if there are no users in the database', () => {
const newUserService = new UserService();
const users = newUserService.getUsers();
expect(users).toEqual([]);
});
it('should return an array of all users in the database', () => {
const users = userService.getUsers();
expect(users).toEqual(usersToAdd);
});
});
describe('getUser', () => {
it('should return undefined if the user is not found', () => {
const user = userService.getUser('3');
expect(user).toBeUndefined();
});
it('should return the correct user if the user is found', () => {
const foundUser = userService.getUser(usersToAdd[0].id);
expect(foundUser).toEqual(usersToAdd[0]);
expect(foundUser).not.toEqual(usersToAdd[1]);
});
});
describe('login', () => {
it('should throw an error if the email is not found', () => {
expect(() => userService.login('user4@example.com', '123456').toThrowError('Wrong email'));
expect(usersToAdd[0].lastLogin).toBeUndefined();
});
it('should throw an error if the password is incorrect', () => {
expect(() => userService.login('user1@example.com', '123456').toThrowError('Wrong password'));
expect(usersToAdd[0].lastLogin).toBeUndefined();
});
it('should log in the user and update the last login time', () => {
const beforeLogin = new Date();
const loggedInUser = userService.login('user1@example.com', 'password123');
const afterLogin = new Date();
expect(loggedInUser).toEqual(usersToAdd[0]);
expect(usersToAdd[0].lastLogin.getTime()).toBeGreaterThanOrEqual(beforeLogin.getTime());
expect(usersToAdd[0].lastLogin.getTime()).toBeLessThanOrEqual(afterLogin.getTime());
});
});
});
End to end testing
End-to-end testing is an essential part of software development as it allows us to test our entire application from start to finish. In this type of testing, we test the application as a whole, including its various components, modules, and systems, to ensure that it is working as intended.
In order to make an end-to-end test with Supertest, you need to first import the request
module from the Supertest package, as well as the instance of your application that you want to test.
// ./src/tests/e2e.test.js
import request from 'supertest';
import app from '../app';
We will use the request(app)
method from Supertest to create an instance of the running server, and then use that instance to make HTTP requests and receive responses.
For example, in the POST /users
test case, we can use request(app).post('/users')
to make a POST request to the /users
endpoint of the server and the send()
method to send data with the request. We will then receive a response that we can test against using Jest's expect()
method.
// ./src/tests/e2e.test.js
// ...
describe('User Component', () => {
let user1;
describe('POST /users', () => {
it('should add one user', async () => {
const res = await request(app).post('/users').send({
email: 'user1@example.com',
password: '123456',
});
expect(res.statusCode).toBe(201);
expect(res.body).toHaveProperty('id');
expect(res.body.email).toBe('user1@example.com');
user1 = res.body;
});
});
// ...
Here is an entire example of an end-to-end test that covers adding a user, returning all users, logging in a user, and retrieving a user using the Supertest module:
// ./src/tests/e2e.test.js
import request from 'supertest';
import app from '../app';
describe('User Component', () => {
let user1;
describe('POST /users', () => {
it('should add one user', async () => {
const res = await request(app).post('/users').send({
email: 'user1@example.com',
password: '123456',
});
expect(res.statusCode).toBe(201);
expect(res.body).toHaveProperty('id');
expect(res.body.email).toBe('user1@example.com');
user1 = res.body;
});
});
describe('GET /users', () => {
it('should return all users', async () => {
const res = await request(app).get('/users');
expect(res.statusCode).toBe(200);
expect(res.body.length).toBe(1);
});
});
describe('POST /users/login', () => {
it('should login', async () => {
const beforeLoginDate = new Date();
const res = await request(app).post('/users/login').send({
email: 'user1@example.com',
password: '123456',
});
expect(res.statusCode).toBe(200);
expect(new Date(res.body.lastLogin).getTime()).toBeGreaterThanOrEqual(beforeLoginDate.getTime());
});
it('should not login', async () => {
const res = await request(app).post('/users/login').send({
email: 'user1@example.com',
password: '1234',
});
expect(res.statusCode).toBe(500);
});
});
describe('GET /users/:id', () => {
it('should return one user', async () => {
const res = await request(app).get(`/users/${user1.id}`);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('id');
});
});
});
Conclusion
In conclusion, testing is an essential part of software development. It helps ensure that our code works as expected and that any changes we make do not introduce new bugs or regressions. Unit testing, integration testing, and end-to-end testing are all important types of testing that developers can use to improve the quality of their code. Jest and Supertest are powerful tools that can help simplify the testing process and make it easier to catch errors and bugs early on. By following best practices and regularly testing our code, we can create more reliable, robust, and maintainable software.
If you found this tutorial helpful, be sure to follow me for more content like this, and feel free to leave comments and suggestions for future topics you’d like me to cover. Some potential ideas for future extensions could include incorporating an ORM like Prisma, or adding authentication and authorization to our application.