Fastify ベストプラクティス #7: 堅牢で信頼性の高いJestテストスイートを構築する
現代のバックエンド開発において、質の高いテストを作成することは、単なる飾りではなく、アプリケーションの安定性と保守性を保証するための基盤です。優れたテストスイートがあれば、頻繁なイテレーションやリファクタリングにも自信を持って臨むことができます。
NodeJSのエコシステムにおいて、Jestは広く利用されているテストフレームワークです。しかし、実践においては、しばしば二つの厄介な課題に直面します。
- データベースと連携するロジックをどう効果的にテストするか?
- HTTP APIインターフェースをどのようにスマートにテストするか?
本稿では、これらの問題を深く掘り下げ、コミュニティのベストプラクティスに基づいた、本番環境でも利用可能な解決策を提案します。
データベースアクセスのテスト:Mockからの脱却、リアルな環境でのテストへ
バックエンドアプリケーションとデータベースは切っても切れない関係です。データアクセス層のテストにおいて、私たちは通常、選択を迫られます。
- データベースドライバのMock化:データベースの戻り値をモック(Mock)することで、実際のデータベース操作をスキップする方法です。このアプローチの利点は、テストが高速で外部環境に依存しないことです。しかし、その致命的な欠点は、我々が書いたSQL文やクエリビルダのロジックが正しいかを検証できない点にあります。これにより、テストはパスするものの、本番環境でアプリケーションがエラーを起こすという事態が頻発し、テストの本来の意味を失わせます。
- リアルなデータベースの使用:テストプロセス中に、本物のデータベースインスタンスに接続する方法です。このアプローチは忠実度が最も高く、データ操作ロジックの正しさを保証できます。しかし、従来の方法では、独立したテストデータベース環境を維持するのは比較的手間がかかり、特にCI/CDの自動化フローにおいては、環境の準備とクリーンアップが大きな課題となります。
筆者の見解: 中核となるビジネスロジックに対しては、第二の方法を強く推奨します。テストの第一の目標は問題を発見し、自信を確立することです。もしテストが実際の動作状況を反映していなければ、それがもたらす自信は偽りのものです。私たちは利便性のためにテストの信頼性を犠牲にするのではなく、環境問題を解決することに注力すべきです。
幸いなことに、Testcontainers を利用すれば、リアルなデータベース環境の管理問題を完璧に解決できます。
Testcontainersによる動的なデータベース環境の実現
Testcontainers は、コードを通じて一時的なDockerコンテナをプログラム的に作成・破棄できる強力なライブラリです。これにより、テストスイートを実行するたびに、まっさらでクリーンなデータベースインスタンスを動的に起動し、テスト終了後に自動的に破棄することが可能になります。
主な利点:
- 環境の分離:各テストが独立したデータベースで実行されるため、データの汚染やテストケース間の相互影響を防ぎます。
- 手動管理が不要:データベースのライフサイクルをテストプロセスに紐付けることで、「すぐに使える」状態を実現します。
- CI/CDフレンドリー:Dockerをサポートするあらゆる環境でシームレスに動作します。
@testcontainers/postgresql を使ってJestに統合する方法を見ていきましょう。
まず、依存関係をインストールします。
# testcontainersは開発およびテスト段階でのみ使用するため、devDependenciesにインストールします
npm i -D @testcontainers/postgresql
次に、jest.config.ts でグローバルな setup と teardown スクリプトを設定します。これにより、全てのテストが開始される前にデータベースコンテナが起動し、全てのテストが終了した後に破棄されることが保証されます。
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
// ... 他のJest設定
// Jestの実行前に一度だけ実行されるグローバルセットアップスクリプト
globalSetup: '<rootDir>/tests/global-setup.ts',
// Jestの実行後に一度だけ実行されるグローバルクリーンアップスクリプト
globalTeardown: '<rootDir>/tests/global-teardown.ts',
};
export default config;
そして、これら二つのコアスクリプトを作成します。
global-setup.ts:データベースの起動と初期化
// tests/global-setup.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { execSync } from 'child_process';
import dotenv from 'dotenv';
import path from 'path';
import 'tsconfig-paths/register';
export default async () => {
console.log('Setting up test environment...');
// 基本的な環境変数を注入する
dotenv.config({ path: path.resolve(__dirname, '../env/.env') });
// PostgreSQLコンテナインスタンスを起動する
console.log('Starting PostgreSQL container...');
const container = await new PostgreSqlContainer('postgres:15-alpine')
.withDatabase(process.env.DB_NAME || 'test_db')
.withUsername(process.env.DB_USER || 'test_user')
.withPassword(process.env.DB_PASSWORD || 'test_password')
.start();
console.log('PostgreSQL container started.');
// 重要なステップ:コンテナ起動後、そのポートなどの情報は動的に割り当てられます。
// これらの実際の接続パラメータを環境変数に上書きする必要があります。
// これにより、アプリケーションがテスト時に正しいデータベースインスタンスに接続できるようになります。
Object.assign(process.env, {
DB_HOST: container.getHost(),
DB_USER: container.getUsername(),
DB_PASSWORD: container.getPassword(),
DB_PORT: container.getPort().toString(),
DB_NAME: container.getDatabase(),
});
// データベースのマイグレーションとデータ投入(シーディング)を実行する
// ここではKyselyを例にしていますが、お使いのORMやマイグレーションツールに置き換えることができます
console.log('Running database migrations and seeding...');
const kyselyPath = './node_modules/.bin/kysely';
execSync(`${kyselyPath} migrate:latest && ${kyselyPath} seed:run`);
console.log('Database is ready.');
// teardownスクリプトでアクセスできるように、コンテナインスタンスをグローバル変数に保存します
(global as any).__TESTCONTAINER__ = container;
};
global-teardown.ts:データベースコンテナの破棄
// tests/global-teardown.ts
import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';
export default async () => {
console.log('\nTearing down test environment...');
const container: StartedPostgreSqlContainer = (global as any).__TESTCONTAINER__;
if (container) {
await container.stop();
console.log('Container stopped successfully.');
}
};
以上の設定により、全自動で忠実度の高いデータベーステスト環境が手に入ります。この方法はMockよりも少し遅くなるかもしれませんが、それと引き換えに得られるテストの信頼性は、その価値が十分にあります。
APIインターフェースのテスト:テスト可能なアプリケーションの設計
APIインターフェースのテストにおける我々の目標は、実際にネットワークポートをリッスンすることなく、HTTPリクエストをシミュレートし、サーバーのレスポンスを検証することです。これを実現するためには、いくつかの問題を解決する必要があります。
1. アプリケーションのビルドとサービスのリッスンを分離する
問題:テスト中に直接 app.listen() を実行すると、ポートを占有するだけでなく、テスト速度を低下させ、管理も困難になります。
解決策:Fastifyアプリケーションのビルドロジックと起動・リッスン処理を分離します。これは優れた設計パターンであり、アプリケーションのテスタビリティを大幅に向上させることができます。
ビルドロジック (src/app.ts):
このファイルはFastifyインスタンスの作成と設定のみを担当し、「アプリケーションファクトリ」のように機能します。
// src/app.ts
import autoLoad from '@fastify/autoload';
import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';
export function build(opts?: FastifyServerOptions): FastifyInstance {
const server = fastify({
// ... logger, etc.
...opts,
});
// プラグインやルートなどを自動的にロードする
// ...
return server;
}
起動ロジック (src/index.ts):
このファイルはアプリケーションのエントリポイントであり、build() を呼び出してインスタンスを取得し、サービスを起動します。
// src/index.ts
import { build } from './app';
const app = build();
app.listen({ host: '0.0.0.0', port: Number(process.env.PORT) || 3000 }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
console.log(app.printRoutes());
});
テスト時には、build() 関数をインポートして呼び出すだけで、純粋なFastifyインスタンスを取得できます。
2. テストサーバーのライフサイクルを管理する
問題:サーバーインスタンスはいつ作成し、いつ破棄すべきか?グローバルに一つだけ作成すると、テスト間でMockや他の状態が相互に干渉する可能性があります。
解決策:各テストファイル(describe ブロック)ごとに独立したサーバーインスタンスを作成します。このプロセスを簡素化するために、ヘルパー関数をカプセル化することができます。
// tests/helpers/fastify.helper.ts
import { build } from '@/app';
import { FastifyInstance } from 'fastify';
/**
* describeブロック内で使用するヘルパー関数。
* Fastifyインスタンスの作成と破棄を自動的に処理し、テストの分離を保証する。
* @returns テストケース内でappインスタンスを安全に取得するためのgetApp関数を返す。
*/
export function setupFastify() {
const appContainer: { instance: FastifyInstance | null } = { instance: null };
// 現在のdescribeブロックの全テスト実行前に、appを作成して初期化する
beforeAll(async () => {
appContainer.instance = build();
await appContainer.instance.ready(); // 全てのプラグインがロードされるのを待つ
});
// 現在のdescribeブロックの全テスト実行後に、appを閉じる
afterAll(async () => {
await appContainer.instance?.close();
});
// it/testブロックが初期化済みのappインスタンスを安全に取得できるように、クロージャ関数を返す
return {
getApp: (): FastifyInstance => {
if (!appContainer.instance) {
throw new Error(
'Fastify app instance is not available. Ensure getApp() is called within a test case (it/test).',
);
}
return appContainer.instance;
},
};
}
このヘルパーの具体的な使い方は後述します。
3. HTTPリクエストの送信とアサーション
问题:Jest内でメモリ上のFastifyインスタンスにHTTPリクエストを送信するにはどうすればよいか?
解决策:supertest ライブラリを使用します。これはリクエストの構築とレスポンスのアサーションを行うための非常にスムーズなチェーンAPIを提供し、可読性が非常に高いです。
代替案: Fastifyは
app.inject()メソッドを標準で提供しており、これもリクエストをシミュレートできます。追加の依存関係も不要です。inject()は実際のHTTPソケットを経由しないため、パフォーマンスが若干高い可能性があります。しかし、supertestのBDDスタイルアサーション(.expect(200).expect('Content-Type', /json/))は、一般的に開発者により好まれる傾向があります。ほとんどのプロジェクトにとって、supertestはより推奨される選択肢です。
まず、supertest をインストールします。
npm i -D supertest @types/supertest
次に、テストケースで setupFastify と supertest を組み合わせてテストを行います。
import { setupFastify } from '@/tests/helpers/fastify.helper';
import supertest from 'supertest';
describe('GET /book Endpoint', () => {
// describeブロックのトップレベルで setupFastify を呼び出す
const { getApp } = setupFastify();
describe('Success cases', () => {
it('should return a book by its ID', async () => {
// itブロック内でgetApp()を使い、現在のテストコンテキストのappインスタンスを取得する
const app = getApp();
const bookId = '123e4567-e89b-12d3-a456-426614174100';
// supertestを使ってリクエストを送信する
const response = await supertest(app.server)
.get(`/book/${bookId}`)
//.query({ fields: 'id,title' }) // 例:クエリパラメータの追加方法
//.set('Authorization', 'Bearer your_token') // 例:リクエストヘッダーの設定方法
.expect(200) // ステータスコードをアサーション
.expect('Content-Type', /application\/json/); // レスポンスヘッダーをアサーション
// Jestのexpectを使い、より複雑なレスポンスボディのアサーションを行う
expect(response.body).toEqual({
id: bookId,
title: 'Book One',
author: 'Author One',
});
});
});
});
結論
Testcontainers、アプリケーションロジックの分離、そして supertest を組み合わせることで、Fastifyアプリケーションのために強力で信頼性が高く、保守しやすいテストスイートを構築することができます。
要点のまとめ:
- テストの忠実度:可能な限り本物の依存関係(データベースなど)を使用して統合テストを行いましょう。
Testcontainersはこれを実現するための強力なツールです。 - テスタビリティを考慮した設計:アプリケーションの中核となるビルドロジックを実行時の詳細(
listenなど)から分離することが鍵です。 - テストの分離:Jestのライフサイクルフック(
beforeAll/afterAll)を利用して、各テストスイートがクリーンな環境で実行されることを保証します。 - 適切なツールの選択:
supertestはAPIテストのために洗練された宣言的な構文を提供します。
このような堅固なテストプロセスを確立するために時間を投資することは、プロジェクトのライフサイクル全体にわたって大きな恩恵をもたらすでしょう。