ハードルを下げ続けるブログ@task

常に低い意識で投稿していきたいエンジニアのブログ。当ブログの内容は、個人の見解であり所属する組織の公式見解ではありませんよ。

NestJS で TDD (1) -- Controller

この記事は、ベーシックな REST API を TDD の手法を使って NestJS で実装していく過程をまとめたものです。全4パートで、各レイヤーのユニットテストと最後にE2Eテストについてご紹介します。このパートは Controller ユニットテストです。

記事作成時点では nestjs@7.6.15 です。

リポジトリ: GitHub - task-k0414/tdd-nestjs-exam-manager

はじめに

NestJS を業務で使いっていく中で、テストをどのように実装すればいいのか模索していたところ、

dzone.com

こちらの素晴らしい記事に出会いました。

この記事では、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-validatorclass-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