From Unit to End-to-End Testing with Jest and Supertest

Camilo Salazar
10 min readMar 16, 2023

--

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.

--

--

Camilo Salazar

Driven to share powerful ideas and experiences that ignite inspiration, deepen knowledge, and empower others