NestJSを触りながら学ぶ(TodoAPI作成)
NestJSとは
Nest (NestJS) is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with and fully supports TypeScript (yet still enables developers to code in pure JavaScript) and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Reactive Programming).
Under the hood, Nest makes use of robust HTTP Server frameworks like Express (the default) and optionally can be configured to use Fastify as well!
Nest provides a level of abstraction above these common Node.js frameworks (Express/Fastify), but also exposes their APIs directly to the developer. This gives developers the freedom to use the myriad of third-party modules which are available for the underlying platform.
Node.jsのバックエンドフレームワークです。
デフォルトでExpress(Node.jsのWebアプリケーションフレームワーク)が内部で使用されていますので、Expressの技術を流用できます。
設定変更でFastify(軽量なNode.jsフレームワーク)を使用することも可能です。
またTypescriptで構成されているので、フロントエンドの方でもすんなり入っていけると思われます。
NestJSのアーキテクチャ
NestJSは下図のような構成例で開発することができます。
構成例
- Module
- 依存関係の管理を行う。
- ルートモジュール(上図でいうAppModule)が開始点となる。
- Controller
- クライアントからのリクエストを処理し、応答を返す。
- 上図の構成だと実処理はServiceが担っていることが多く、リソースを取得するエンドポイントだけ定義している。
- Service
- NestJSにおけるプロバイダーの一種。依存性の注入に利用される。
- 上図の構成だとControllerに機能提供している。
環境
- Node.js v12.19.1
- PostgreSQL 12.5
- macOS Catalina
インストール
以下のコマンドでNest CLIをインストールし、プロジェクトを作成します。
$ npm i -g @nestjs/cli
$ nest new TodoApp // nest new [プロジェクト名]
⚡ We will scaffold your app in a few seconds..
CREATE todo-app/.eslintrc.js (630 bytes)
CREATE todo-app/.prettierrc (51 bytes)
CREATE todo-app/README.md (3339 bytes)
CREATE todo-app/nest-cli.json (64 bytes)
CREATE todo-app/package.json (1962 bytes)
CREATE todo-app/tsconfig.build.json (97 bytes)
CREATE todo-app/tsconfig.json (339 bytes)
CREATE todo-app/src/app.controller.spec.ts (617 bytes)
CREATE todo-app/src/app.controller.ts (274 bytes)
CREATE todo-app/src/app.module.ts (249 bytes)
CREATE todo-app/src/app.service.ts (142 bytes)
CREATE todo-app/src/main.ts (208 bytes)
CREATE todo-app/test/app.e2e-spec.ts (630 bytes)
CREATE todo-app/test/jest-e2e.json (183 bytes)
? Which package manager would you ❤️ to use? (Use arrow keys)
❯ npm
yarn
///// npm か yarnか選びます(今回はyarnを選んでる) ////
▹▸▹▹▹ Installation in progress... ☕
🚀 Successfully created project todo-app
👉 Get started with the following commands:
$ cd todo-app
$ yarn run start
Thanks for installing Nest 🙏
Please consider donating to our open collective
to help us maintain this package.
🍷 Donate: https://opencollective.com/nest
出来上がる構成は下記のようになります。
todo-app
├── .eslintrc.js // ESLintの設定ファイル
├── .git // gitリポジトリのスケルトンディレクトリ
├── .gitignore // git管理除外設定ファイル
├── .prettierrc // Prettierの設定ファイル
├── README.md // NestJSのREADME
├── dist // ビルド結果出力先
├── nest-cli.json // プロジェクトを整理、構築、デプロイするために必要なメタデータが書いてあるファイル(Monorepo)
├── node_modules // プロジェクトで利用する npm ライブラリの配置先
├── package.json // npm の設定ファイル
├── src // 実装していくコードの配置先
├── test // E2Eテストコード配置先
├── tsconfig.build.json // tsconfig.jsonの分割ファイル(デフォルトはトランスパイ対象外ディレクトリが書いてある)
├── tsconfig.json // TypeScript を JavaScript にトランスパイルための設定情報ファイル
└── yarn.lock // yarnの構成管理ファイル(npmだとpackage-lock.jsonが生成されてます)
動作確認だけします。
$ cd todo-app
$ yarn run start
yarn run v1.22.5
$ nest start
[Nest] 132 - 11/20/2020, 1:52:37 PM [NestFactory] Starting Nest application...
[Nest] 132 - 11/20/2020, 1:52:37 PM [InstanceLoader] AppModule dependencies initialized +56ms
[Nest] 132 - 11/20/2020, 1:52:37 PM [RoutesResolver] AppController {}: +39ms
[Nest] 132 - 11/20/2020, 1:52:37 PM [RouterExplorer] Mapped {, GET} route +19ms
[Nest] 132 - 11/20/2020, 1:52:38 PM [NestApplication] Nest application successfully started +7ms
Nest application successfully
が出力されたらブラウザでhttp://localhost:3000/
を開きHello World!が表示されていたらOK。
Ctrl + C
で終了できます。
準備
データベース作成
作成方法はコマンド、クライアントアプリ(pgAdminなど)の使用、どちらでも構いません。
コマンド
postgres=# create database todoapp;
CREATE DATABASE
SQLクライアントアプリ
ファイル削除
srcディレクトリの直下は以下のファイルが配置されています。
今回必要ないものは削ってしまいます。
-
app.controller.spec.ts
削除対象。AppControllerのユニットテスト用ファイル。
-
app.controller.ts
削除対象。デフォルトコントローラ。
-
app.module.ts
ルートモジュール。
-
app.service.ts
削除対象。デフォルトプロバイダ(サービス)。
-
main.ts
NestJSアプリケーションインスタンスを作成するエントリファイル。
main.ts
とapp.module.ts
だけ残ります。
削除ついでにapp.module.ts
も修正しておきます。
import { Module } from '@nestjs/common';
@Module({
imports: [],
})
export class AppModule {}
// AppController と AppService をファイル内から削除
必要パッケージインストール
npm なら npm install [パッケージ名] --save
yarn なら yarn add [パッケージ名]
で各種インストールします。
パッケージ | 用途 |
---|---|
@nestjs/typeorm | TypeORM統合(NestJS専用パッケージ) |
typeorm | TypeORM使用 |
pg | PostgreSQL使用 |
@nestjs/config | 環境変数使用 |
class-validator | バリデーション用(パイプ機能で使用) |
class-transformer | オブジェクト変換用(パイプ機能で使用) |
Module作成
構成
今回は下図のようなシンプルな構成で作っていこうと思います。
CLI(Genarate Module)
CLIを使用してModuleを作成します。
nest g module [module名]
で自動生成されます。
$ nest g module tasks
CREATE src/tasks/tasks.module.ts (82 bytes)
UPDATE src/app.module.ts (312 bytes)
中身は以下のように、ほとんど空のような状態です。
import { Module } from '@nestjs/common';
@Module({})
export class TasksModule {}
Controller作成
CLI(Genarate Controller)
こちらもCLIを使用してControllerを作成します。
nest g controller [controller名]
で生成されます。
同時にテスト用のファイルも自動生成されます。今回は使用しないので--no-spec
オプションを追加して生成しないようにします。
$ nest g controller tasks --no-spec
CREATE src/tasks/tasks.controller.ts (99 bytes)
UPDATE src/tasks/tasks.module.ts (170 bytes)
この時点でtasks.module.ts
に自動でControllerが追記されています。
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller'; // 自動追加
@Module({
controllers: [TasksController] // 自動追加
})
export class TasksModule {}
ルーティング
各リクエストのルートマッピングは以下のように構成します。
URL | HTTP | Method | Decorator | Contents |
---|---|---|---|---|
/tasks | GET | getTasks | @Get() | 登録されているタスク取得 |
/tasks/[id] | GET | getTaskById | @Get(/:id) | [id]のタスク取得 |
/tasks | POST | createTask | @Post() | 新規タスク登録 |
/tasks/[id] | DELETE | deleteTask | @Delete(/:id) | 登録されている[id]のタスク削除 |
/tasks/[id] | PATCH | updateTaskStatus | @Patch(/:id) | 登録されている[id]のタスク更新 |
HTTPプロトコルの参考リンク
コントローラ内のメソッドにデコレータを付与してあげることで、ハンドラとしてマッピングします。
Controller修正
上記の構成で作成します。
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common';
@Controller('tasks')
export class TasksController {
@Get()
getTasks() {
return "getTasks Success!"
}
@Get('/:id')
getTaskById(
@Param('id', ParseIntPipe) id: number) {
return `getTaskById Success! Parameter [id:${id}]`
}
@Post()
createTask(
@Body('title') title: string,
@Body('description') description: string) {
return `createTask Success! Prameter [title:${title}, descritpion:${description}]`
}
@Delete('/:id')
deleteTask(
@Param('id', ParseIntPipe) id: number) {
return `deleteTask Success! Prameter [id:${id}]`
}
@Patch('/:id')
updateTask(
@Param('id', ParseIntPipe) id: number,
@Body('status') status: string ) {
return `updateTask Success! Prameter [id:${id}, status:${status}]`
}
}
@Param()
や@Body()
はHTTPリクエストを取得するためのデコレータです。
基本はExpressのリクエストオブジェクトのプロパティに沿っています。(Express Application Request)
ParseIntPipe
はパイプという機能の1つです。
コントローラのメソッドに値が引き渡される前に変換、もしくは検証を行います。
@Param('id', ParseIntPipe)
ではidを数値型へと変換します。(変換できなかった場合は例外をスローします)
動作確認
早速確認しましょう。
yarn run start:dev
でファイル更新があるたびに自動的に再起動してくれるので、開発中はそちらでNestJSを起動するのがオススメです。
APIの確認は素直にツールを使用した方が手間がないです。
参考:
もちろんcurl
でも可能。
$ curl -X GET http://localhost:3000/tasks
getTasks Success!
$ curl -X GET http://localhost:3000/tasks/1
getTaskById Success! Parameter [id:1]
$ curl -X POST http://localhost:3000/tasks -d 'title=TEST' -d 'description=NestJS'
createTask Success! Prameter [title:TEST, descritpion:NestJS]
$ curl -X PATCH http://localhost:3000/tasks/1 -d 'status=DONE'
updateTask Success! Prameter [id:1, status:DONE]
$ curl -X DELETE http://localhost:3000/tasks/1
deleteTask Success! Prameter [id:1]
// パイプで変換できない場合、いい感じに返してくれる(内容変更可能)
$ curl -X GET http://localhost:3000/tasks/aaa
{"statusCode":400,"message":"Validation failed (numeric string is expected)","error":"Bad Request"}
リクエストオブジェクトから値が取得できていることが分かると思います。
DTO作成
値を渡す際のバリデーションを有効するためにDTOを定義していきます。
import { IsNotEmpty } from "class-validator";
export class TaskPropertyDto {
@IsNotEmpty()
title: string;
@IsNotEmpty()
description: string;
}
デコレータの@IsNotEmpty
は値に"",null,undefined
を受け入れません。
Pipe作成
statusに対するパイプ機能を実装します。
import { BadRequestException, PipeTransform } from "@nestjs/common";
export class TaskStatusPipe implements PipeTransform {
readonly allowStatus = [
'OPEN',
'PROGRESS',
'DONE'
];
transform(value: any) {
value = value.toUpperCase();
if(!this.isStatusValid(value)) {
throw new BadRequestException();
}
return value;
}
private isStatusValid(status: any) {
const result = this.allowStatus.indexOf(status);
return result !== -1;
}
}
PipeTransform
を継承することによって検証機能をもつパイプを自ら作成することができます。
上記はstatus
が'OPEN','PROGRESS','DONE'
のいづれかでないとエラーを返しています。
Controller修正(DTO、Pipe追加)
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, UsePipes, ValidationPipe } from '@nestjs/common';
import { TaskPropertyDto } from './dto/task-property.dto';
import { TaskStatusPipe } from './pipe/task-status.pipe';
@Controller('tasks')
export class TasksController {
@Get()
getTasks() {
return "getTasks Success!"
}
@Get('/:id')
getTaskById(
@Param('id', ParseIntPipe) id: number) {
return `getTaskById Success! Parameter [id:${id}]`
}
@Post()
@UsePipes(ValidationPipe)
createTask(
@Body() taskPropertyDto: TaskPropertyDto) {
const { title, description } = taskPropertyDto
return `createTask Success! Prameter [title:${title}, descritpion:${description}]`
}
@Delete('/:id')
deleteTask(
@Param('id', ParseIntPipe) id: number) {
return `deleteTask Success! Prameter [id:${id}]`
}
@Patch('/:id')
updateTask(
@Param('id', ParseIntPipe) id: number,
@Body('status',TaskStatusPipe) status: string ) {
return `updateTask Success! Prameter [id:${id}, status:${status}]`
}
}
@UsePipes
を付与したメソッドはバリデーションパイプ(今回でいうとTaskPropertyDtoで定義した値の空検証)が有効になります。
updateTask
で作成したパイプを定義していますが、この場合ですとstatusの値にだけおよびます。
Service作成
CLI(Genarate Service)
CLIでServiceを作成します。
nest g service [service名]
で生成されます。
今回はテストファイルも作ってみましょう。
$ nest g service tasks
CREATE src/tasks/tasks.service.spec.ts (453 bytes)
CREATE src/tasks/tasks.service.ts (89 bytes)
UPDATE src/tasks/tasks.module.ts (247 bytes)
tasks.module.ts
に自動で追記されてます。
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service'; // 自動追加
@Module({
controllers: [TasksController],
providers: [TasksService] // 自動追加
})
export class TasksModule {}
TypeORMの準備
Service実装の前に、DBとのやりとりのためにTypeORMの設定を行っておきます。
接続設定
DBとの接続を設定します。
app.module.ts
に下記を追加します。
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; // 追加
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [
TasksModule,
// importsに追加
TypeOrmModule.forRoot({
type: 'postgres', // DBの種類
port: 5432, // 使用ポート
database: 'todoapp', // データベース名
host: 'localhost', // DBホスト名
username: 'root', // DBユーザ名
password: 'root', // DBパスワード
synchronize: true, // モデル同期(trueで同期)
entities: [__dirname + '/**/*.entity.{js,ts}'], // ロードするエンティティ
})
],
})
export class AppModule {}
この状態でyarn run startをしてみましょう。
[Nest] 118 - 11/29/2020, 6:04:30 AM [NestFactory] Starting Nest application...
[Nest] 118 - 11/29/2020, 6:04:31 AM [InstanceLoader] TypeOrmModule dependencies initialized +933ms
[Nest] 118 - 11/29/2020, 6:04:31 AM [InstanceLoader] AppModule dependencies initialized +4ms
[Nest] 118 - 11/29/2020, 6:04:31 AM [InstanceLoader] TasksModule dependencies initialized +3ms
[Nest] 118 - 11/29/2020, 6:04:32 AM [InstanceLoader] TypeOrmCoreModule dependencies initialized +1270ms
[Nest] 118 - 11/29/2020, 6:04:32 AM [RoutesResolver] TasksController {/tasks}: +3ms
[Nest] 118 - 11/29/2020, 6:04:32 AM [RouterExplorer] Mapped {/tasks, GET} route +3ms
[Nest] 118 - 11/29/2020, 6:04:32 AM [RouterExplorer] Mapped {/tasks/:id, GET} route +4ms
[Nest] 118 - 11/29/2020, 6:04:32 AM [RouterExplorer] Mapped {/tasks, POST} route +5ms
[Nest] 118 - 11/29/2020, 6:04:32 AM [RouterExplorer] Mapped {/tasks/:id, DELETE} route +4ms
[Nest] 118 - 11/29/2020, 6:04:32 AM [RouterExplorer] Mapped {/tasks/:id, PATCH} route +2ms
[Nest] 118 - 11/29/2020, 6:04:32 AM [NestApplication] Nest application successfully started +9ms
エラーが出なければ接続成功です。
Entitiyの作成
以下のようなテーブル内容で作成しようと思います。
Task |
---|
id: number |
title: string |
description: string |
status: string |
新規にtask.entity.ts
を作成します。
TypeORMについての説明は省略させて頂きます。
参考: TypeORM(github)
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class Task extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@Column()
description: string;
@Column()
status: string;
}
yarn run start
でNestJSを起動してDBの中を見てみます。
(下記はコマンドで確認してますが、確認できればなんでもよいです。)
todoapp=# \d
List of relations
Schema | Name | Type | Owner
--------+-------------+----------+-------
public | task | table | root
public | task_id_seq | sequence | root
(2 rows)
todoapp=# \d task
Table "public.task"
Column | Type | Collation | Nullable | Default
-------------+-------------------+-----------+----------+----------------------------------
id | integer | | not null | nextval('task_id_seq'::regclass)
title | character varying | | not null |
description | character varying | | not null |
status | character varying | | not null |
Indexes:
"PK_fb213f79ee45060ba925ecd576e" PRIMARY KEY, btree (id)
ちゃんと同期がとれてますね。
環境毎での設定変更
envファイル
開発時点ではこれでいいのですが、本番環境では同期するとエライ目にあうので、環境変数を読み込むように変更します。
プロジェクトのルートディレクトリ直下に.env
ディレクトリを新規に作成し、各環境毎の構成ファイルを格納します。
API_PORT=3000
DB_PORT=5432
DB_NAME=todoapp
DB_HOSTNAME=localhost
DB_USERNAME=root
DB_PASSWORD=root
DB_SYNC=true
DB_SYNC=false
環境毎にdevlopment.env
とproduction.env
を切り替えて使います(default.env
は他envファイルで上書き可能な共通定義を書いてます)
TypeORMのConfigService
この定義を読み込むTypeORMのConfigServiceを作成します。
src
ディレクトリにconfig
ディレクトリを作成し格納します。
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from "@nestjs/typeorm";
@Injectable()
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
createTypeOrmOptions(): TypeOrmModuleOptions {
const configService = new ConfigService();
return {
type: 'postgres',
host: configService.get<string>('DB_HOSTNAME'),
port: configService.get<number>('DB_PORT'),
username: configService.get<string>('DB_USERNAME'),
password: configService.get<string>('DB_PASSWORD'),
database: configService.get<string>('DB_NAME'),
entities: [__dirname + '/../**/*.entity.{js,ts}'],
synchronize: this.strToBoolean(configService.get<string>('DB_SYNC', 'false'))
};
}
// get<boolean>が上手く変換してくれないため泣く泣く対応
private strToBoolean(boolStr:string):boolean {
switch(boolStr.toLowerCase().trim())
{
case "true":
case "yes":
case "1":
return true;
case "false":
case "no":
case "0":
case null:
return false;
default:
return boolStr as unknown as boolean
}
}
}
ConfigService
のgetメソッドで環境変数を取得できます。
さきほどapp.module.ts
に直接記述していたオプション達をここにまとめておきます。
AppModule修正
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeOrmConfigService } from './config/typeorm-config.service';
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [
/** env読み込み
* 環境変数NODE_ENVの値によって読み込むファイルを切り替える。
* default.envは後続で呼ばれる。同じ変数がある場合は先に定義されているものが優先される。
*/
ConfigModule.forRoot({
envFilePath: [`.env/${process.env.NODE_ENV}.env`,'.env/default.env'],
isGlobal: true,
}),
// TypeORMの設定を非同期取得に変更
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useClass: TypeOrmConfigService,
}),
TasksModule,
],
})
export class AppModule {}
これで環境ごとに接続情報を書き換える手間を省けます。
動作確認
分かりやすくコンソールに表示されるようmain.ts
を書き換えましょう(ついでlistenポートもenvを参照するようにしちゃいましょう)
import { ConfigService } from '@nestjs/config'; // 追加
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = new ConfigService(); // 追加
const sync = configService.get('DB_SYNC'); // 追加
console.log(`TypeORM synchronize is [ ${sync} ]`); // 追加
const port = configService.get('API_PORT'); // 追加
await app.listen(port); // 3000をportに変更
}
bootstrap();
NODE_ENV
を設定して起動してみます。
// dev
$ NODE_ENV=development yarn run start
yarn run v1.22.5
$ nest start
[Nest] 195 - 11/29/2020, 3:06:23 PM [NestFactory] Starting Nest application...
~~~ 略 ~~~~
[Nest] 195 - 11/29/2020, 3:06:24 PM [InstanceLoader] TasksModule dependencies initialized +1ms
[Nest] 195 - 11/29/2020, 3:06:25 PM [InstanceLoader] TypeOrmCoreModule dependencies initialized +560ms
TypeORM synchronize is [ true ]
~~~ 略 ~~~~
// prod
$ NODE_ENV=production yarn run start
yarn run v1.22.5
$ nest start
[Nest] 195 - 11/29/2020, 3:06:23 PM [NestFactory] Starting Nest application...
~~~ 略 ~~~~
TypeORM synchronize is [ false ]
~~~ 略 ~~~~
// ランタイム環境に既に環境変数が存在する場合、そちらが優先される
$ NODE_ENV=development DB_SYNC=false yarn run start
yarn run v1.22.5
$ nest start
[Nest] 195 - 11/29/2020, 3:06:23 PM [NestFactory] Starting Nest application...
~~~ 略 ~~~~
TypeORM synchronize is [ false ]
~~~ 略 ~~~~
(ここまで書いてなんだが、node-config使った方が綺麗にまとまりそうですね・・・)
Serviceの修正
import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TaskPropertyDto } from './dto/task-property.dto';
import { Task } from './task.entity';
@Injectable()
export class TasksService {
constructor(
@InjectRepository(Task)
private taskRepository: Repository<Task>,
) { }
async getTasks(): Promise<Task[]> {
return this.taskRepository.find();
}
async getTaskById(id: number): Promise<Task> {
const found = await this.taskRepository.findOne(id);
if (!found) {
throw new NotFoundException();
}
return found;
}
async createTask(
taskPropertyDto: TaskPropertyDto
): Promise<Task> {
const { title, description } = taskPropertyDto;
const task = new Task();
task.title = title;
task.description = description;
task.status = 'OPEN';
try {
await this.taskRepository.save(task);
} catch (error) {
throw new InternalServerErrorException();
}
return task;
}
async deleteTask(id: number): Promise<void> {
const result = await this.taskRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException();
}
}
async updateTask(
id: number,
status: string): Promise<Task> {
const task = await this.getTaskById(id);
task.status = status;
await this.taskRepository.save(task);
return task;
}
}
Controllerから受け取った値で実処理するよう構成しています。
あとはControllerのファイルを修正すれば完成なのですが、Service作成時にテストファイルを作成したので、テストを行っていきましょう。
テスト作成
テストはJestがデフォルトになっています。
NestJSはテストツールに制限があるわけでないので、お好みでよいようです。
今回はデフォルトのJestでいきます。
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Task } from './task.entity';
import { TasksService } from './tasks.service';
const mockRepository = () => ({
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
});
describe('TasksService', () => {
let tasksService: TasksService;
let taskRepository;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TasksService,
{ provide: getRepositoryToken(Task), useFactory: mockRepository }
]
}).compile();
tasksService = await module.get<TasksService>(TasksService);
taskRepository = await module.get(getRepositoryToken(Task));
});
describe('getTasks', () => {
it('get all tasks', async () => {
taskRepository.find.mockResolvedValue('mockTask');
expect(taskRepository.find).not.toHaveBeenCalled();
const result = await tasksService.getTasks()
expect(taskRepository.find).toHaveBeenCalled();
expect(result).toEqual('mockTask');
});
});
describe('getTaskById', () => {
it('find success', async () => {
const mockTask = { title: 'mockTitle', description: 'mockDesc' };
taskRepository.findOne.mockResolvedValue(mockTask);
expect(taskRepository.findOne).not.toHaveBeenCalled();
const mockId: number = 1;
const result = await tasksService.getTaskById(mockId);
expect(taskRepository.findOne).toHaveBeenCalled();
expect(result).toEqual(mockTask);
});
it('task is not found', async () => {
const mockId: number = 1;
taskRepository.findOne.mockResolvedValue(null);
expect(tasksService.getTaskById(mockId)).rejects.toThrow(NotFoundException);
});
});
describe('createTask', () => {
it('insert task', async () => {
const mockTask = { title: 'mockTitle', description: 'mockDesc' };
taskRepository.save.mockResolvedValue(mockTask);
expect(taskRepository.save).not.toHaveBeenCalled();
const result = await tasksService.createTask(mockTask);
expect(taskRepository.save).toHaveBeenCalled();
expect(result).toEqual({
title: mockTask.title,
description: mockTask.description,
status: 'OPEN'
});
});
});
describe('deleteTask', () => {
it('delete task', async () => {
taskRepository.delete.mockResolvedValue({ affected: 1 });
expect(taskRepository.delete).not.toHaveBeenCalled();
const mockId: number = 1;
await tasksService.deleteTask(mockId);
expect(taskRepository.delete).toHaveBeenCalledWith(mockId);
});
it('delete error', async () => {
taskRepository.delete.mockResolvedValue({ affected: 0 });
const mockId: number = 1;
expect(tasksService.deleteTask(mockId)).rejects.toThrow(NotFoundException);
});
});
describe('updateTask', () => {
it('update status', async () => {
const mockStatus = 'DONE';
tasksService.getTaskById = jest.fn().mockResolvedValue({
status: 'OPEN'
});
expect(tasksService.getTaskById).not.toHaveBeenCalled();
const mockId: number = 1;
const result = await tasksService.updateTask(mockId, mockStatus);
expect(tasksService.getTaskById).toHaveBeenCalled();
expect(taskRepository.save).toHaveBeenCalled();
expect(result.status).toEqual(mockStatus);
});
});
});
beforeEach
で呼ばれているTest.createTestingModule
はモックやオーバーライドなど、クラスインスタンスの管理を容易にしてくれるフックの機能があります。
今回はDBのアクセスはモックにするので、Repository
クラスのfind
やsave
はオーバライドしています。
テスト実行
yarn run test
で実行しましょう。(yarn run test:watch
でファイルが更新する度テストをしてくれることも出来ます)
Watch Usage: Press w to show more.
PASS src/tasks/tasks.service.spec.ts (21.055 s)
TasksService
getTasks
✓ get all tasks (9 ms)
getTaskById
✓ find success (5 ms)
✓ task is not found (19 ms)
createTask
✓ insert task (6 ms)
deleteTask
✓ delete task (5 ms)
✓ delete error (5 ms)
updateTask
✓ update status (5 ms)
Test Suites: 1 passed, 1 total
Tests: 7 passed, 7 total
Snapshots: 0 total
Time: 21.135 s, estimated 22 s
なんだが良く分からないけどエラーがないのでヨシ!(テストムズカシイ・・・)
Module & Controller修正
ControllerはServiceに値を渡すよう修正しましょう。
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { TaskPropertyDto } from './dto/task-property.dto';
import { TaskStatusPipe } from './pipe/task-status.pipe';
import { Task } from './task.entity';
import { TasksService } from './tasks.service';
@Controller('tasks')
export class TasksController {
constructor(
private tasksService: TasksService
) {}
@Get()
getTasks(): Promise<Task[]> {
return this.tasksService.getTasks();
}
@Get('/:id')
getTaskById(
@Param('id', ParseIntPipe) id: number): Promise<Task> {
return this.tasksService.getTaskById(id);
}
@Post()
@UsePipes(ValidationPipe)
createTask(
@Body() taskPropertyDto: TaskPropertyDto
): Promise<Task> {
return this.tasksService.createTask(taskPropertyDto);
}
@Delete('/:id')
deleteTask(
@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.tasksService.deleteTask(id);
}
@Patch('/:id')
updateTask(
@Param('id', ParseIntPipe) id: number,
@Body('status',TaskStatusPipe) status: string ): Promise<Task> {
return this.tasksService.updateTask(id, status);
}
}
Moduleはリポジトリをimportしておきます。
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; // 追加
import { Task } from './task.entity'; // 追加
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
imports: [
TypeOrmModule.forFeature([Task]) // 追加
],
controllers: [TasksController],
providers: [TasksService]
})
export class TasksModule {}
確認
yarn run start
で起動して確認します。
// get (登録なし)
$ curl -X GET http://localhost:3000/tasks
[]
// create (1)
$ curl -X POST http://localhost:3000/tasks -d 'title=Test' -d 'description=testDesc'
{"title":"Test","description":"testDesc","status":"OPEN","id":1}
// get (登録あり)
$ curl -X GET http://localhost:3000/tasks
[{"id":1,"title":"Test","description":"testDesc","status":"OPEN"}]
// create (2)
$ curl -X POST http://localhost:3000/tasks -d 'title=Nest' -d 'description=nestjs'
{"title":"Nest","description":"nestjs","status":"OPEN","id":2}
// get (id指定)
$ curl -X GET http://localhost:3000/tasks/2
{"id":2,"title":"Nest","description":"nestjs","status":"OPEN"}
// get (複数登録あり)
$ curl -X GET http://localhost:3000/tasks
[{"id":1,"title":"Test","description":"testDesc","status":"OPEN"},{"id":2,"title":"Nest","description":"nestjs","status":"OPEN"}]
// update
$ curl -X PATCH http://localhost:3000/tasks/2 -d 'status=DONE'
{"id":2,"title":"Nest","description":"nestjs","status":"DONE"}
// get (update確認)
$ curl -X GET http://localhost:3000/tasks/2
{"id":2,"title":"Nest","description":"nestjs","status":"DONE"}
// delete
$ curl -X DELETE http://localhost:3000/tasks/2
// get (delete確認)
$ curl -X GET http://localhost:3000/tasks
[{"id":1,"title":"Test","description":"testDesc","status":"OPEN"}]
// 不正な値の確認
$ curl -X POST http://localhost:3000/tasks -d 'title=Nest' -d 'description='
{"statusCode":400,"message":["description should not be empty"],"error":"Bad Request"}
$ curl -X PATCH http://localhost:3000/tasks/2 -d 'status=aaa'
{"statusCode":400,"message":"Bad Request"}
まとめ
NestJSを使って必要最低限なREST方式で実装してみました。
ここに紹介しているだけじゃなくNestJSには、もっと便利な機能がありますし拡張性が高いです。
(書いている途中で記事じゃなく本の方が見やすかったと後悔・・・)
唯一日本語ドキュメントが少ないという意見を良く見かけますが、今では日本語の記事も多くなってきて調べやすい環境になっているのかなと思います。
Discussion
参考になる記事ありがとうございました!(全体像をとてもわかり易く理解することができました!)
全体像が分かった状態で,さらに公式ドキュメントで勉強しようと思います><
以下,気づいた点です><
@Post
が@Podt
になっているようですtask.entity.ts
だと思うのですが,ソースコードの名前がtasks/tasks.entity.ts
で (s)がついていましたcreateTask
の引数の渡し方が違いそうです.ご指摘ありがとうございます!
記事は修正しました!