JestとSupertestによるAPIの網羅的な安定性確保
Lukas Schneider
DevOps Engineer · Leapcell

はじめに: API信頼性の必要性
今日の相互接続された世界では、Node.js REST APIは無数のアプリケーションのバックボーンとして機能し、データ交換を促進し、ユーザーエクスペリエンスを強化しています。これらのAPIが複雑さと規模を増すにつれて、それらの安定性、正確性、および仕様への準拠を確保することが最重要になります。手動テストは、しばしば必要ですが、ペースの速い開発サイクルでは非効率的、エラーが発生しやすく、持続不可能であることがよくあります。ここで自動エンドツーエンド(E2E)テストが登場し、リクエストからレスポンスまで、ユーザーまたはクライアントアプリケーションが対話するのと同じように、アプリケーション全体のフローを検証する重要なセーフティネットを提供します。現実世界のシナリオをシミュレートすることにより、E2Eテストは早期にリグレッションを検出し、開発者の自信を高め、最終的に、より堅牢で信頼性の高いAPIに貢献します。この記事では、強力なJestによるテストとSupertestによるHTTPアサーションの組み合わせを使用して、Node.js REST APIのエンドツーエンドテストを効果的に実装する方法を探ります。
堅牢なAPIテストのためのコアツールの理解
実装に入る前に、関係する基本的なツールを明確に理解しましょう。
Jest: JestはFacebookによって開発された、シンプルさ、速度、および包括的な機能で広く採用されているJavaScriptテストフレームワークです。テストランナー、アサーションライブラリ、およびモック機能を含むオールインワンソリューションです。E2Eテストの場合、Jestはテストスイートを定義、実行、および報告するための構造を提供し、親しみやすく強力な環境を提供します。
Supertest: Supertestは、HTTPサーバーのテスト専用に設計された、Superagent(HTTPリクエストライブラリ)の上に構築された高レベルの抽象化です。これにより、APIエンドポイントへのHTTPリクエストを作成し、流れるような読みやすい方法でレスポンスをアサートできます。Supertestは、テストのためにサーバーの起動と停止をエレガントに処理し、E2Eテストプロセスをシームレスにします。
エンドツーエンド(E2E)テスト: ユニットテスト(孤立したコンポーネントに焦点を当てる)や統合テスト(複数のユニット間の対話を検証する)とは異なり、E2Eテストはユーザーの観点からアプリケーションの完全なフローを検証します。APIの場合、これは実行中のサーバーへの実際のHTTPリクエストの作成、データベースとの対話、およびステータスコード、データ形式、副作用を含む期待されるレスポンスの検証を意味します。
テスト環境のセットアップ
基本的なNode.js Express REST APIがあると仮定します。以下は、APIのapp.js
ファイルの簡単な例です。
// app.js const express = require('express'); const bodyParser = require('body-parser'); const app = express(); const port = 3000; app.use(bodyParser.json()); let items = [ { id: '1', name: 'Item One', description: 'This is item one.' }, { id: '2', name: 'Item Two', description: 'This is item two.' }, ]; // すべてのアイテムを取得 app.get('/items', (req, res) => { res.status(200).json(items); }); // IDでアイテムを取得 app.get('/items/:id', (req, res) => { const item = items.find(i => i.id === req.params.id); if (item) { res.status(200).json(item); } else { res.status(404).send('Item not found'); } }); // 新しいアイテムを作成 app.post('/items', (req, res) => { const { name, description } = req.body; if (!name || !description) { return res.status(400).send('Name and description are required'); } const newItem = { id: String(items.length + 1), name, description }; items.push(newItem); res.status(201).json(newItem); }); // アイテムを更新 app.put('/items/:id', (req, res) => { const { name, description } = req.body; const itemIndex = items.findIndex(i => i.id === req.params.id); if (itemIndex > -1) { items[itemIndex] = { ...items[itemIndex], name: name || items[itemIndex].name, description: description || items[itemIndex].description }; res.status(200).json(items[itemIndex]); } else { res.status(404).send('Item not found'); } }); // アイテムを削除 app.delete('/items/:id', (req, res) => { const initialLength = items.length; items = items.filter(i => i.id !== req.params.id); if (items.length < initialLength) { res.status(204).send(); // No Content } else { res.status(404).send('Item not found'); } }); if (process.env.NODE_ENV !== 'test') { app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); } module.exports = app; // Export the app for testing
まず、必要なパッケージをインストールしましょう。
npm install jest supertest express body-parser
またはyarnを使用する:
yarn add jest supertest express body-parser
package.json
にテストスクリプトを追加します。
"scripts": { "test": "jest" }
JestとSupertestを使用したエンドツーエンドテストの記述
これで、テストファイルを作成しましょう。たとえば、__tests__/items.e2e.test.js
です。
// __tests__/items.e2e.test.js const request = require('supertest'); const app = require('../app'); // Import your Express app let server; // すべてのテストの前にサーバーを起動 beforeAll(() => { server = app.listen(0); // Listen on a random unused port }); // すべてのテストの後にサーバーを閉じる afterAll((done) => { server.close(done); }); describe('Items API E2E Tests', () => { let initialItems; // 各テストの前に、データをリセット(テストの分離のために重要) beforeEach(() => { // デモンストレーションのための簡単なデータリセット。 // 本番アプリケーションでは、テストデータベースをシードする可能性があります。 initialItems = [ { id: '1', name: 'Initial Item One', description: 'This is the first initial item.' }, { id: '2', name: 'Initial Item Two', description: 'This is the second initial item.' }, ]; // これはデモンストレーションのための単純化されたリセットです。実際のアプリでは、 // 通常、各テストの前にデータベースをリセットします。 // この例では、`app.js`から`items`配列を直接操作しますが、 // E2Eの概念を説明するには機能します。 // 適切なセットアップでは、テストデータベースまたはモック戦略を使用します。 // `app.js`の`let`変数のような`items`配列をリセットするには、 // `app.js`からリセット関数を公開するか、モジュールを再インポートする // 方法が必要になります。現在の`app.js`では、直接操作でのリセットは難しいです。 //この例では、テストがある程度分離されているか、シーケンシャルに実行されると仮定します。 // より堅牢なソリューションは、テストデータベースまたはapp.jsからのリセット機能の公開を伴います。 }); it('should get all items', async () => { const res = await request(app).get('/items'); expect(res.statusCode).toEqual(200); expect(Array.isArray(res.body)).toBeTruthy(); expect(res.body.length).toBeGreaterThanOrEqual(1); // Assuming some initial data expect(res.body[0]).toHaveProperty('id'); expect(res.body[0]).toHaveProperty('name'); }); it('should get an item by ID', async () => { const itemId = '1'; const res = await request(app).get(`/items/${itemId}`); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('id', itemId); expect(res.body).toHaveProperty('name', 'Item One'); // Assuming current state of items }); 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'); }); it('should create a new item', async () => { const newItem = { name: 'New Item', description: 'This is a brand new item.' }; const res = await request(app) .post('/items') .send(newItem); expect(res.statusCode).toEqual(201); expect(res.body).toHaveProperty('id'); expect(res.body).toHaveProperty('name', newItem.name); expect(res.body).toHaveProperty('description', newItem.description); //Verify it exists by fetching it const getRes = await request(app).get(`/items/${res.body.id}`); expect(getRes.statusCode).toEqual(200); expect(getRes.body).toEqual(expect.objectContaining(newItem)); }); it('should return 400 if name or description is missing when creating an item', async () => { const invalidItem = { description: 'Missing name' }; const res = await request(app) .post('/items') .send(invalidItem); expect(res.statusCode).toEqual(400); expect(res.text).toBe('Name and description are required'); }); it('should update an existing item', async () => { const itemIdToUpdate = '1'; const updatedData = { name: 'Updated Item One', description: 'Description has changed.' }; const res = await request(app) .put(`/items/${itemIdToUpdate}`) .send(updatedData); expect(res.statusCode).toEqual(200); expect(res.body).toHaveProperty('id', itemIdToUpdate); expect(res.body).toHaveProperty('name', updatedData.name); expect(res.body).toHaveProperty('description', updatedData.description); //Verify the update by fetching it const getRes = await request(app).get(`/items/${itemIdToUpdate}`); expect(getRes.statusCode).toEqual(200); expect(getRes.body).toEqual(expect.objectContaining(updatedData)); }); it('should delete an item', async () => { const itemIdToDelete = '2'; const res = await request(app).delete(`/items/${itemIdToDelete}`); expect(res.statusCode).toEqual(204); //Verify it's deleted by trying to fetch it const getRes = await request(app).get(`/items/${itemIdToDelete}`); expect(getRes.statusCode).toEqual(404); }); it('should return 404 when trying to delete a non-existent item', async () => { const res = await request(app).delete('/items/999'); expect(res.statusCode).toEqual(404); }); });
セットアップとテストの説明
require('supertest')
andrequire('../app')
:supertest
とExpressapp
をインポートします。Supertestは、別のサーバープロセスを実行する必要なく、このapp
インスタンスを使用してHTTPリクエストを作成します。beforeAll
andafterAll
: これらのJestライフサイクルフックは非常に重要です。beforeAll
は、すべてのテストが実行される前にExpressapp
を起動します。app.listen(0)
を使用して、ランダムな利用可能なポートでサーバーをリッスンし、競合を回避します。afterAll
は、すべてのテストが完了したらサーバーを閉じます。これにより、クリーンなテイクダウンとリソースリークの防止が保証されます。
describe('Items API E2E Tests', ...)
: 関連するテストをグループ化するテストスイートです。beforeEach
(データリセット): このフックは、各テストの前に実行されます。E2Eテスト、特にデータベースなどの永続ストレージを扱う場合、各テストの前に状態をリセットすることが不可欠です。これにより、各テストが予測可能で分離された環境で実行され、以前のテストが後のテストに影響を与えるのを防ぎます。単純なインメモリ配列の例では、適切なbeforeEach
にはitems
配列の直接操作、またはより現実的には、テストデータベースの切り捨てと再シードが含まれます。現在の例は必要性を強調していますが、単純化を認めています。it('should get all items', async () => { ... });
: 各it
ブロックは、個別のテストケースを定義します。await request(app).get('/items')
: ここでSupertestの強みが発揮されます。request(app)
を呼び出してExpressアプリケーションをターゲットにし、.get()
、.post()
、.put()
、.delete()
などのHTTPメソッドをチェーンします。.send(data)
: リクエストボディを送信するために、POST
およびPUT
リクエストで使用されます。.expect(status)
または: Supertestは
.expect()アサーションをチェーンできます。または、
await request(...)から返される
resオブジェクトでJestの
expect`を直接使用できます。expect(Array.isArray(res.body)).toBeTruthy()
: Jestアサーションを使用して、レスポンスボディの構造とコンテンツを検証します。- 非同期性: HTTPリクエストは非同期操作です。
async/await
を使用してそれらを適切に処理し、テストコードを同期的に見やすくします。
テストの実行
ターミナルで npm test
または yarn test
を実行するだけです。JestはE2Eテストを検出し実行し、成功と失敗の明確なレポートを提供します。
ベストプラクティスとさらなる検討事項
- テストデータベース: 実稼働アプリケーションでは、常に専用のテストデータベース(例:SQLiteインメモリ、別のPostgreSQL/MongoDBインスタンス)を使用してください。このデータベースでの各テストのデータリセットは、分離に不可欠です。
jest-mongodb
のようなツールやカスタムスクリプトが、シードとクリアに役立ちます。 - 環境変数: テスト中に異なる構成を条件付きでロードしたり、外部サービスをモックしたりするために、環境変数(例:
process.env.NODE_ENV = 'test'
)を使用します。 - 認証: APIに認証がある場合、E2Eテストではログインプロセスをシミュレートしてトークン(JWT、セッションID)を取得し、それらを後続のリクエストに含める必要があります(例:
request(app).get('/secure').set('Authorization', 'Bearer <token>')
)。 - 外部サービスのモック: E2Eテストは理想的にはすべてのレイヤーにヒットしますが、テストが高速、信頼性があり、決定論的になるように、外部サービス(サードパーティAPI、支払いゲートウェイ)をモックする必要がある場合があります。Jestの強力なモック機能や
nock
のようなライブラリがこれに使用できます。 - テストの整理: APIルートまたは機能に合わせて、テストファイルを論理的に整理します(例:
__tests__/users.e2e.test.js
、__tests__/products.e2e.test.js
)。 - クリーンアップ:
afterAll
またはafterEach
フックが、リソース(データベース接続、開いているファイル、実行中のサーバー)を適切にクリーンアップすることを常に確認してください。 - ロギング: 過剰なコンソール出力を防ぐために、テスト中のAPIロギングは最小限に抑えてください。
結論: API信頼性のための基盤
JestとSupertestを使用したエンドツーエンドテストの習得は、Node.js REST APIの強力なセーフティネットを提供します。ユーザーの全ジャーニーとAPIとの対話を検証することにより、アプリケーションが進化しても期待どおりに機能するという計り知れない自信を得られます。これは、本番環境でのバグの削減、開発サイクルの迅速化、そして最終的には、より信頼性が高く保守可能なコードベースにつながります。このテスト方法論を採用することは、アプリケーションの安定性と開発者の安心感に利益をもたらす投資です。