End-to-end testing for Express + MongoDB apps using Supertest and Jest
Introduction
End-to-end (E2E) testing is an important step for verifying the behavior of an entire application. In particular, testing backend APIs is essential for ensuring response correctness. This article explains in detail how to implement end-to-end tests for an Express application using Supertest and Jest. You will also learn how to test the entire application in practical scenarios by replacing MongoDB access with mocks. This enables you to build a highly reliable backend.
For the Express + MongoDB sample code, we will implement test code this time based on the code from the following blog post:
Goal of this article
In the previous structure, the controller was responsible for database access as well, but to make testing easier, we added a new service layer and separated out the database access part.
For API tests, we use Supertest to start Express, send requests, and check the responses. At that time, the service is replaced with a mock.
We also implement tests for the controller. These are run with Jest, and MongoDB is replaced with a mock.
Since the tests use Jest, which is a test framework, those who have used Jest before should be able to understand and implement this fairly quickly.
What is Supertest?
Supertest is a library for HTTP testing and is well suited for end-to-end testing of server-side code such as Express applications.
Features of Supertest:
- Good compatibility with Express applications: You can directly test API endpoints.
- Simulation of real requests: You can test as if sending requests from an actual client and verify response correctness.
- Simple API: You can write concise test code using intuitive methods such as .get(), .post(), and .expect().
- Can be used with other test tools: By combining it with frameworks like Jest, you can build an integrated test environment.
With Supertest, you can verify that your API behaves as intended and perform tests from a perspective close to that of end users.
What is Jest?
Jest is a JavaScript and TypeScript test framework developed by Facebook, characterized by simplicity and flexibility.
Features of Jest:
- Rich functionality: Mocks, snapshot testing, and testing of asynchronous code can be done easily.
- TypeScript support: By using ts-jest, it can be smoothly integrated into TypeScript projects.
- High adoption and community: Documentation and resources are abundant, making problem solving easier.
- Fast execution speed: Parallel execution of test cases and caching enable a fast test cycle.
By using Jest, you can establish an environment that allows efficient testing while maintaining code quality.
Benefits of combining Jest and Supertest
By adopting Jest as the test framework and combining it with Supertest as a helper tool, you gain the following benefits:
- Comprehensive testing: Covers a wide range from unit tests to API tests.
- Fast debugging: It is easy to identify where errors occur, and test results are visually easy to understand.
- Reproducible tests: By using mocks while also testing actual endpoints, you can provide a stable test environment.
Leveraging Jest and Supertest allows you to build an efficient and highly reliable API testing environment.
Test design
In the previous article, when we built the Express + MongoDB environment, we implemented it with two layers (routing and controller).
Since we want to mock database access, we will separate database access from the controller and put the code into a state that is easy to test before writing the test code.
Ultimately, we assume that test code will be created for both routing and controllers.
Refactoring
We will refactor the code we have written so far to make it easier to write test code.
You may find it easier to visualize by referring to the following blog post:
Separating database access logic from the controller
We separate the logic so that database access can be replaced with a mock.
We newly prepare a service layer and implement the database access logic there.
import Todo from '../models/todo';
export const getTodos = async () => {
return await Todo.find();
};
In the controller, we modify it to call and execute the above function.
+ import * as todoServices from '../services/todo'
export const getTodos = asyncHandler(async (req: Request, res: Response) => {
- const todos = await Todo.find()
+ const todos = await todoServices.getTodos()
res.json(todos);
});
Separating Express server startup logic
The current src/index.ts file both creates the server instance and starts the server.
If we use this file to test with supertest, the server will keep running, so we split the creation of the server instance and the server startup into separate files.
We create the server instance in app.ts.
import express from 'express';
import cors from 'cors';
import todoRoutes from './routes/todo'
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/todos', todoRoutes)
export default app
The original index.ts will only handle connecting to MongoDB and starting Express.
import app from './app'
import connectDB from './lib/db';
connectDB();
app.listen(3001);
console.log('Express WebAPI listening on port 3001');
Installing packages
Install jest, supertest, and ts-jest, which runs TypeScript test code.
npm install -D supertest jest ts-jest @types/jest @types/supertest
jest.config settings
Configure Jest to run tests using ts-jest.
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
Controller tests
We will write test code for getTodos in the controller.
import { NextFunction, Request, Response } from 'express';
import * as todoService from '../services/todo';
import { getTodos } from './todo';
import Todo from '../models/todo'
import mongoose from 'mongoose';
describe('Todo Controller', () => {
const mockReq = {} as Request;
const mockRes = {
json: jest.fn(),
status: jest.fn(() => mockRes),
} as unknown as Response;
const mockNext = jest.fn() as NextFunction;
beforeEach(() => {
jest.clearAllMocks();
});
test('getTodos returns a list of Todos when executed', async () => {
const todos = [
new Todo({
_id: new mongoose.Types.ObjectId('507f191e810c19729de860ea'),
title: 'Test Todo',
isCompleted: false,
createdAt: new Date(),
updatedAt: new Date(),
}),
];
const spy = jest.spyOn(todoService, 'getTodos').mockResolvedValue(todos);
await getTodos(mockReq, mockRes, mockNext);
expect(spy).toHaveBeenCalledTimes(1);
expect(mockRes.json).toHaveBeenCalledWith(todos);
spy.mockRestore();
});
});
The code is explained below.
We replace the Express request and response with mocks.
const mockReq = {} as Request;
const mockRes = {
json: jest.fn(),
status: jest.fn(() => mockRes),
} as unknown as Response;
const mockNext = jest.fn() as NextFunction;
We create sample data. We use the model to align the data types.
const todos = [
new Todo({
_id: new mongoose.Types.ObjectId('507f191e810c19729de860ea'),
title: 'Test Todo',
isCompleted: false,
createdAt: new Date(),
updatedAt: new Date(),
}),
];
We replace the database access logic with a mock and define the previously created sample data as the response.
const spy = jest.spyOn(todoService, 'getTodos').mockResolvedValue(todos);
We execute the controller’s getTodos.
await getTodos(mockReq, mockRes, mockNext);
We verify that the mocked database access logic was executed.
expect(spy).toHaveBeenCalledTimes(1);
We verify that the return value of getTodos matches the sample data.
expect(mockRes.json).toHaveBeenCalledWith(todos);
Verifying behavior
Run the tests with the following command:
npx jest
One test completed successfully.
API tests
Next, we will test the GET method.
We will access Express using supertest.
import request from 'supertest';
import app from '../app';
import * as todoService from '../services/todo';
import Todo from '../models/todo';
import mongoose from 'mongoose';
describe('Todo API', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('GET /todos returns a list of Todos when accessed', async () => {
const todos = [
new Todo({
_id: new mongoose.Types.ObjectId('507f191e810c19729de860ea'),
title: 'Test Todo',
isCompleted: false,
createdAt: new Date(),
}),
];
const formattedTodos = todos.map(todo => ({
...todo.toObject(),
_id: todo._id.toString(),
createdAt: todo.createdAt.toISOString(),
}));
const spy = jest.spyOn(todoService, 'getTodos').mockResolvedValue(todos);
const res = await request(app).get('/todos');
expect(res.status).toBe(200);
expect(res.body).toEqual(formattedTodos);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
})
The code is explained below.
We prepare the sample data and the API response data in advance.
Since the API response is in JSON format, we need to convert the object ID and date to strings before comparing.
const todos = [
new Todo({
_id: new mongoose.Types.ObjectId('507f191e810c19729de860ea'),
title: 'Test Todo',
isCompleted: false,
createdAt: new Date(),
}),
];
const formattedTodos = todos.map(todo => ({
...todo.toObject(),
_id: todo._id.toString(),
createdAt: todo.createdAt.toISOString(),
}));
As explained in the controller tests, we replace the database access logic with a mock and define the previously created sample data as the response.
const spy = jest.spyOn(todoService, 'getTodos').mockResolvedValue(todos);
We use supertest to send a GET request to /todos and receive the response.
const res = await request(app).get('/todos');
We verify the following three points:
- The response status is 200
- The response data matches the expected data
- The database access function replaced with a mock was executed
expect(res.status).toBe(200);
expect(res.body).toEqual(formattedTodos);
expect(spy).toHaveBeenCalledTimes(1);
Verifying behavior
Run the tests with the following command:
npx jest
Two tests completed successfully.
Refactoring the remaining code
So far, we have refactored and implemented tests for data retrieval logic.
We will proceed with test implementation for the other logic in the same way.
Separate database access into the service layer:
export const addTodo = async (data: any) => {
const newTodo = new Todo(data);
return await newTodo.save();
};
export const updateTodo = async (id: string, data: any) => {
return await Todo.findByIdAndUpdate({ _id: id }, data, { new: true });
};
export const deleteTodo = async (id: string) => {
return await Todo.findByIdAndDelete(id);
};
In the controller, we modify it to call and execute the above functions.
export const addTodo = asyncHandler(async (req: Request, res: Response) => {
- const newTodo = new Todo(req.body);
- const data = await newTodo.save()
+ const data = await todoServices.addTodo(req.body);
res.json(data)
});
export const updateTodo = asyncHandler(async (req: Request, res: Response): Promise<void> => {
- const todo = await Todo.findByIdAndUpdate(
- { _id: req.params.todoId },
- req.body,
- { new: true }
- );
+ const todo = await todoServices.updateTodo(req.params.todoId, req.body);
if (!todo) {
res.status(404).json({ message: "Todo not found" });
}
res.json(todo)
});
export const deleteTodo = asyncHandler(async (req: Request, res: Response): Promise<void> => {
- const todo = await Todo.findByIdAndDelete(req.params.todoId);
+ const todo = await todoServices.deleteTodo(req.params.todoId);
if (!todo) {
res.status(404).json({ message: "Todo not found" });
}
res.json({ message: "Todo deleted successfully", todo });
});
Controller tests (other than data retrieval)
We will write test code for addTodo, updatedTodo, and deleteTodo.
Note that updatedTodo and deleteTodo are defined to return a dedicated message when the data to be updated or deleted does not exist, so we will also test those cases.
test('addTodo adds a new Todo when executed', async () => {
const newTodo = new Todo({
_id: new mongoose.Types.ObjectId('507f191e810c19729de860eb'),
title: 'New Todo',
isCompleted: false,
createdAt: new Date(),
updatedAt: new Date(),
});
mockReq.body = { title: 'New Todo', isCompleted: false };
const spy = jest.spyOn(todoService, 'addTodo').mockResolvedValue(newTodo);
await addTodo(mockReq, mockRes, mockNext);
expect(spy).toHaveBeenCalledWith(mockReq.body);
expect(mockRes.json).toHaveBeenCalledWith(newTodo);
spy.mockRestore();
});
test('updateTodo updates the specified Todo when executed', async () => {
const updatedTodo = new Todo({
_id: '507f191e810c19729de860ec',
title: 'Updated Todo',
isCompleted: true,
createdAt: new Date(),
updatedAt: new Date(),
});
mockReq.params = { todoId: '507f191e810c19729de860ec' };
mockReq.body = { title: 'Updated Todo', isCompleted: true };
const spy = jest.spyOn(todoService, 'updateTodo').mockResolvedValue(updatedTodo);
await updateTodo(mockReq, mockRes, mockNext);
expect(spy).toHaveBeenCalledWith(mockReq.params.todoId, mockReq.body);
expect(mockRes.json).toHaveBeenCalledWith(updatedTodo);
spy.mockRestore();
});
test('updateTodo returns { message: "Todo not found" } when executed', async () => {
mockReq.params = { todoId: '507f191e810c19729de860ec' };
mockReq.body = { title: 'Updated Todo', isCompleted: true };
const spy = jest.spyOn(todoService, 'updateTodo').mockResolvedValue(null);
await updateTodo(mockReq, mockRes, mockNext);
expect(spy).toHaveBeenCalledWith(mockReq.params.todoId, mockReq.body);
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockRes.json).toHaveBeenCalledWith({ message: "Todo not found" });
spy.mockRestore();
});
test('deleteTodo deletes the specified Todo when executed', async () => {
const deletedTodo = new Todo({
_id: '507f191e810c19729de860ed',
title: 'Deleted Todo',
isCompleted: false,
createdAt: new Date(),
updatedAt: new Date(),
});
mockReq.params = { todoId: '507f191e810c19729de860ed' };
const spy = jest.spyOn(todoService, 'deleteTodo').mockResolvedValue(deletedTodo);
await deleteTodo(mockReq, mockRes, mockNext);
expect(spy).toHaveBeenCalledWith(mockReq.params.todoId);
expect(mockRes.json).toHaveBeenCalledWith({
message: 'Todo deleted successfully',
todo: deletedTodo,
});
spy.mockRestore();
});
test('deleteTodo returns { message: "Todo not found" } when executed', async () => {
mockReq.params = { todoId: '507f191e810c19729de860ec' };
const spy = jest.spyOn(todoService, 'deleteTodo').mockResolvedValue(null);
await deleteTodo(mockReq, mockRes, mockNext);
expect(spy).toHaveBeenCalledWith(mockReq.params.todoId);
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockRes.json).toHaveBeenCalledWith({ message: "Todo not found" });
spy.mockRestore();
});
Verifying behavior
Run the tests with the following command:
npx jest
Routing tests (other than data retrieval)
We will write tests for the POST, PATCH, and DELETE methods.
For PATCH and DELETE, there are also cases where a 404 is returned, so we will write tests for those as well.
test('POST /todos adds a new Todo when accessed', async () => {
const newTodo = new Todo({
_id: new mongoose.Types.ObjectId('507f191e810c19729de860eb'),
title: 'New Todo',
isCompleted: false,
createdAt: new Date(),
});
const todoData = {
title: 'New Todo',
isCompleted: false,
};
const spy = jest.spyOn(todoService, 'addTodo').mockResolvedValue(newTodo);
const res = await request(app).post('/todos').send(todoData);
expect(res.status).toBe(200);
expect(res.body).toEqual({
...newTodo.toObject(),
_id: newTodo._id.toString(),
createdAt: newTodo.createdAt.toISOString(),
});
expect(spy).toHaveBeenCalledWith(todoData);
spy.mockRestore();
});
test('PATCH /todos/:todoId updates the Todo when accessed', async () => {
const updatedTodo = new Todo({
_id: new mongoose.Types.ObjectId('507f191e810c19729de860ec'),
title: 'Updated Todo',
isCompleted: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const updateData = { title: 'Updated Todo', isCompleted: true };
const spy = jest.spyOn(todoService, 'updateTodo').mockResolvedValue(updatedTodo);
const res = await request(app).patch(`/todos/${updatedTodo._id}`).send(updateData);
expect(res.status).toBe(200);
expect(res.body).toEqual({
...updatedTodo.toObject(),
_id: updatedTodo._id.toString(),
createdAt: updatedTodo.createdAt.toISOString(),
updatedAt: updatedTodo.updatedAt?.toISOString(),
});
expect(spy).toHaveBeenCalledWith(updatedTodo._id.toString(), updateData);
spy.mockRestore();
});
test('PATCH /todos/:todoId returns 404 for a non-existent Todo', async () => {
const todoId = '507f191e810c19729de860ef';
jest.spyOn(todoService, 'updateTodo').mockResolvedValue(null);
const res = await request(app).patch(`/todos/${todoId}`).send({ title: 'Non-existent Todo' });
expect(res.status).toBe(404);
expect(res.body).toEqual({ message: 'Todo not found' });
});
test('DELETE /todos/:todoId deletes the Todo when accessed', async () => {
const deletedTodo = new Todo({
_id: new mongoose.Types.ObjectId('507f191e810c19729de860ed'),
title: 'Deleted Todo',
isCompleted: false,
createdAt: new Date(),
updatedAt: new Date(),
});
const spy = jest.spyOn(todoService, 'deleteTodo').mockResolvedValue(deletedTodo);
const res = await request(app).delete(`/todos/${deletedTodo._id}`);
expect(res.status).toBe(200);
expect(res.body).toEqual({
message: 'Todo deleted successfully',
todo: {
...deletedTodo.toObject(),
_id: deletedTodo._id.toString(),
createdAt: deletedTodo.createdAt.toISOString(),
updatedAt: deletedTodo.updatedAt?.toISOString(),
},
});
expect(spy).toHaveBeenCalledWith(deletedTodo._id.toString());
spy.mockRestore();
});
test('DELETE /todos/:todoId returns 404 for a non-existent Todo', async () => {
const todoId = '507f191e810c19729de860ef';
jest.spyOn(todoService, 'deleteTodo').mockResolvedValue(null);
const res = await request(app).patch(`/todos/${todoId}`);
expect(res.status).toBe(404);
expect(res.body).toEqual({ message: 'Todo not found' });
});
Conclusion
In this article, we explained how to efficiently implement end-to-end tests for Express applications using Supertest and Jest.
We also replaced MongoDB access with mocks to achieve stable test results.
By implementing E2E tests, you can improve the reliability of your application and detect potential bugs early. Incorporate this approach into your projects to build robust, high-quality backend APIs.
If you have any questions, please feel free to contact me.
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
Complete Guide to Web Accessibility: From Automated Testing with Lighthouse / axe and Defining WCAG Criteria to Keyboard Operation and Screen Reader Support
2023/11/21Robust Authorization Design for GraphQL and REST APIs: Best Practices for RBAC, ABAC, and OAuth 2.0
2024/05/13How to Easily Build a Web API with Express and MongoDB [TypeScript Compatible]
2024/12/09Express (+ TypeScript) Beginner’s Guide: How to Quickly Build Web Applications
2024/12/07Complete Guide to Refactoring React: Improve Your Code with Modularization, Render Optimization, and Design Patterns
2025/01/13Test Automation with Jest and TypeScript: A Complete Guide from Basic Setup to Writing Type-Safe Tests
2023/09/13ESLint / Prettier Introduction Guide: Thorough Explanation from Husky, CI/CD Integration, to Visualizing Code Quality
2024/02/12Practical Microservices Strategy: The Tech Stack Behind BFF, API Management, and Authentication Platform (AWS, Keycloak, gRPC, Kafka)
2024/03/22





