【Nx】【NestJS】【Prisma】Jestで本物のDBを使ったAPI単体テスト入門

# 前提
- Nxプロジェクトが作成済み
- Nestアプリが作成済み
- jest-prismaはプレビュー版である$transactionをベースとしているため一旦使わない

# はじめに
省略

# 追記 2023/02/12
以下ではbeforeEachにDBのmigrate処理を入れているため、テストケースが増えるごとにテスト総時間が肥大化してしまう。
また、テスト実行コマンドやディレクトリ構成によっては環境変数の読み込みタイミングが悪さし、上書きされずに本DBを初期化してしまう可能性があることが判明した。
そのため、jest.config.jsにDBのmigrate処理を別で切り出して、前テスト横断して必ず環境変数を上書きしつつテスト用DBを作成することをおすすめする。

# 参考
- [prisma-fabbrica](https://github.com/Quramy/prisma-fabbrica)
- [Integrated testing with Prisma](https://quramy.medium.com/integrated-testing-with-prisma-4bc73404d027)
- [Prisma で本物のDBMSを使って自動テストを書く](https://www.mizdra.net/entry/2022/11/24/153459)
- [Prisma の Seed ファイルを複数にする方法](https://zenn.dev/a_o/articles/7a60edeb9aa26e)

# Nx × NestJS × Prisma × Jest
## Node Version
```sh
% node -v
v18.13.0
% npm -v
8.19.3
```
## Nx
ライブラリを作成し、リポジトリ間で共通のリソースがあればここに格納していくと良い
```sh
% nx g @nrwl/nest:library nestjslib ←任意の名称で良いが下記のimportで指定しているためベタ打ち
```
## 依存関係
[package.json]
```json
{
  "name": "translate-app",
  "version": "0.0.0",
  "license": "MIT",
  "scripts": {},
  "private": true,
  "dependencies": {
    "@nestjs/common": "^9.0.0",
    "@nestjs/core": "^9.0.0",
    "@nestjs/platform-express": "^9.0.0",
    "@nestjs/swagger": "^6.1.4",
    "@prisma/client": "^4.8.1",
    "@quramy/prisma-fabbrica": "^1.0.0",
    "class-transformer": "^0.5.1",
    "class-validator": "^0.13.2",
    "dayjs": "^1.11.7",
    "prisma": "^4.8.1",
    "reflect-metadata": "^0.1.13",
    "rxjs": "^7.0.0",
    "supertest": "^6.3.3",
    "tslib": "^2.3.0"
  },
  "devDependencies": {
    "@nestjs/schematics": "^9.0.0",
    "@nestjs/testing": "^9.0.0",
    "@nrwl/eslint-plugin-nx": "15.4.5",
    "@nrwl/jest": "15.4.5",
    "@nrwl/linter": "15.4.5",
    "@nrwl/nest": "15.4.5",
    "@nrwl/node": "15.4.5",
    "@nrwl/nx-cloud": "latest",
    "@nrwl/workspace": "15.4.5",
    "@quramy/jest-prisma": "^1.3.1",
    "@types/jest": "28.1.1",
    "@types/node": "18.7.1",
    "@typescript-eslint/eslint-plugin": "^5.36.1",
    "@typescript-eslint/parser": "^5.36.1",
    "eslint": "~8.15.0",
    "eslint-config-prettier": "8.1.0",
    "jest": "28.1.1",
    "jest-environment-jsdom": "28.1.1",
    "nx": "15.4.5",
    "prettier": "^2.6.2",
    "ts-jest": "28.0.5",
    "ts-node": "10.9.1",
    "typescript": "~4.8.2"
  },
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  }
}
```

```sh
% npm install
```

## Prisma
```sh
% npx prisma init
```
### スキーマ
[/prisma/schema.prisma]DBスキーマ定義を編集
```TS
generator client {
  provider = "prisma-client-js"
}

generator fabbrica { //テストツール
  provider = "prisma-fabbrica"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

//master tables
model role_type {
  id    Int     @default(autoincrement()) @id
  role String  @unique
  users user[]
}

model inquiry_status {
  id    Int     @default(autoincrement()) @id
  status String  @unique
  inquiries inquiry[]
}

//transaction tables
model user {
  id    Int     @default(autoincrement()) @id
  role_type role_type @relation(fields: [role_type_Id], references: [id])
  role_type_Id Int
  email String  @unique
  name  String
  password String
  inquiryUsers inquiry[] @relation("InquiryUser")
  answerUsers inquiry[] @relation("AnswerUser")
}

model inquiry {
  id    Int     @default(autoincrement()) @id
  inquiry_user user @relation("InquiryUser", fields: [inquiry_user_id], references: [id])
  inquiry_user_id Int
  answer_user user? @relation("AnswerUser", fields: [answer_user_id], references: [id])
  answer_user_id Int?
  inquiry_status inquiry_status @relation(fields: [inquiry_status_id], references: [id])
  inquiry_status_id Int
  title  String
  detail String
  creation_datetime DateTime @default(now())
  update_datetime DateTime?
}
```
スキーマをDBに反映
```sh
% npx prisma migrate dev
```
### seed
[libs/nestjslib/src/lib/prisma/enum/master.enum.ts]マスタデータの定数定義
```TS
export enum RoleType {
    ADMIN = '管理者',
    USER = 'ユーザ',
}

export enum InquiryStatus {
    NEW = '新規',
    ANSWERED = '回答済',
}
```
[/prisma/seed.ts]マスターテーブルの初期データ
```TS
import { PrismaClient, Prisma } from '@prisma/client'
import { RoleType, InquiryStatus } from '../libs/nestjslib/src/lib/prisma/enum/master.enum';
const prisma = new PrismaClient()

// マスターテーブルのデータ定義
const roleTypeData: Prisma.role_typeCreateInput[] = [
    {
        role: RoleType.ADMIN
    },
    {
        role: RoleType.USER
    },
]

const inquiryStatusData: Prisma.inquiry_statusCreateInput[] = [
    {
        status: InquiryStatus.NEW
    },
    {
        status: InquiryStatus.ANSWERED
    },
]

const transfer = async () => {
    for (const insertData of roleTypeData) {
        await prisma.role_type.create({
            data: insertData,
        })
    }
    for (const insertData of inquiryStatusData) {
        await prisma.inquiry_status.create({
            data: insertData,
        })
    }
    return true;
}

// 定義されたデータを実際のモデルへ登録する処理
const main = async () => {
    console.log(`Start seeding ...`)

    await transfer();

    console.log(`Seeding finished.`)
}

// 処理開始
main()
    .catch((e) => {
        console.error(e)
        process.exit(1)
    })
    .finally(async () => {
        await prisma.$disconnect()
    })
```
初期データ投入
```sh
% npx prisma db seed
```
## NestJS
### Pipe
[libs/nestjslib/src/lib/pipe/validation.pipe.ts]DTOエラーバリデーション
```TS
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';

type ErrorResponse = {
  errorCode: string
  errorMessage: string
}

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      Object.keys(errors[0].contexts).map(key => {
        const firstErrorResponse: ErrorResponse = errors[0].contexts[key];
        throw new BadRequestException(firstErrorResponse);
      })
    }
    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}
```
[apps/backend/src/main.ts]main.tsで上記のPipeをグローバル設定
```TS
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
import { ValidationPipe } from 'libs/nestjslib/src/lib/pipe/validation.pipe';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const config = new DocumentBuilder()
    .setTitle('Translate App')
    .setVersion('1.0')
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);

  app.useGlobalPipes(new ValidationPipe()) //Pipeのグローバル設定
  const port = process.env.PORT || 3333;
  await app.listen(port);
  Logger.log(
    `🚀 Application is running on: http://localhost:${port}`
  );
}

bootstrap();
```
### DTO
[/src/app/inquiries/dto/create-inquiry.dto.ts]DTOクラス(POST)
```TS
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';

export class CreateInquiryReqBodyDto {
    @ApiProperty({
        description: '問い合わせタイトル',
        example: '英訳不備について',
    })
    @IsNotEmpty({
        context: {
            errorCode: 'E0001',
            errorMessage: '問い合わせタイトルを入力してください。',
        },
    })
    inquiryTitle: string;

    @ApiProperty({
        description: '問い合わせ詳細',
        example: '英訳に不備があります。',
    })
    @IsNotEmpty({
        context: {
            errorCode: 'E0001',
            errorMessage: '問い合わせ詳細を入力してください。',
        },
    })
    inquiryDetail: string;
}

export class CreateInquiryResBodyDto {
    @ApiProperty({
        description: '問い合わせID',
        example: '1',
    })
    id: number;

    @ApiProperty({
        description: '問い合わせユーザ名',
        example: 'hoge',
    })
    inquiryUserName: string;

    @ApiProperty({
        description: '問い合わせ日時',
        example: '2023-01-01 01:01:01',
    })
    creationDatetime: string;
}
```
[/src/app/inquiries/dto/find-inquiry.dto.ts]DTOクラス(GET)
```TS
import { Optional } from '@nestjs/common';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class FindInquiryReqParamDto {
    @ApiPropertyOptional({
        description: '問い合わせユーザ名(検索)',
        example: 'hoge',
    })
    @Optional()
    inquiryUserName?: string;

    @ApiPropertyOptional({
        description: '回答ユーザ名(検索)',
        example: 'fuga',
    })
    @Optional()
    answerUserName?: string;

    @ApiPropertyOptional({
        description: '問い合わせタイトル(検索)',
        example: '英訳',
    })
    @Optional()
    inquiryTitle?: string;

    @ApiPropertyOptional({
        description: '問い合わせ詳細(検索)',
        example: '英訳。',
    })
    @Optional()
    inquiryDetail?: string;

    @ApiPropertyOptional({
        description: '問い合わせ日(From)',
        example: '2023-01-01',
    })
    @Optional()
    creationDateFrom?: string;
    
    @ApiPropertyOptional({
        description: '問い合わせ日(To)',
        example: '2023-01-02',
    })
    @Optional()
    creationDateTo?: string;
}

export class FindInquiryResBodyDto {
    @ApiProperty({
        description: '問い合わせID',
        example: '1',
    })
    id: number;

    @ApiProperty({
        description: '問い合わせユーザ名',
        example: 'hoge',
    })
    inquiryUserName: string;

    @ApiProperty({
        description: '回答ユーザ名',
        example: 'hoge',
    })
    answerUserName: string;

    @ApiProperty({
        description: '問い合わせタイトル',
        example: '英訳不備について',
    })
    inquiryTitle: string;

    @ApiProperty({
        description: '問い合わせ詳細',
        example: '英訳に不備があります。',
    })
    inquiryDetail: string;

    @ApiProperty({
        description: '問い合わせ日時',
        example: '2023-01-01 01:01:01',
    })
    creationDatetime: string;

    @ApiProperty({
        description: '回答日時',
        example: '2023-01-01 03:01:01',
    })
    updateDatetime: string;
}
```
### コントローラ
[/src/app/inquiries/inquiries.controller.ts]コントローラクラス
```TS
import {
  Controller,
  Get,
  Post,
  Body,
  Param,
} from '@nestjs/common';
import { InquiriesService } from './inquiries.service';
import { CreateInquiryReqBodyDto, CreateInquiryResBodyDto } from './dto/create-inquiry.dto';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { FindInquiryReqParamDto, FindInquiryResBodyDto } from './dto/find-inquiry.dto';

@ApiTags('inquiries')
@Controller('inquiries')
export class InquiriesController {
  constructor(private readonly inquiriesService: InquiriesService) {}

  @Post()
  @ApiOkResponse({type: CreateInquiryResBodyDto})
  create(@Body() createInquiryReqBodyDto: CreateInquiryReqBodyDto): Promise<CreateInquiryResBodyDto> {
    const inquiryUserId: number = 1;
    return this.inquiriesService.create(inquiryUserId, createInquiryReqBodyDto);
  }

  @Get()
  @ApiOkResponse({type: FindInquiryResBodyDto, isArray: true})
  findAll(@Param() param: FindInquiryReqParamDto): Promise<FindInquiryResBodyDto[]> {
    return this.inquiriesService.findAll(param);
  }
}
```
### サービス
[/src/app/inquiries/inquiries.service.ts]サービスクラス
```TS
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateInquiryReqBodyDto, CreateInquiryResBodyDto } from './dto/create-inquiry.dto';
import { PrismaService } from 'libs/nestjslib/src/lib/prisma/prisma.service';
import { InquiryStatus } from 'libs/nestjslib/src/lib/prisma/enum/master.enum';
import dayjs from 'dayjs';
import { FindInquiryReqParamDto, FindInquiryResBodyDto } from './dto/find-inquiry.dto';

@Injectable()
export class InquiriesService {
  constructor(private prisma: PrismaService) {}

  async create(inquiryUserId: number, createInquiryReqBodyDto: CreateInquiryReqBodyDto): Promise<CreateInquiryResBodyDto> {
    try{
      const {inquiryTitle, inquiryDetail} = createInquiryReqBodyDto

      const insertData = await this.prisma.inquiry.create({
        select: {
          id: true,
          creation_datetime: true,
          inquiry_user: {
            select: {
              name: true
            }
          }
        },
        data: {
          inquiry_user: {
            connect: {id: inquiryUserId},
          },
          title: inquiryTitle,
          detail: inquiryDetail,
          inquiry_status: {
            connect: {status: InquiryStatus.NEW},
          },
          creation_datetime: dayjs().toDate()// .format('YYYY-MM-DD HH:mm:ss')
        },
      })

      return {
        id: insertData.id,
        inquiryUserName: insertData.inquiry_user.name,
        creationDatetime: insertData.creation_datetime.toString()
      }
    }
    catch(err){
      throw new HttpException({
        errorCode: 'E9999',
        errorMessage: `システムエラーが発生しました。`,
      },
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }

  async findAll(param: FindInquiryReqParamDto): Promise<FindInquiryResBodyDto[]> {
    try{
      const foundList = await this.prisma.inquiry.findMany({ //where句省略
        include: {
          inquiry_user: true,
          answer_user: true
        }
      });
      const findInquiryResBodyDtoList: FindInquiryResBodyDto[] = foundList.map((data) => {
        return {
          id: data.id,
          inquiryUserName: data.inquiry_user.name,
          answerUserName: data.answer_user? data.answer_user.name: '',
          inquiryTitle: data.title,
          inquiryDetail: data.detail,
          creationDatetime: dayjs(data.creation_datetime).format('YYYY-MM-DD HH:mm:ss'),
          updateDatetime: data.update_datetime? dayjs(data.update_datetime).format('YYYY-MM-DD HH:mm:ss'): ''
        }
      })
      return findInquiryResBodyDtoList;
    }
    catch(err){
      throw new HttpException({
        errorCode: 'E9999',
        errorMessage: `システムエラーが発生しました。`,
      },
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }
}
```
## Jest
### APIテストソース
[/src/app/inquiries/inquiries.controller.spec.ts]
```TS
import { Test, TestingModule } from '@nestjs/testing';
import { execSync } from 'child_process';
import request from 'supertest';
import { INestApplication } from '@nestjs/common';
import { AppModule } from '../app.module';
import { CreateInquiryReqBodyDto, CreateInquiryResBodyDto } from './dto/create-inquiry.dto';
import { ValidationPipe } from 'libs/nestjslib/src/lib/pipe/validation.pipe';
import { initialize, defineuserFactory, defineinquiryFactory } from "../../../../../src/__generated__/fabbrica"; //謎にエラーが出るが実行可能
import { InquiryStatus, RoleType } from 'libs/nestjslib/src/lib/prisma/enum/master.enum';
import { PrismaClient } from '@prisma/client';
import dayjs from 'dayjs';
import { FindInquiryResBodyDto } from './dto/find-inquiry.dto';

let app: INestApplication;
process.env.DATABASE_URL = `${process.env.DATABASE_URL}-test-${process.env.JEST_WORKER_ID}`;
const prisma = new PrismaClient();
initialize({ prisma });

describe('InquiriesController with DB initialize', () => { //DBのセットアップを伴うテスト

  beforeEach(async () => {
    //テストを実行する前に、前のテストで insert されたレコードを削除しつつ、スキーマも最新のものに更新する。
    execSync('npx prisma migrate reset --force ', {
      env: {
        ...process.env,
      },
    });

    //共通テストユーザ
    const userFactory = defineuserFactory({
      defaultData: {
        role_type: {connect: {role: RoleType.USER}},
        name: 'hoge'
      },
    });
    await userFactory.create();

    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe())//DTOで定義した単項目チェックを適用
    await app.init();
  }, 10000); //itの第3引数やjest.setTimeoutでは変更できなかった

  it('001: GET /inquiries (OK)', async () => { //0件
    //リクエスト
    const res = await request(app.getHttpServer())
    .get('/inquiries')

    //レスポンスステータスコードチェック
    expect(res.status).toEqual(200);

    //レスポンスボディチェック
    const findInquiryResBodyDtoList: FindInquiryResBodyDto[] = res.body;
    expect(findInquiryResBodyDtoList).toHaveLength(0); //問い合わせ数
  });

  it('002: GET /inquiries (OK)', async () => { //1件
    //テストデータ(問い合わせ)
    const inquiryFactory = defineinquiryFactory({
      defaultData: {
        inquiry_user: {
          connect: {id: 1},
        },
        inquiry_status: {
          connect: {status: InquiryStatus.NEW},
        },
        creation_datetime: dayjs().toDate()
      },
    });
    await inquiryFactory.create();

    //リクエスト
    const res = await request(app.getHttpServer())
    .get('/inquiries')

    //レスポンスステータスコードチェック
    expect(res.status).toEqual(200);

    //レスポンスボディチェック
    const findInquiryResBodyDtoList: FindInquiryResBodyDto[] = res.body;
    expect(findInquiryResBodyDtoList).toHaveLength(1); //問い合わせ数
  });

  it('003: POST /inquiries (OK)', async () => { //OKパターン
    const createInquiryReqBodyDto: CreateInquiryReqBodyDto = {
      inquiryTitle: '問い合わせタイトル',
      inquiryDetail: '問い合わせ内容'
    }

    //リクエスト
    const res = await request(app.getHttpServer())
    .post('/inquiries')
    .set('Accept', 'application/json')
    .send(createInquiryReqBodyDto);
    
    //レスポンスステータスコードチェック
    expect(res.status).toEqual(201);

    //レスポンスボディチェック
    const createInquiryResBodyDto: CreateInquiryResBodyDto = res.body;
    expect(createInquiryResBodyDto.id).toEqual(1); //問い合わせID
    expect(createInquiryResBodyDto.inquiryUserName).toEqual('hoge'); //問い合わせユーザ名
    expect(createInquiryResBodyDto).toHaveProperty('creationDatetime'); //時刻はプロパティの有無確認だけ？
  });
});

describe('InquiriesController without DB initialize', () => {

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe())//DTOで定義した単項目チェックを適用
    await app.init();
  });

  it('004: POST /inquiries (NG)', async () => {
    const createInquiryReqBodyDto: CreateInquiryReqBodyDto = {
      inquiryTitle: '', //必須チェックエラー
      inquiryDetail: '問い合わせ内容'
    }
    const errorResponse = {
      errorCode: 'E0001',
      errorMessage: '問い合わせタイトルを入力してください。'
    }

    //リクエスト
    return request(app.getHttpServer())
    .post('/inquiries')
    .set('Accept', 'application/json')
    .send(createInquiryReqBodyDto)
    .expect(400)
    .expect(errorResponse);
  });

  it('005: POST /inquiries (NG)', async () => {
    const createInquiryReqBodyDto: CreateInquiryReqBodyDto = {
      inquiryTitle: '問い合わせタイトル',
      inquiryDetail: '' //必須チェックエラー
    }
    const errorResponse = {
      errorCode: 'E0001',
      errorMessage: '問い合わせ詳細を入力してください。'
    }

    //リクエスト
    return request(app.getHttpServer())
    .post('/inquiries')
    .send(createInquiryReqBodyDto)
    .expect(400)
    .expect(errorResponse);
  });
});
```
### テスト実行コマンド
```sh
npx nx test <nx app name>
```
# おわりに
手探りで考えている最中の備忘です。