NestJS で TDD (1) -- Controller
この記事は、ベーシックな REST API を TDD の手法を使って NestJS で実装していく過程をまとめたものです。全4パートで、各レイヤーのユニットテストと最後にE2Eテストについてご紹介します。このパートは Controller ユニットテストです。
記事作成時点では nestjs@7.6.15
です。
リポジトリ: GitHub - task-k0414/tdd-nestjs-exam-manager
はじめに
NestJS を業務で使いっていく中で、テストをどのように実装すればいいのか模索していたところ、
こちらの素晴らしい記事に出会いました。
この記事では、Controller, Service, Repository の 3レイヤーのアーキテクチャーを採用した、シンプルな REST API をTDDで実装していく過程が紹介されています。
これを元に、サンプルアプリを作ってみたので、同じくテストに思い悩む方の参考になれば幸いです。
背景
3つのレイヤーの説明
- Controller - API を呼び出すエントリーポイント。レスポンスの成形や、リクエストのバリデーションなんかもこのレイヤー
- Service - ビジネスロジックを扱うレイヤー。アプリケーションのコア
- Repository - DB 操作を扱うレイヤー
NestJSではこれらのレイヤーを分離し、DIする方法を提供しています。
シナリオの設定
生徒(User)の試験結果を保存する必要があります。
- 試験結果を入力する API はまだありません (実装する必要があります)
- クラスの先生が、そのデータを入力しようとしています。
保存するデータ
{ "userId": 1, "examId": 1, "score": 9 }
user と exam はそれぞれ存在確認が必要です。
score は 100点満点でそれを超えたデータはバリデーションで弾く必要があります
プロジェクトの準備
npx @nestjs/cli new tdd-nestjs-exam-manager npm i
これで、app.module.ts 他、ルートになるモジュールが生成されます。
すでに、app.controller.spec.ts が用意されているので、テストを実行しておきます
npm test
テストがうまくいったら、試験結果を保存する、exam-result module を作成します NestJS CLI がジェネレーターを提供しているので、それを活用します
npx nest g module exam-result npx nest g controller exam-result
TDD をしていく上で、どのレイヤーから実装していくべきか?という問題がありますが、 今回は深く考えずに、Controller から実装していきます。
Controller のテストを実装
まず、Controller の失敗するテストを書きます
// Controller @Controller('exam-result') export class ExamResultController {}
// test it('should call the save function', () => { const examResult = {} controller.save(examResult) expect(examResultService.save).toHaveBeenCalled() })
この時点で、IDEから警告がでてると思いますが、とりあえず無視します。
現時点で Controller の save method も examResultService もありません。
続いてエラーを解消するために、controller に save method を作り、service と連携させます。
npx nest g service exam-result
@Controller('exam-result') export class ExamResultController { constructor(private readonly examResultService: ExamResultService) {} save(examResult: any) { this.examResultService.save(examResult) } } @Injectable() export class ExamResultService { save(data: any) { throw new Error('Have not implemented') } } // test describe('ExamResultController', () => { let controller: ExamResultController let examResultService: ExamResultService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ExamResultController], providers: [ExamResultService], }).compile() controller = module.get<ExamResultController>(ExamResultController) examResultService = module.get<ExamResultService>(ExamResultService) }) it('should call the save function', () => { const examResult = {} controller.save(examResult) expect(examResultService.save).toHaveBeenCalled() }) })
ここで、Controller のユニットテストとしては、Service の実際の実装に関係がないので、Service をモックする必要があります
jest.mock('./exam-result.service')
これでテストが通りました。 続いて、Controllerのもう一つの仕事である入力データのチェックできる状態を作っていきます
入力データのバリデーション
まずは、リクエストデータの interface を定義します
export interface ISaveExamResultRequest { userId: number examId: number score: number } @Controller('exam-result') export class ExamResultController { constructor(private readonly examResultService: ExamResultService) {} @Post() save(examResult: ISaveExamResultRequest) { this.examResultService.save(examResult) } } // test it('should call the save function', () => { const examResult: ISaveExamResultRequest = { userId: 1, examId: 1, score: 50, } controller.save(examResult) expect(examResultService.save).toHaveBeenCalled() })
入力されるデータが有効かどうかチェックします シナリオから制約は以下の通りです
- user と exam はそれぞれ存在確認が必要です。
- score は 100点満点でそれを超えたデータはバリデーションで弾く必要があります
まずは、score をバリデーションしてみます
@Controller('exam-result') export class ExamResultController { constructor(private readonly examResultService: ExamResultService) {} @Post() save(examResult: ISaveExamResultRequest) { if (examResult.score < 0 || examResult.score > 100) { throw new BadRequestException('Validation failed') } this.examResultService.save(examResult) } }
save()
の中に直接バリデーションを実行しましたが、これでは単一責任原則に反しています。
また、今は score のみをバリデーションしていますが、入力されるデータのチェック項目はそれぞれのフィールドに対して必要で、同じフィールドに対しても複数のチェックが存在します。
そこで NestJS が提供する DTO と Validation Pipe の仕組みをつかってバリデーションを save()
の外に出します。
DTO
今回は公式でも紹介されている class-validator
と class-transformer
を使います
DTO とそのテストを用意します
// DTO @Exclude() export class SaveExamResultRequest implements ISaveExamResultRequest { @Expose() @IsNumber() @IsNotEmpty() userId: number @Expose() @IsNumber() @IsNotEmpty() examId: number @Expose() @IsNumber() @IsNotEmpty() @Max(100) @Min(0) score: number constructor(obj: Record<string, unknown> = {}) { Object.assign(this, obj) } static validate(obj: Record<string, unknown>) { return validate(new SaveExamResultRequest(obj)) } } // test describe('SaveExamResultRequest', () => { it('should return errors when any fields are empty', async () => { const errors = await SaveExamResultRequest.validate({}) expect(errors).toHaveLength(3) }) it('should return errors when any fields are string', async () => { const errors = await SaveExamResultRequest.validate({ userId: 'user1', examId: 'exam1', score: 'scoreless', }) expect(errors).toHaveLength(3) }) it('should return an error when score is more than maximum value', async () => { const errors = await SaveExamResultRequest.validate({ userId: 1, examId: 1, score: 120, }) expect(errors).toHaveLength(1) }) it('should return an error when score is less than minimum value', async () => { const errors = await SaveExamResultRequest.validate({ userId: 1, examId: 1, score: -1, }) expect(errors).toHaveLength(1) }) })
このDTOを body の型に指定して、global pipe で NestJS 標準の ValidationPipe を呼び出すよう設定します。
これで、Controller に処理が渡ってくる時点で正しいデータであることが保証されている状態がつくれたので、Controller自身は service.save()
を呼び出す単一の役割に集中することができます。
// main.ts async function bootstrap() { const app = await NestFactory.create(AppModule) app.useGlobalPipes(new ValidationPipe()) await app.listen(3000) } bootstrap() // Controller save(@Body() examResult: SaveExamResultRequest) { ... } // test it('should call the save function', () => { const examResult: ISaveExamResultRequest = { userId: 1, examId: 1, score: 100, } controller.save(examResult) expect(examResultService.save).toHaveBeenCalledWith(examResult) })
Custom Pipe
続いて、User ID が存在しているかどうかのチェックを、Custom Pipe を用いて行います。
NestJSのCustom Pipeについては ドキュメント をご確認ください
generator を実行
npx nest g pi UserExistenceValidation --flat
UserService がすでにある体で進めていきます。
// Pipe @Injectable() export class UserExistenceValidationPipe implements PipeTransform { constructor(private readonly userService: UserService) {} async transform(value: { userId: number }) { if (!(await this.userService.exists(value.userId))) { throw new BadRequestException('User ID is not correct') } return value } } // test jest.mock('./user.service') describe('UserExistenceValidationPipe', () => { let userExistenceValidationPipe: UserExistenceValidationPipe let userService: UserService beforeEach(() => { userService = new UserService() userExistenceValidationPipe = new UserExistenceValidationPipe(userService) }) it('should be defined', () => { expect(userExistenceValidationPipe).toBeDefined() }) it('should throw validation error', () => { expect.assertions(1) jest.spyOn(userService, 'exists').mockResolvedValue(false) return expect( userExistenceValidationPipe.transform({ userId: 99 }), ).rejects.toThrowError() }) it('should return validated object', () => { expect.assertions(1) jest.spyOn(userService, 'exists').mockResolvedValue(true) const payload = { userId: 99, anotherProperty: 'another property' } return expect( userExistenceValidationPipe.transform(payload), ).resolves.toEqual(payload) }) })
余談ですが、expect.assertions()
を入れることで、実行されたテストの個数を確認することができます。非同期処理のテストでは、お作法を守らないとテスト実行がすり抜けてしまうため、必ず書くようにしましょう。
Exam ID にも同じものを用意します。(Userと全く同じなので省略)
NestJS の強力な DI で純粋な Controller テスト
これを@Body()
の引数に渡すことで、関数の実行前に User の存在確認が行えます。
また、インスタンスではなくInjectable Class をそのまま渡すことで、必要な依存関係をModule側でDIできます。
@Controller('exam-result') export class ExamResultController { constructor(private readonly examResultService: ExamResultService) {} @Post() save( @Body(UserExistenceValidationPipe, ExamExistenceValidationPipe) examResult: SaveExamResultRequest, ) { this.examResultService.save(examResult) } } // test jest.mock('./exam-result.service') jest.mock('../user') jest.mock('../exam') describe('ExamResultController', () => { let controller: ExamResultController let examResultService: ExamResultService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ExamResultController], providers: [ExamResultService], }).compile() controller = module.get<ExamResultController>(ExamResultController) examResultService = module.get<ExamResultService>(ExamResultService) }) it('should call the save function', () => { const examResult: ISaveExamResultRequest = { userId: 1, examId: 1, score: 100, } controller.save(examResult) expect(examResultService.save).toHaveBeenCalledWith(examResult) }) })
ここまでで、クリーンな Controller とそのテストを実装することができました。
Controller って割といろんなサービスを呼び出したりして、複雑になってしまうことが多いな、と感じていたのですが NestJS の仕組みをつかってシンプルに片付けることができて感動しました。
次の記事では Service ユニットテストについてご紹介します。
参考
TDD Typescript NestJS API Layers with Jest Part 1: Controller Unit Test - DZone Web Dev
NestJS の DTO と Validation の基本 - 型定義とデコレータで安全にデータを受け付ける - Qiita