最近、私は NestJS プロジェクトの単体テストと E2E テストを作成しています。バックエンド プロジェクトのテストを作成するのは初めてですが、そのプロセスがフロントエンド テストの経験とは異なるため、始めるのが大変でした。いくつかの例を見た後、テストへの取り組み方をより明確に理解できたので、同じような混乱に直面している他の人を助けるために、記事を書いて自分の学習を記録し共有する予定です。
さらに、関連するユニットと E2E テストが完了したデモ プロジェクトをまとめました。これは興味深いかもしれません。コードは Github にアップロードされました: https://github.com/woai3c/nestjs-demo.
単体テストと E2E テストはソフトウェア テストの方法ですが、目的と範囲が異なります。
単体テストには、ソフトウェア内のテスト可能な最小単位のチェックと検証が含まれます。たとえば、関数やメソッドを 1 つの単位とみなすことができます。単体テストでは、関数のさまざまな入力に対して期待される出力を提供し、その動作の正確さを検証します。単体テストの目的は、関数内のバグを迅速に特定することです。また、バグは簡単に記述でき、迅速に実行できます。
一方、E2E テストでは、実際のユーザー シナリオをシミュレートしてアプリケーション全体をテストすることがよくあります。たとえば、フロントエンドは通常、テストにブラウザまたはヘッドレス ブラウザを使用しますが、バックエンドは API 呼び出しをシミュレートすることによってテストを行います。
NestJS プロジェクト内では、Users モジュールの更新メソッドがユーザーを正しく更新するかどうかを検証するなど、単体テストで特定のサービスやコントローラーのメソッドを評価する場合があります。ただし、E2E テストでは、新しいユーザーの作成からパスワードの更新、最終的にはユーザーの削除に至るまで、複数のサービスとコントローラーが関与する完全なユーザー ジャーニーを検査する場合があります。
インターフェイスを含まないユーティリティ関数またはメソッドの単体テストを記述するのは比較的簡単です。必要なのは、さまざまな入力を考慮して、対応するテスト コードを作成することだけです。ただし、インターフェイスが登場すると、状況はさらに複雑になります。例としてコードを使用してみましょう:
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
上記のコードは、auth.service.ts ファイル内のメソッド validateUser であり、主にログイン中にユーザーが入力したユーザー名とパスワードが正しいかどうかを確認するために使用されます。これには次のロジックが含まれています:
ご覧のとおり、validateUser メソッドには 4 つの処理ロジックが含まれており、validateUser 関数全体が正しく動作していることを確認するには、これら 4 つのポイントに対応する単体テスト コードを記述する必要があります。
単体テストの作成を開始すると、問題が発生します。findOne メソッドはデータベースと対話する必要があり、ユーザー名を使用してデータベース内で対応するユーザーを検索します。ただし、すべての単体テストでデータベースと対話する必要がある場合、テストは非常に煩雑になります。したがって、これを達成するために偽のデータを模擬することができます。
たとえば、woai3c という名前のユーザーを登録したとします。次に、ログイン中に、 const enity = await this.usersService.findOne({ username }); を通じて validateUser メソッドでユーザー データを取得できます。このコード行が目的のデータを返すことができれば、データベースとの対話がなくても問題はありません。これはモックデータを通じて実現できます。次に、validateUser メソッドに関連するテスト コードを見てみましょう。
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
usersService の findOne メソッドを呼び出してユーザー データを取得するため、テスト コードで usersService の findOne メソッドをモックする必要があります。
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<Record<keyof UsersService, jest.Mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); });
jest.fn() を使用して、実際の usersService.findOne() を置き換える関数を返します。ここで usersService.findOne() を呼び出した場合、戻り値はないため、最初の単体テスト ケースはパスします:
beforeEach(async () => { usersService = { findOne: jest.fn(), // mock findOne method }; const module = await Test.createTestingModule({ providers: [ AuthService, // real AuthService, because we are testing its methods { provide: UsersService, // use mock usersService instead of real usersService useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); });
const エンティティ内の findOne = await this.usersService.findOne({ username }); validateUser メソッドの は戻り値のない偽の関数であり、validateUser メソッドのコードの 2 行目から 4 行目は実行できます。
it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); });
401 エラーをスローします。これは予想どおりです。
validateUser メソッドの 2 番目のロジックは、ユーザーがロックされているかどうかを判断するもので、対応するコードは次のとおりです。
if (!entity) { throw new UnauthorizedException('User not found'); }
ご覧のとおり、ユーザー データにロック時間 lockUntil があり、ロック終了時間が現在時刻より大きい場合、現在のアカウントがロックされていると判断できます。したがって、lockUntil フィールドを使用してユーザー データをモックする必要があります:
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
上記のテスト コードでは、オブジェクト lockedUser が最初に定義されており、これには必要な lockUntil フィールドが含まれています。次に、usersService.findOne.mockResolvedValueOnce(lockedUser); によって実現される findOne の戻り値として使用されます。したがって、 validateUser メソッドが実行されると、その中のユーザー データはモック データとなり、2 番目のテスト ケースを正常に通過させます。
単体テスト カバレッジ (コード カバレッジ) は、アプリケーション コードのどれだけが単体テストによってカバーまたはテストされたかを説明するために使用される指標です。通常、これはパーセンテージで表され、考えられるすべてのコード パスのうちどれだけがテスト ケースによってカバーされているかを示します。
単体テストのカバレッジには通常、次のタイプが含まれます:
単体テストのカバレッジは単体テストの品質を測定するための重要な指標ですが、それだけが唯一の指標ではありません。カバレッジ率が高いとコード内のエラーを検出しやすくなりますが、コードの品質が保証されるわけではありません。カバレッジ率が低いということは、テストされていないコードが存在し、検出されていないエラーがある可能性があることを意味する可能性があります。
下の画像は、デモ プロジェクトの単体テスト カバレッジの結果を示しています。
サービスやコントローラーなどのファイルの場合は、単体テストのカバレッジが高い方が一般的に良いのですが、モジュールのようなファイルの場合は、単体テストを書く必要はなく、意味がないので書くこともできません。上の画像は、単体テスト カバレッジ全体の全体的なメトリクスを表しています。特定の関数のテスト カバレッジを表示したい場合は、プロジェクトのルート ディレクトリにあるcoverage/lcov-report/index.html ファイルを開くことができます。たとえば、validateUser メソッドの特定のテスト状況を確認したいとします。
ご覧のとおり、validateUser メソッドの元の単体テスト カバレッジは 100% ではなく、実行されなかったコード行がまだ 2 行あります。ただし、4 つの主要な処理ノードには影響しないため、これはあまり重要ではありません。また、一次元的に高いテスト カバレッジを追求すべきではありません。
単体テストでは、模擬データを使用して、各機能が確実にテストできることを確認して、 validateUser() 関数の各機能の単体テストを作成する方法を示しました。 e2E テストでは、実際のユーザー シナリオをシミュレートする必要があるため、テストのためにデータベースに接続する必要があります。したがって、テストする auth.service.ts モジュール内のメソッドはすべてデータベースと対話します。
認証モジュールには主に次の機能が含まれています:
E2E テストでは、ユーザーの登録から削除まで、これら 6 つの機能を 1 つずつテストする必要があります。テスト中に、テストを実行する専用のテスト ユーザーを作成し、テスト データベースに不要な情報が残らないように、完了時にこのテスト ユーザーを削除できます。
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
beforeAll フック関数はすべてのテストが開始される前に実行されるため、ここでテスト アカウント TEST_USER_NAME を登録できます。 afterAll フック関数はすべてのテストが終了した後に実行されるため、ここでテスト アカウント TEST_USER_NAME を削除するのに適しており、また、登録および削除関数のテストにも便利です。
前のセクションの単体テストでは、validateUser メソッドに関連する単体テストを作成しました。実際には、このメソッドはログイン時に実行され、ユーザーのアカウントとパスワードが正しいかどうかを検証します。したがって、この e2E テストでもログイン プロセスを使用して、e2E テスト ケースの作成方法を示します。
ログイン テスト プロセス全体には、次の 5 つの小さなテストが含まれます。
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<Record<keyof UsersService, jest.Mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); });
これら 5 つのテストは次のとおりです:
それでは、e2E テストの作成を開始しましょう:
beforeEach(async () => { usersService = { findOne: jest.fn(), // mock findOne method }; const module = await Test.createTestingModule({ providers: [ AuthService, // real AuthService, because we are testing its methods { provide: UsersService, // use mock usersService instead of real usersService useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); });
e2E テスト コードの作成は比較的簡単です。インターフェイスを呼び出して結果を確認するだけです。たとえば、ログイン テストが成功した場合、返される結果が 200 であることを確認するだけです。
最初の 4 つのテストは非常に簡単です。次に、アカウントがロックされているかどうかを確認する、少し複雑な e2E テストを見てみましょう。
async validateUser( username: string, password: string, ): Promise<UserAccountDto> { const entity = await this.usersService.findOne({ username }); if (!entity) { throw new UnauthorizedException('User not found'); } if (entity.lockUntil && entity.lockUntil > Date.now()) { const diffInSeconds = Math.round((entity.lockUntil - Date.now()) / 1000); let message = `The account is locked. Please try again in ${diffInSeconds} seconds.`; if (diffInSeconds > 60) { const diffInMinutes = Math.round(diffInSeconds / 60); message = `The account is locked. Please try again in ${diffInMinutes} minutes.`; } throw new UnauthorizedException(message); } const passwordMatch = bcrypt.compareSync(password, entity.password); if (!passwordMatch) { // $inc update to increase failedLoginAttempts const update = { $inc: { failedLoginAttempts: 1 }, }; // lock account when the third try is failed if (entity.failedLoginAttempts + 1 >= 3) { // $set update to lock the account for 5 minutes update['$set'] = { lockUntil: Date.now() + 5 * 60 * 1000 }; } await this.usersService.update(entity._id, update); throw new UnauthorizedException('Invalid password'); } // if validation is sucessful, then reset failedLoginAttempts and lockUntil if ( entity.failedLoginAttempts > 0 || (entity.lockUntil && entity.lockUntil > Date.now()) ) { await this.usersService.update(entity._id, { $set: { failedLoginAttempts: 0, lockUntil: null }, }); } return { userId: entity._id, username } as UserAccountDto; }
ユーザーが 3 回連続でログインに失敗すると、アカウントはロックされます。したがって、このテストでは、テスト アカウント TEST_USER_NAME を使用できません。テストが成功すると、このアカウントはロックされ、次のテストを続行できなくなります。特にアカウント ロックをテストするために別の新しいユーザー TEST_USER_NAME2 を登録し、テストが成功したらこのユーザーを削除する必要があります。ご覧のとおり、この e2E テストのコードは非常に充実しており、セットアップと分解に多くの作業が必要ですが、実際のテスト コードは次の数行だけです。
import { Test } from '@nestjs/testing'; import { AuthService } from '@/modules/auth/auth.service'; import { UsersService } from '@/modules/users/users.service'; import { UnauthorizedException } from '@nestjs/common'; import { TEST_USER_NAME, TEST_USER_PASSWORD } from '@tests/constants'; describe('AuthService', () => { let authService: AuthService; // Use the actual AuthService type let usersService: Partial<Record<keyof UsersService, jest.Mock>>; beforeEach(async () => { usersService = { findOne: jest.fn(), }; const module = await Test.createTestingModule({ providers: [ AuthService, { provide: UsersService, useValue: usersService, }, ], }).compile(); authService = module.get<AuthService>(AuthService); }); describe('validateUser', () => { it('should throw an UnauthorizedException if user is not found', async () => { await expect( authService.validateUser(TEST_USER_NAME, TEST_USER_PASSWORD), ).rejects.toThrow(UnauthorizedException); }); // other tests... }); });
e2E テスト コードの作成は比較的簡単です。模擬データやテスト カバレッジを考慮する必要はありません。システムプロセス全体が期待どおりに実行されれば十分です。
可能であれば、通常はテストを書くことをお勧めします。これにより、システムの堅牢性、保守性、開発効率が向上します。
コードを記述するときは、通常、コア機能が適切に動作することを確認するために、通常の入力でのプログラム フローに重点を置きます。ただし、異常な入力などの特殊なケースは見落とされることがよくあります。テストを書くとこれが変わります。このような場合にどのように対処するかを検討し、適切に対応することでクラッシュを防ぐ必要があります。テストを書くことは間接的にシステムの堅牢性を向上させると言えます。
包括的なテストを含む新しいプロジェクトを引き継ぐのは、とても楽しいことです。これらはガイドとして機能し、さまざまな機能をすぐに理解するのに役立ちます。関数のコードを 1 行ずつ確認することなく、テスト コードを見るだけで、各関数の期待される動作と境界条件を簡単に把握できます。
しばらく更新されていなかったプロジェクトが突然新しい要件を受け取ったと想像してください。変更を加えた後、バグが発生するのではないかと心配になるかもしれません。テストがなければ、プロジェクト全体を手動で再度テストする必要があり、時間が無駄になり非効率的になります。完全なテストでは、コードの変更が既存の機能に影響を与えているかどうかを 1 つのコマンドで知ることができます。エラーがあった場合でも、すぐに特定して対処できます。
短期プロジェクトや要件の反復が非常に速いプロジェクトの場合、テストを作成することはお勧めできません。たとえば、イベントが終了すると役に立たなくなるイベント用のプロジェクトにはテストが必要ありません。また、要件の反復が非常に速いプロジェクトの場合、テストを作成すると開発効率が向上すると言いましたが、それは関数の反復が遅いという前提に基づいています。完了した関数が 1 ~ 2 日で変更される場合は、関連するテスト コードを書き直す必要があります。したがって、テストを作成するのは非常に時間がかかり、労力を費やす価値がないため、テストをまったく作成せず、代わりにテスト チームに頼る方が良いでしょう。
NestJS プロジェクトの単体テストと e2E テストの作成方法を詳しく説明した後、テストの重要性を繰り返しておきたいと思います。システムの堅牢性、保守性、開発効率を向上させることができます。テストを作成する機会がない場合は、自分で練習プロジェクトを開始するか、オープンソース プロジェクトに参加してコードを貢献することをお勧めします。オープンソース プロジェクトには通常、より厳しいコード要件があります。コードに貢献するには、新しいテスト ケースを作成したり、既存のテスト ケースを変更したりすることが必要になる場合があります。
以上がNestJS アプリケーションの単体テストと Etest を作成する方法の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。