JestとSupertestを使った堅牢なNode.js APIの構築
Daniel Hayes
Full-Stack Engineer · Leapcell

はじめに
ペースの速いWeb開発の世界では、堅牢で信頼性の高いNode.js APIの構築が最優先事項です。アプリケーションが複雑になるにつれて、その正確性と安定性を確保することの重要性はますます高まっています。手動テストは、必要になる場合もありますが、時間がかかり、エラーが発生しやすく、長期的には持続可能ではありません。そこで自動テストが登場し、アプリケーションの動作を検証するための体系的で反復可能なアプローチを提供します。特にNode.js APIの場合、単体テストと統合テストの組み合わせは、開発サイクルの早い段階でバグを捕捉し、自信を持ってリファクタリングとデプロイを可能にする包括的なセーフティネットを提供します。この記事では、強力で広く採用されている2つのツール、すなわち汎用性の高いテストフレームワークのためのJestと、エレガントなHTTPアサーション処理のためのSupertestを使用して、APIの効果的な単体テストと統合テストを書くプロセスをガイドします。
APIテストの基盤
実践的な実装に飛び込む前に、Node.js APIのテストに関するいくつかのコアコンセプトを定義しましょう。
主要な用語
- 単体テスト: このレベルのテストは、個々のコンポーネントまたはコードの「ユニット」を分離して焦点を当てます。APIの場合、ユニットは単一の関数、モジュール、またはクラスである可能性があります。目標は、各ユニットがシステムの他の部分とは独立して、意図された動作を正しく実行していることを確認することです。これには、真の分離を確保するために外部依存関係をモックすることがよく含まれます。
- 統合テスト: これは、アプリケーションのさまざまなユニットまたはコンポーネント間の相互作用をテストします。APIの場合、統合テストは通常、エンドポイントへの実際のHTTPリクエストを行い、応答を検証することを含みます。これにより、コントローラー、サービス、データベースなど、APIのさまざまな部分が期待どおりに連携することが保証されます。
- Jest: Facebookによって開発された人気のJavaScriptテストフレームワークです。Jestは、その速度、シンプルさ、および組み込みのアサーションライブラリ、モッキング機能、優れたテストランナーを含む包括的な機能で知られています。JavaScriptテストのためのオールインワンソリューションです。
- Supertest: Node.js HTTPサーバーのテストのためのスーパーエージェント駆動ライブラリです。Supertestを使用すると、APIへのHTTPリクエストの送信と、応答、ヘッダー、ステータスコード、およびボディコンテンツのアサーションを非常に簡単に行えます。テストのためにHTTPサーバーをセットアップおよびティアダウンする複雑さを抽象化します。
JestとSupertestを使用する理由
Jestは、テストの記述に強力で意見のある環境を提供し、アサーションメソッドからテストランナー、レポート作成まで、すべてを提供します。そのスナップショットテスト、強力なモックツール、および優れたパフォーマンスは、JavaScript開発者にとって最適な選択肢となっています。一方、Supertestは、Node.js APIへのHTTPリクエストの送信プロセスを簡素化し、応答に対してアサーションを行うことにより、Jestを完全に補完します。これらを組み合わせることで、APIの品質を確保するための堅牢なツールキットが形成されます。
プロジェクトのセットアップ
基本的なNode.jsプロジェクトをセットアップし、必要なパッケージをインストールすることから始めましょう。
まず、新しいNode.jsプロジェクトを初期化します。
mkdir my-api-tests cd my-api-tests npm init -y
次に、JestとSupertestをインストールします。
npm install --save-dev jest supertest
そして、簡単なAPIの例のために、Expressもインストールしましょう。
npm install express
簡単なAPIの構築
Express API用のapp.js
ファイルを作成しましょう。
// app.js const express = require('express'); const app = express(); const port = 3000; app.use(express.json()); // JSONボディ解析を有効にする let items = [ { id: 1, name: 'Item A' }, { id: 2, name: 'Item B' } ]; // すべてのアイテムを取得 app.get('/items', (req, res) => { res.json(items); }); // IDでアイテムを取得 app.get('/items/:id', (req, res) => { const id = parseInt(req.params.id); const item = items.find(item => item.id === id); if (item) { res.json(item); } else { res.status(404).send('Item not found'); } }); // 新しいアイテムを追加 app.post('/items', (req, res) => { const newItem = { id: items.length > 0 ? Math.max(...items.map(item => item.id)) + 1 : 1, name: req.body.name }; if (!newItem.name) { return res.status(400).send('Name is required'); } items.push(newItem); res.status(201).json(newItem); }); // テスト用にアプリをエクスポート module.exports = app; // モジュールが直接実行された場合は、オプションでサーバーを起動します if (require.main === module) { app.listen(port, () => { console.log(`API running on http://localhost:${port}`); }); }
次に、package.json
でtest
スクリプトを追加してJestを構成します。
{ "name": "my-api-tests", "version": "1.0.0", "description": "", "main": "app.js", "scripts": { "test": "jest" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "express": "^4.19.2" }, "devDependencies": { "jest": "^29.7.0", "supertest": "^6.3.4" } }
Jestを使った単体テストの実装
app.js
にアイテムロジックを処理する別のユーティリティ関数が含まれていると想像してください。単体テストでは、その関数を分離してテストします。私たちの例のapp.js
にはロジックが直接埋め込まれているため、itemService.js
という仮の単体テストを例示しましょう。
// services/itemService.js let itemsData = [ { id: 1, name: 'Initial Item A' }, { id: 2, name: 'Initial Item B' } ]; const getAllItems = () => { return itemsData; }; const getItemById = (id) => { return itemsData.find(item => item.id === id); }; const addItem = (name) => { if (!name) { throw new Error('Name cannot be empty'); } const newItem = { id: itemsData.length > 0 ? Math.max(...itemsData.map(item => item.id)) + 1 : 1, name: name }; itemsData.push(newItem); return newItem; }; // テスト目的で、データの初期化を可能にします const resetItems = () => { itemsData = [ { id: 1, name: 'Initial Item A' }, { id: 2, name: 'Initial Item B' } ]; }; module.exports = { getAllItems, getItemById, addItem, resetItems // テストセットアップ/ティアダウン用にエクスポート };
次に、__tests__/unit/itemService.test.js
ファイルを作成します。
// __tests__/unit/itemService.test.js const itemService = require('../../services/itemService'); describe('itemService', () => { beforeEach(() => { // 各テストの前にデータをリセットして、分離を保証します itemService.resetItems(); }); test('should return all items', () => { const items = itemService.getAllItems(); expect(items).toHaveLength(2); expect(items[0]).toHaveProperty('name', 'Initial Item A'); }); test('should return an item by ID', () => { const item = itemService.getItemById(1); expect(item).toHaveProperty('name', 'Initial Item A'); }); test('should return undefined if item ID does not exist', () => { const item = itemService.getItemById(99); expect(item).toBeUndefined(); }); test('should add a new item', () => { const newItem = itemService.addItem('New Test Item'); expect(newItem).toHaveProperty('name', 'New Test Item'); expect(newItem.id).toBeGreaterThan(0); expect(itemService.getAllItems()).toHaveLength(3); }); test('should throw error if name is empty when adding item', () => { expect(() => itemService.addItem('')).toThrow('Name cannot be empty'); }); test('should generate correct ID for new items', () => { itemService.addItem('One'); const item2 = itemService.addItem('Two'); expect(item2.id).toBe(4); // 初期アイテムが1, 2、次にOneが3、Twoが4であると想定 }); });
これらのテストを実行するには、単純に実行します。
npm test
Jestはすべてのテストファイルを見つけて実行します。
JestとSupertestを使った統合テストの実装
統合テストはAPIエンドポイント自体の検証に焦点を当てます。ここでSupertestが光ります。__tests__/integration/items.test.js
ファイルを作成します。
// __tests__/integration/items.test.js const request = require('supertest'); const app = require('../../app'); // Expressアプリをインポート // テストが分離されることを保証するために、データの状態を管理する必要があることがよくあります。 // 実アプリでは、これはテストデータベースに接続し、データをクリア/シードすることになるでしょう。 // この簡単な例では、各テストスイート/テストの前にアイテム配列がリセットされることに依存するか、 // アプリを再起動することになるかもしれません。 // アプリ.jsにインメモリ配列があるため、各テスト実行でそれが新鮮であることを確認する必要があります。 // 一般的なアプローチは、app.jsから初期化またはアイテムをリセットする機能をエクスポートすることです。 // この例の単純さのために、各テスト実行で更新された状態を想定します。 // 本番アプリでは、適切なテストデータベース戦略を使用してください。 describe('Items API Integration Tests', () => { let server; // すべてのテストの前に、サーバーを起動します beforeAll((done) => { // アプリはすべての統合テストのために一度だけ起動すればよく, // 個々のテスト間のリセットが必要な共有状態がないと想定します。 // ただし、アプリがグローバル状態(`items`配列のような)を変更する場合、 // 各テストの前に`app`を再要求またはその状態をリセットする必要があるかもしれません。 // この例では、SupertestがExpressアプリの接続をモックする機能に依存します。 done(); // SupertestはExpressアプリの起動/停止を内部的に処理します。 }); // Test GET /items it('should return all items', async () => { const res = await request(app).get('/items'); expect(res.statusCode).toEqual(200); expect(res.body).toBeInstanceOf(Array); expect(res.body.length).toBeGreaterThan(0); expect(res.body[0]).toHaveProperty('name', 'Item A'); }); // Test GET /items/:id it('should return a specific item by ID', async () => { const res = await request(app).get('/items/1'); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('id', 1); expect(res.body).toHaveProperty('name', 'Item A'); }); it('should return 404 for a non-existent item', async () => { const res = await request(app).get('/items/999'); expect(res.statusCode).toEqual(404); expect(res.text).toBe('Item not found'); }); // Test POST /items it('should add a new item', async () => { // このテストの前に、app.jsの「items」配列には、 // 同じテスト実行中の以前のテストのアイテムがすでに含まれている可能性があります。 // これを堅牢にするために、統合テストでは状態の変更を伴う場合、 // データをリセットする方法やデータベースをモックする方法が必要になります。 // この簡単な機能のために、テスト目的で名前を一意にすることを確認します。 const newItemName = `New Item ${Date.now()}`; const res = await request(app) .post('/items') .send({ name: newItemName }) .set('Accept', 'application/json'); expect(res.statusCode).toEqual(201); expect(res.body).toHaveProperty('id'); expect(res.body).toHaveProperty('name', newItemName); // オプションで、GET経由でアクセス可能であることを確認します const getRes = await request(app).get(`/items/${res.body.id}`); expect(getRes.statusCode).toEqual(200); expect(getRes.body).toHaveProperty('name', newItemName); }); it('should return 400 if name is missing when adding item', async () => { const res = await request(app) .post('/items') .send({}) // 空のボディ .set('Accept', 'application/json'); expect(res.statusCode).toEqual(400); expect(res.text).toBe('Name is required'); }); });
npm test
を使用してこれらの統合テストを実行します。
実践でのテストの適用
- 継続的インテグレーション: テストをCI/CDパイプライン(例:GitHub Actions、GitLab CI、Jenkins)に統合します。これにより、すべてのコードプッシュでテストが自動的に実行され、リグレッションを防ぐことができます。
- テスト駆動開発(TDD): テストを 先に 書き、次に実際のコードを書くTDDアプローチを採用することを検討してください。これにより、より良いAPI設計、テスト容易性、およびバグの発生を減らすことができます。
- データベースと外部サービスのモック: より複雑な統合テストでは、データベースの操作や外部APIへの呼び出しをモックする必要があることがよくあります。Jestの強力なモッキング機能はこれに使用でき、実際の外部サービスにヒットすることなく、これらの依存関係から返されるデータを制御できます。
jest-mock-extended
のようなツールは、インターフェイスのモッキングを簡素化できます。データベースの場合、テスト用のインメモリデータベース(sqlite
など)を使用するか、各テストの前にリセットされる専用のテストデータベースを検討してください。
結論
堅牢なNode.js APIの構築は、機能的なコードを書くだけでなく、包括的なテストを通じてその信頼性と保守性を確保することでもあります。強力なテストフレームワークのためのJestと、エレガントなHTTPアサーション機能のためのSupertestを活用することで、開発者は強力なセーフティネットを提供する効果的な単体テストと統合テストを作成できます。これにより、個々のコンポーネントの正確性が保証されるだけでなく、APIのさまざまな部分のシームレスな相互作用も保証され、最終的にはより安定した、スケーラブルで、信頼性の高いアプリケーションにつながります。JestとSupertestによる自動テストを採用することは、自信を持って高品質なNode.js APIを提供する上での重要なステップです。