前提
- Nxプロジェクトが作成済み
- Nestアプリが作成済み
- jest-prismaはプレビュー版である$transactionをベースとしているため一旦使わない
はじめに
省略
追記 2023/02/12
以下ではbeforeEachにDBのmigrate処理を入れているため、テストケースが増えるごとにテスト総時間が肥大化してしまう。
また、テスト実行コマンドやディレクトリ構成によっては環境変数の読み込みタイミングが悪さし、上書きされずに本DBを初期化してしまう可能性があることが判明した。
そのため、jest.config.jsにDBのmigrate処理を別で切り出して、前テスト横断して必ず環境変数を上書きしつつテスト用DBを作成することをおすすめする。
参考
- prisma-fabbrica
- Integrated testing with Prisma
- Prisma で本物のDBMSを使って自動テストを書く
- Prisma の Seed ファイルを複数にする方法
Nx × NestJS × Prisma × Jest
Node Version
% node -v
v18.13.0
% npm -v
8.19.3
Nx
ライブラリを作成し、リポジトリ間で共通のリソースがあればここに格納していくと良い
% nx g @nrwl/nest:library nestjslib ←任意の名称で良いが下記のimportで指定しているためベタ打ち
依存関係
[package.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"
}
}
% npm install
Prisma
% npx prisma init
スキーマ
[/prisma/schema.prisma]DBスキーマ定義を編集
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に反映
% npx prisma migrate dev
seed
[libs/nestjslib/src/lib/prisma/enum/master.enum.ts]マスタデータの定数定義
export enum RoleType {
ADMIN = '管理者',
USER = 'ユーザ',
}
export enum InquiryStatus {
NEW = '新規',
ANSWERED = '回答済',
}
[/prisma/seed.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()
})
初期データ投入
% npx prisma db seed
NestJS
Pipe
[libs/nestjslib/src/lib/pipe/validation.pipe.ts]DTOエラーバリデーション
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をグローバル設定
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)
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)
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]コントローラクラス
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]サービスクラス
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]
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);
});
});
テスト実行コマンド
npx nx test <nx app name>
おわりに
手探りで考えている最中の備忘です。