はじめに
エンドツーエンド(E2E)テストは、アプリケーション全体の動作を確認するための重要なステップです。特に、バックエンドAPIのテストは、レスポンスの正確性を保証するうえで欠かせません。本記事では、Supertest と Jest を使った Express アプリケーションのエンドツーエンドテストの実装方法について詳しく解説します。また、MongoDBへのアクセスをモックに置き換えることにより実践的なシナリオを通じて、アプリケーション全体をテストする方法を学びます。これにより、信頼性の高いバックエンドの構築が可能になります。
なお、Express + MongoDBのサンプルコードはコチラのブログ記事のコードとして今回はテストコードを実装します。
Supertestとは
Supertest は HTTP テスト用のライブラリで、Express アプリケーションのようなサーバーサイドコードをエンドツーエンドでテストするのに適しています。
Supertest の特徴。
- Express アプリケーションとの相性の良さ: API エンドポイントを直接テストできる。
- 実際のリクエストのシミュレーション: 実際のクライアントからリクエストを送るかのようにテストでき、レスポンスの正確性を確認可能。
- シンプルな API: .get(), .post(), .expect() といった直感的なメソッドを使って簡潔にテストコードを書ける。
- 他のテストツールとの併用: Jest などのフレームワークと組み合わせて使用することで、統合的なテスト環境を構築できる。
Supertest によって、API の動作が意図通りであることを検証し、エンドユーザーに近い視点でのテストが実現します。
Jestとは
Jest は Facebook が開発した JavaScript および TypeScript のテストフレームワークで、シンプルさと柔軟性が特徴です。
Jestの特徴。
- 豊富な機能: モックやスナップショットテスト、非同期コードのテストが簡単に実行可能。
- TypeScript サポート: ts-jest を利用することで、TypeScript プロジェクトにもスムーズに統合できる。
- 高い普及率とコミュニティ: ドキュメントやリソースが充実しており、問題解決が容易。
- 実行速度の速さ: テストケースの並列実行やキャッシング機能により、高速なテストサイクルが可能。
Jest を利用することで、コード品質を確保しつつ効率的にテストを行える環境が整います。
Jest と Supertest の組み合わせのメリット
Jest をテストフレームワークとして採用し、Supertest を補助ツールとして組み合わせることで以下のようなメリットがあります:
- 包括的なテストが可能: ユニットテストから API テストまで幅広く対応。
- 迅速なデバッグ: エラー発生箇所の特定が容易で、テスト結果も視覚的に分かりやすい。
- 再現性のあるテスト: モックを活用しつつ、実際のエンドポイントテストも行えるため、安定したテスト環境を提供。
Jest と Supertest を活用することで、効率的かつ信頼性の高い API テスト環境を構築できます。
テスト設計
前回の記事でExpress + MongoDBの環境を構築した際は2層(ルーティング、コントローラー)で実装しました。
データベースアクセスについてはモックにしたいためコントローラーからデータベースアクセスについては分離させテストコードを書きやすい状態にしてからテストコードを書いていきます。
最終的にはルーティング、コントローラーでそれぞれテストコードが作られる想定です。
リファクタリング
テストコードを書きやすくするためにこれまで書いてきたコードをリファクタリングしていきます。
下記ブログ記事をご参考頂くとよりイメージが湧くかと思います。
コントローラーからデータベースアクセスの処理を分離
データベースアクセスをモックに置き換えるために処理を分離します。
新たにサービス層を用意してデータベースアクセス処理を実装します。
import Todo from '../models/todo';
export const getTodos = async () => {
return await Todo.find();
};
コントローラーでは上記の関数を呼び出して実行するよう修正します。
+ 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);
});
Express
サーバー起動の処理を分離
現在のsrc/index.ts
ファイルはサーバー用のインスタンスを生成しサーバーを起動するまで行われています。
このファイルを使ってsupertest
でテストを行うとサーバーが起動しっぱなしになるため、サーバー用のインスタンスを生成とサーバー起動はファイルを分割します。
サーバー用のインスタンスを生成するファイルは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
元々のindex.ts
は、MongoDBへの接続とExpress
の起動のみ行います。
import app from './app'
import connectDB from './lib/db';
connectDB();
app.listen(3001);
console.log('Express WebAPI listening on port 3001');
パッケージのインストール
jest
、supertest
及びTypeScriptのテストコードを実行するts-jest
をインストールします。
npm install -D supertest jest ts-jest @types/jest @types/supertest
jest.configの設定
ts-jest
を使ってテストを実施するよう設定します。
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
コントローラーのテスト
コントローラーでgetTodos
のテストコードを書いていきます。
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を実行するとTodo一覧が返ってくること', 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();
});
});
コードの解説を以下に記載します。
Express
のリクエストやレスポンスをモックに置き換えます。
const mockReq = {} as Request;
const mockRes = {
json: jest.fn(),
status: jest.fn(() => mockRes),
} as unknown as Response;
const mockNext = jest.fn() as NextFunction;
サンプルのデータを作成します。データの型を揃えるためにモデルを使用しています。
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);
コントローラーのgetTodos
を実行します。
await getTodos(mockReq, mockRes, mockNext);
モックにしたデータベースアクセスの処理が動いたことを確認しています。
expect(spy).toHaveBeenCalledTimes(1);
getTodos
の戻り値がサンプルのデータと一致していることを確認しています。
expect(mockRes.json).toHaveBeenCalledWith(todos);
動作確認
下記コマンドでテストを実行します。
npx jest
テストが1件正常に終了しました。
ルーティングのテスト
次はGETメソッドのテストを行います。
Express
へのアクセスを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 にアクセスするとTodo一覧が返ってくること', 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();
});
})
コードの解説を以下に記載します。
サンプルのデータとAPIのレスポンスのデータを事前に用意しています。
APIのレスポンスはJSON形式なのでオブジェクトIDや日付については文字列に変換してから比較する必要があります。
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);
supertest
を使って/todos
にGETメソッドでリクエストを送信しレスポンスを受け取っています。
const res = await request(app).get('/todos');
下記3点を確認しています。
- レスポンスのステータスが200であること
- レスポンスが想定通りのデータであること
- モックに置き換えたデータベースアクセスの関数が実行されていること
expect(res.status).toBe(200);
expect(res.body).toEqual(formattedTodos);
expect(spy).toHaveBeenCalledTimes(1);
動作確認
下記コマンドでテストを実行します。
npx jest
テストが2件正常に終了しました。
残りのコードのリファクタリング
データの参照処理に関してこれまでリファクタリングとテストの実装を行なってきました。
それ以外の処理に関しても同じような流れでテストの実装まで進めていきます。
サービス層にデータベースアクセスを分離
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);
};
コントローラーでは上記の関数を呼び出して実行するよう修正します。
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 });
});
コントローラーのテスト(データの参照以外)
addTodo
、updatedTodo
、deleteTodo
それぞれのテストコードを書いていきます。
なおupdatedTodo
、deleteTodo
は更新や削除の対象となるデータが存在しない時に専用のメッセージを返すよう定義していますので、それらについてもテストを実施します。
test('addTodoを実行すると新しいTodoが追加されること', 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を実行すると指定されたTodoが更新されること', 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を実行し{ message: "Todo not found" } が返ってくること', 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を実行すると指定されたTodoが削除されること', 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を実行し{ message: "Todo not found" } が返ってくること', 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();
});
動作確認
下記コマンドでテストを実行します。
npx jest
ルーティングのテスト(データの参照以外)
POST
、PATCH
、DELETE
メソッドに対してテストを書いていきます。
PATCH
、DELETE
に関しては404
を返すケースもあるためそちらについてもテストを書いていきます。
test('POST /todos にアクセスすると新しいTodoが追加されること', 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 にアクセスするとTodoが更新されること', 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 が存在しないTodoに対して404を返すこと', 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 にアクセスするとTodoが削除されること', 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 が存在しないTodoに対して404を返すこと', 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' });
});
さいごに
この記事では、SupertestとJestを活用してExpressアプリケーションのエンドツーエンドテストを効率的に実装する方法について説明しました。
また、MongoDBへのアクセスをモックに置き換えることで、安定したテスト結果を得られるよう対応しました。
E2Eテストを実装することで、アプリケーションの信頼性が向上し、潜在的なバグを早期に検出できます。この手法をプロジェクトに取り入れ、堅牢で高品質なバックエンドAPIを構築してください。
何かご不明な点があれば、お気軽にお問い合わせください。
関連記事
- JestとTypeScriptで始めるテスト自動化:基本設定から型安全なテストの書き方まで徹底解説
2023/09/13 - 【Next.js】フロントエンド開発で欠かせないReactのUIコンポーネントのテストをReact Testing Libraryで実装
2023/09/20 - ExpressとMongoDBで簡単にWeb APIを構築する方法【TypeScript対応】
2024/12/09 - TypeScriptで始めるApollo Server入門:Express & MongoDB連携ガイド
2024/12/25 - Express(+TypeScript)入門ガイド: Webアプリケーションを素早く構築する方法
2024/12/07 - フロントエンド開発に役立つモックサーバー構築:@graphql-tools/mock と Faker を使った実践ガイド
2024/12/25 - Next.jsとAuth.jsで認証機能を実装するチュートリアル
2024/09/13 - React + TypeScript + Webpackでバンドル環境を作るステップバイステップガイド
2025/01/05