NestJSでテスト

テスト

テストの自動化は、現代のソフトウェア開発においては必要不可欠なものになっています。自動化により、開発中に個々のテストやテストスイートを迅速かつ容易に実装できます。これにより、リリースが品質と性能の目標に合致していることを確認できます。
テストを自動化することで、カバレッジが向上し、開発者へのフィードバックが迅速に行われます。自動化は、個々の開発者の生産性を向上させ、ソースコード管理のチェックイン、機能統合やバージョンリリースなどの重要な開発ライフサイクルの分岐点におけるテストの実行を保証します。
このようなテストは、ユニットテスト、エンドツーエンドテストや統合テストなど様々な種類にわたって行なわれることが多いです。その利点は否定できませんが、その設定には手間がかかることがあります。NestJSでは、効率的にテストを進める上で以下のような機能をデフォルトで用意しています。
  • コンポーネント用のデフォルトのユニットテストとアプリケーション用のエンドツーエンド(e2e)テストを自動的に実装する
  • デフォルトのツールを提供する
  • テストツールに依存しないまま、Jestとすぐに統合できる
  • NestJSの依存性注入システムをテスト環境で利用できるようにし、コンポーネントを簡単にモックできるようにする
前述の通り、NestJSは好きなテストフレームワークを使えます。必要な要素を置換するだけで、NestJSの恩恵を十分にフル活用できます。

インストール

まずは以下のコマンドを入力してテストツールをインストールしてください。
npm i --save-dev @nestjs/testing

ユニットテスト

以下のプログラムでは、CatsControllerCatsServiceという2つのクラスをテストしています。前述の通り、Jestはデフォルトのテストフレームワークとして提供されています。実際にテストを実行する機能だけではなく、モッキングなどのアサート関数やtest-doubleユーティリティも提供されています。以下の基本的なテストでは、これらのクラスを手動でインスタンス化しコントローラとサービスがAPIの実行条件を満たしているかどうかを確認しています。
cats.controller.spec.ts
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
describe('CatsController', () => {
 // CatsServiceのインスタンスをCatsControllerに渡しているわけではないので十分に注意する
let catsController: CatsController;
let catsService: CatsService;
beforeEach(() => {
catsService = new CatsService();
catsController = new CatsController(catsService);
});
describe('findAll', () => {
it('should return an array of cats', async () => {
const result = ['test'];
jest.spyOn(catsService, 'findAll').mockImplementation(() => result);
expect(await catsController.findAll()).toBe(result);
});
});
});
メモ
テストファイルは、テストするクラスの近くに置いてください。テストファイルの接尾辞は .specまたは.testでなければなりません。(言い換えれば、これらの単語を忘れないこと)
上記のプログラムは些細なものなので、NestJSに特化したテストをしているわけではありません。
このようにテスト対象のクラスを手動でインスタンス化するテストは、フレームワークから独立しています。NestJSの機能をフル活用するためのアプリケーションのテストに役立つ、より高度な機能をいくつか紹介しましょう。

テストの使い方

@nestjs/testingパッケージは、より堅牢なテストプロセスを可能にするユーティリティのセットを提供します。組み込みのTestクラスを使って、先程のプログラムを書き換えてみましょう。
cats.controller.spec.ts
import { Test } from '@nestjs/testing';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
describe('CatsController', () => {
let catsController: CatsController;
let catsService: CatsService;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
providers: [CatsService],
}).compile();
catsService = moduleRef.get<CatsService>(CatsService);
catsController = moduleRef.get<CatsController>(CatsController);
});
describe('findAll', () => {
it('should return an array of cats', async () => {
const result = ['test'];
jest.spyOn(catsService, 'findAll').mockImplementation(() => result);
expect(await catsController.findAll()).toBe(result);
});
});
});
Testクラスは、基本的にNestランタイムを完全にモックしたアプリケーション実行コンテキストを提供するのには便利です。主にモッキングやオーバーライドなどのクラスインスタンスの管理を簡単にするフックを提供します。TestクラスにはcreateTestingModule()メソッドがあり、モジュールのメタデータオブジェクトを引数として受け取ります。このメソッドはTestingModuleのインスタンスを返し、そのインスタンスにはいくつかのメソッドが用意されています。
ユニットテストを行う上で重要なのはcomplie()メソッドです。このメソッドは、依存関係を持つモジュールを認識し、テスト用の準備ができたらモジュールを返します。(しくみはアプリケーションがNestFactory.create()を使って従来のmain.tsファイルで認識されるのと同等)
メモ
compile()メソッドは非同期処理で実装されます。モジュールがコンパイルされると、宣言されている静的インスタンス (コントローラやプロバイダ) をget()メソッドで取得することができるようになります。
TestingModuleはモジュール参照クラスを継承しているので、スコープ付きプロバイダを動的に解決できます。これはresolve()メソッドでこれを実行します。(get()メソッドは静的なインスタンスだけを習得できます)
const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
providers: [CatsService],
}).compile();
catsService = await moduleRef.resolve(CatsService);
注意
resolve()メソッドは、プロバイダの一意のインスタンスを、それ自身のDIコンテナやサブツリーから返します。各サブツリーには、一意のコンテキスト識別子があります。したがって、このメソッドを複数回呼び出してインスタンス参照を比較すると、両者が等しくないことがわかります。
任意のプロバイダを本番バージョンを使用する代わりに、テスト用にカスタムプロバイダで継承できます。たとえば、実際のデータベースに接続する代わりにデータベースサービスをモックすることができます。

モックの自動化

Nestでは、不足している依存関係のすべてに適用するモックファクトリを定義できます。これは、クラス内に多数の依存関係があり、すべての依存関係をモック化すると時間がかかり、設定も大変になる際に有効です。この機能を使用するには、 createTestingModule()useMocker()メソッドを連結して、 依存性モック用のファクトリを渡す必要があります。このファクトリーは、オプションのトークン (インスタンス・トークン、Nest プロバイダで有効な任意のトークン) を受け取り、モックの実装を返します。以下は、jest-mockを使った汎用モッカーと、jest.fn()を使ったCatsService専用のモックを作成する例です。
ちなみに、モックとはメソッドの実行に対して実行回数やパラメータの呼び出しを記録するオブジェクトです。
import { ModuleMocker, MockFunctionMetadata } from 'jest-mock';
const moduleMocker = new ModuleMocker(global);
describe('CatsController', () => {
let controller: CatsController;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
})
.useMocker((token) => {
if (token === CatsService) {
return { findAll: jest.fn().mockResolvedValue(results) };
}
if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock();
}
})
.compile();
controller = moduleRef.get(CatsController);
});
});
メモ
golevelup/ts-jestcreateMockのような一般的なモックファクトリーを直接渡すこともできます。
これらのモックは、通常のカスタムプロバイダと同様にテスティングコンテナから取得できます。(moduleRef.get(CatsService)を使う)

エンドツーエンドテスト

個々のモジュールやクラスに焦点を当てるユニットテストとは異なり、エンドツーエンド (e2e) テストは、クラスとモジュールの相互作用をより総合的なレベルで、つまり、エンドユーザが本番システムに持つような相互作用に近い形でカバーします。アプリケーションが成長するにつれ、各 API エンドポイントのエンドツーエンドの動作を手動でテストすることは困難になります。自動化されたエンドツーエンドテストは、システムの全体的な動作が正しく、プロジェクトの要件を満たしていることを確認するのに役立ちます。
e2eテストを行うには、先ほどユニットテストで説明したのと同じような構成を使用します。さらに、NestではHTTPリクエストをシミュレートするためにSupertestライブラリを簡単に使うことができます。
cats.e2e-spec.ts
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { CatsModule } from '../../src/cats/cats.module';
import { CatsService } from '../../src/cats/cats.service';
import { INestApplication } from '@nestjs/common';
describe('Cats', () => {
let app: INestApplication;
let catsService = { findAll: () => ['test'] };
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [CatsModule],
})
.overrideProvider(CatsService)
.useValue(catsService)
.compile();
app = moduleRef.createNestApplication();
await app.init();
});
it(`/GET cats`, () => {
return request(app.getHttpServer())
.get('/cats')
.expect(200)
.expect({
data: catsService.findAll(),
});
});
afterAll(async () => {
await app.close();
});
});
先程のcompile()メソッドに加えて、今度はcreateNestApplication()メソッドを使用して、完全なNest実行環境をインスタンス化します。実行中のアプリへの参照をapp変数に保存し、HTTPリクエストのシミュレーションに使用できるようにします。
Supertestのrequest()関数を使用して、HTTP テストをシミュレートします。このHTTPリクエストは、実行中のNestアプリにルーティングさせたいので、request()関数に、Nestの基盤となるHTTPリスナー(Expressプラットフォームが提供する場合もある)への参照を渡します。そのため、request(app.getHttpServer())という構文になります。request()を呼び出すと、ラップされたHTTPサーバーがNestアプリに接続され、実際のHTTPリクエストをシミュレートするためのメソッドが公開されます。例えば、request(...).get('/cats')を使うと、ネットワーク経由でget '/cats'といった実際のHTTPリクエストと同じリクエストをNestアプリに送ることができます。
この例では、CatsServiceの代替 (test-double) 実装も提供しています。これは、テスト可能なハードコードされた値を単純に返すものです。このような代替実装を提供するには、overrideProvider()を使用します。同様に、Nestは、overrideGuard(), overrideInterceptor(), overrideFilter(), overridePipe()メソッドでそれぞれガード、インターセプタ、フィルタ、およびパイプを継承するメソッドを提供しています。
各オーバーライドメソッドは、カスタムプロバイダーについて説明したものを反映した 3 つの異なるメソッドを持つオブジェクトを返します。
  • useClass:オブジェクトを継承するためにインスタンス化されるクラス (プロバイダ、ガードなど) を指定します。
  • useValue:オブジェクトを継承するインスタンスを指定します。
  • useFactory:そのオブジェクトを継承するインスタンスを返す関数が作成されます。
オーバーライドメソッドの各タイプは、順番にTestingModuleのインスタンスを返すので、流暢なスタイルで他のメソッドと連鎖させることができます。このような連鎖の最後にcompile()を使って、Nest にモジュールをインスタンス化、初期化させる必要があります。
また、(CI サーバなどで) テストを実行する際に独自のロガーを設定したいこともあるでしょう。setLogger()メソッドを使い、 LoggerServiceインターフェースを満たすオブジェクトを渡すことで、 テスト中のロギング方法をTestModuleBuilderに指示します (デフォルトでは、「エラー」ログのみがコンソールに記録されます)。
メモ
e2e のテストファイルはtestディレクトリの中に置いてください。テスト用ファイルの名前に必ず.e2e-specを含めてください。

グローバルに登録されたエンハンサー(enhancer)を継承する

エンハンサーとは、永続的なクラスを書いた後に自動的にコードを追加してくれるツールを意味します。
グローバルに登録されたガード(またはパイプ、インターセプター、フィルター)がある場合、そのエンハンサーをオーバーライドするために、もう少し手順を踏む必要があります。要約すると、元の登録は次のようになります。
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
これは、APP_*トークンを通して、ガードを「マルチ」プロバイダとして登録することです。ここでJwtAuthGuardを置き換えるには、このスロットに既存のプロバイダを登録する必要があります。
providers: [
{
provide: APP_GUARD,
useExisting: JwtAuthGuard,
// ^^^^^^^^ notice the use of 'useExisting' instead of 'useClass'
},
JwtAuthGuard,
],
メモ
Nestがトークンの後ろでインスタンス化する代わりに、登録されたプロバイダを参照するためuseClassuseExistingに変更します。
これで、JwtAuthGuardは通常のプロバイダとして Nest から見えるようになり、TestingModuleを作成する際にオーバーライドできるようになりました。
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(JwtAuthGuard)
.useClass(MockAuthGuard)
.compile();
これで、すべてのテストがすべてのリクエストでMockAuthGuardを使用するようになった。

リクエスト対応のインスタンスを作成する

リクエストに対応したプロバイダは、受信するリクエストごとに一意に作成されます。このインスタンスは、リクエストの処理が完了した後にガベージコレクションされます。テストされたリクエストのために特別に生成された依存性注入のサブツリーにアクセスできないので、これは問題になります。
resolve()メソッドを使用すると、動的にインスタンス化されたクラスを取得できることは (上記のセクションで) 理解しています。また、ここで説明したように、一意のコンテキスト識別子を渡して、DI コンテナサブツリーのライフサイクルを制御できることも分かっています。これをテストの場でどのように活用すればよいのでしょうか。
その方法は、事前にコンテキスト識別子を生成し、Nestがこの特定のIDを使用して、すべての受信リクエストのサブツリーを作成するように強制することです。こうすることで、テストしたリクエストに対して作成されたインスタンスを取得することができます。
これを実現するために、ContextIdFactoryに対してjest.spyOn()を使います。
const contextId = ContextIdFactory.create();
jest
.spyOn(ContextIdFactory, 'getByRequest')
.mockImplementation(() => contextId);
これで、以降のリクエストでcontextIdを使用して生成された1つのコンテナツリーにアクセスできるようになりました。
catsService = await moduleRef.resolve(CatsService, contextId);