End-to-end testing for Express + MongoDB apps using Supertest and Jest

  • jest
    jest
  • mongodb
    mongodb
  • expressjs
    expressjs
  • typescript
    typescript
  • mongoose
    mongoose
Published on 2024/12/20

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:

https://shinagawa-web.com/en/blogs/express-mongodb-rest-api-development-with-typescript

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.

Image from Gyazo

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.

Image from Gyazo

We also implement tests for the controller. These are run with Jest, and MongoDB is replaced with a mock.

Image from Gyazo

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.

Image from Gyazo

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:

https://shinagawa-web.com/en/blogs/express-mongodb-rest-api-development-with-typescript

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.

src/services/todo.ts
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.

src/controllers/todo.ts
+ 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.

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.

index.ts
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.

jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

Controller tests

We will write test code for getTodos in the controller.

src/controllers/todo.test.ts
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.

Image from Gyazo

API tests

Next, we will test the GET method.

We will access Express using supertest.

src/routes/todo.test.ts
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.

Image from Gyazo

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:

src/services/todo.ts
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.

src/controllers/todo.ts

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.

src/controllers/todo.test.ts
  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

Image from Gyazo

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.

src/routes/todo.test.ts
  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.

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form