Supertest と Jest を活用した Express + MongoDB アプリのエンドツーエンドテスト解説

2024/12/20に公開

はじめに

エンドツーエンド(E2E)テストは、アプリケーション全体の動作を確認するための重要なステップです。特に、バックエンドAPIのテストは、レスポンスの正確性を保証するうえで欠かせません。本記事では、Supertest と Jest を使った Express アプリケーションのエンドツーエンドテストの実装方法について詳しく解説します。また、MongoDBへのアクセスをモックに置き換えることにより実践的なシナリオを通じて、アプリケーション全体をテストする方法を学びます。これにより、信頼性の高いバックエンドの構築が可能になります。

なお、Express + MongoDBのサンプルコードはコチラのブログ記事のコードとして今回はテストコードを実装します。

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

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層(ルーティング、コントローラー)で実装しました。
データベースアクセスについてはモックにしたいためコントローラーからデータベースアクセスについては分離させテストコードを書きやすい状態にしてからテストコードを書いていきます。

最終的にはルーティング、コントローラーでそれぞれテストコードが作られる想定です。

リファクタリング

テストコードを書きやすくするためにこれまで書いてきたコードをリファクタリングしていきます。

下記ブログ記事をご参考頂くとよりイメージが湧くかと思います。

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

コントローラーからデータベースアクセスの処理を分離

データベースアクセスをモックに置き換えるために処理を分離します。

新たにサービス層を用意してデータベースアクセス処理を実装します。

src/services/todo.ts
import Todo from '../models/todo';

export const getTodos = async () => {
  return await Todo.find();
};

コントローラーでは上記の関数を呼び出して実行するよう修正します。

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);
});

Expressサーバー起動の処理を分離

現在のsrc/index.tsファイルはサーバー用のインスタンスを生成しサーバーを起動するまで行われています。

このファイルを使ってsupertestでテストを行うとサーバーが起動しっぱなしになるため、サーバー用のインスタンスを生成とサーバー起動はファイルを分割します。

サーバー用のインスタンスを生成するファイルは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

元々のindex.tsは、MongoDBへの接続とExpressの起動のみ行います。

index.ts
import app from './app'
import connectDB from './lib/db';

connectDB();

app.listen(3001);
console.log('Express WebAPI listening on port 3001');

パッケージのインストール

jestsupertest及びTypeScriptのテストコードを実行するts-jestをインストールします。

npm install -D supertest jest ts-jest @types/jest @types/supertest

jest.configの設定

ts-jestを使ってテストを実施するよう設定します。

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

コントローラーのテスト

コントローラーでgetTodosのテストコードを書いていきます。

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を実行すると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件正常に終了しました。

Image from Gyazo

ルーティングのテスト

次はGETメソッドのテストを行います。

Expressへのアクセスを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 にアクセスすると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件正常に終了しました。

Image from Gyazo

残りのコードのリファクタリング

データの参照処理に関してこれまでリファクタリングとテストの実装を行なってきました。

それ以外の処理に関しても同じような流れでテストの実装まで進めていきます。

サービス層にデータベースアクセスを分離

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);
};

コントローラーでは上記の関数を呼び出して実行するよう修正します。

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 });
});

コントローラーのテスト(データの参照以外)

addTodoupdatedTododeleteTodoそれぞれのテストコードを書いていきます。

なおupdatedTododeleteTodoは更新や削除の対象となるデータが存在しない時に専用のメッセージを返すよう定義していますので、それらについてもテストを実施します。

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

Image from Gyazo

ルーティングのテスト(データの参照以外)

POSTPATCHDELETEメソッドに対してテストを書いていきます。

PATCHDELETEに関しては404を返すケースもあるためそちらについてもテストを書いていきます。

src/routes/todo.test.ts
  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を構築してください。

何かご不明な点があれば、お気軽にお問い合わせください。

記事に関するお問い合わせ📝

記事の内容に関するご質問、ご意見などは、下記よりお気軽にお問い合わせください。
ご質問フォームへ

技術支援などお仕事に関するお問い合わせ📄

技術支援やお仕事のご依頼に関するお問い合わせは、下記よりお気軽にお問い合わせください。
お問い合わせフォームへ