NestJSをゼロから学ぶ - TypeORMの活用などをREST APIの実装から身に付けよう【Node.jsフレームワークの基本】

Nest.jsは、スケーラブルで効率的なサーバーサイドのNode.jsフレームワークで、TypeScriptで構築されています。この記事はNestJSのハンズオンとして、TypeScriptやNode.jsの経験があるソフトウェアエンジニアが手を動かしながらNestJSに入門できます。NestJS Japan Users Groupの羽馬直樹さんによる執筆です。

NestJSをゼロから学ぶ - TypeORMの活用などをREST APIの実装から身に付けよう【Node.jsフレームワークの基本】

はじめまして。羽馬@NaokiHabaと申します。株式会社エブリーDELISH KITCHEN 開発本部でバックエンドエンジニアをしています。プライベートでは、NestJS Japan Users Groupの運営を通じてNestJSの普及に貢献しています。

当記事ではハンズオンを通して、初めてNestJSを触る方がNestJSの基本的な機能を学べるように、REST APIを作成しながら解説していきます。読み終えた方は、次のようなことができるようになっていることを想定しています。

  • NestJSの基本的な機能を使うことができる
  • NestJSでREST APIを作成することができる
  • NestJS + JestでREST APIのテストを書くことができる

対象読者には、次のような方を想定しています。

  • Node.jsを使った開発経験はある
  • TypeScriptを使った開発経験はある
  • NestJSを使った開発経験はない

これはあくまで想定読者であり、経験のない方でもNestJSの基本的な機能を学べるよう解説していきます。

この記事のハンズオンで必要なソフトウェア

この記事でハンズオンを実施する前に、次の準備をしておいてください。

Node.jsは、バージョン16.14.0で動作確認しています。次のコマンドでバージョンを確認できます。

$ node -v
v16.14.0

Dockerは、バージョン20.10.17で動作確認しています。次のコマンドで確認できます。

$ docker -v
Docker version 20.10.17, build f0df350

Docker Composeは、バージョン2.6.1で動作確認しています。次のコマンドで確認できます。

$ docker-compose -v
docker-compose version 2.6.1

そのほか次の環境で動作確認を行っています。

  • NestJS 9.1.5
  • MySQL 8.0
  • macOS Ventura 13.1

また、解説するソースコードは次のGitHubリポジトリで公開しています。

NaokiHaba/nestjs-example

それでは、NestJSの基本的な機能を学んでいきましょう。

NestJSはどういうフレームワークか?

NestJSとは、Node.jsのフレームワークのひとつで、TypeScriptで構築されています。コマンドラインのインターフェースであるNestJS CLIを使うと、プロジェクトの作成からテストまで簡単に行うことができます。

NestJS - A progressive Node.js framework(公式サイト)

そのほか次のような特徴があります。

  • GraphQLやマイクロサービスなど、さまざまな機能をサポート
  • FastifyExpressなどのWebフレームワークをサポート
  • AngularやReactなど、さまざまなフロントエンドフレームワークと連携が可能
  • テストツールに依存せず、JestSuperTestとの統合が容易
  • 公式のドキュメントが充実している

このようにNestJSは多機能で、また次のようにコミュニティも活発なフレームワークです。

なお、Node.jsのWebフレームワーク中で最も人気が高いのがExpressで、FastifyはそのExpressより高速なフレームワークです。

NestJSのファイル構成

NestJSのファイル構成と、利用されるコアファイルを説明します。

まず、ファイル構成は次のようになっています。

src/
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├──  main.ts

このうちapp.controller.tsは、次のようなファイルです。

  • アプリケーションのルートに対するリクエストを処理するコントローラ
  • @Controller()デコレータを使って、ルートを定義する

app.module.tsは次のようなファイルです。

  • アプリケーションのルートモジュール
  • @Module()デコレータを使って、モジュールを定義する

app.service.tsは次のようなファイルです。

  • アプリケーションのルートに対するリクエストを処理するサービス
  • @Injectable()デコレータを使って、サービスを定義する

main.tsは、アプリケーションのエントリポイントです。

  • アプリケーションのルートモジュールapp.module.tsをインポートする
  • @NestFactory.create()メソッドを使って、アプリケーションを起動する

デコレータ:NestJSの便利な機能

NestJSには便利な機能が数多くありますが、その中からデコレータ、依存性注入、CLIの3つを紹介します。

デコレータとは、クラスやメソッドに対して、メタデータを付与する仕組みです。NestJSでは、デコレータを使ってコントローラやモジュールを定義します。デコレータを使ってクラスやメソッドにメタデータを付与することで、クラスやメソッドに対してさまざまな機能を付与することができます。

例えば次のようなデコレータが、NestJSには用意されています。

デコレータ 役割
@Controller() コントローラを定義する
@Get() GETリクエストを処理する
@Post() POSTリクエストを処理する
@Put() PUTリクエストを処理する
@Delete() DELETEリクエストを処理する
@Patch() PATCHリクエストを処理する
@Module() モジュールを定義する
@Injectable() サービスを定義する
@Inject() 依存性を注入する
@Body() リクエストボディを取得する
@Param() リクエストパラメータを取得する
@Query() クエリパラメータを取得する
@HttpCode() HTTPステータスコードを定義する

このようにさまざまなデコレータが用意されていますが、今回はサンプルで利用するデコレータに絞って説明します。他のデコレータについては、NestJSの公式ドキュメントなどを参照してください。

依存性注入(DI)

依存性注入(DI、Dependency Injection)とは、クラスの依存関係を解決する仕組みです。NestJSでは、依存性注入を使ってクラスの依存関係を解決します。

クラスの依存関係とは、クラスが他のクラスに依存している関係のことです。例えば、次のようなクラスAがあったとします。このクラスは他のクラスに依存していません。@Injectable()デコレータを使って、サービスを定義します。

@Injectable()

class A {
  constructor() {}
}

そして、次のようなクラスBがあったとします。このクラスは、@Inject()デコレータを使って依存性を注入しており、クラスAに依存しています。

@Injectable()

class B {
  constructor(@Inject(A) private a: A) {}
}

このクラスBをインスタンス化するには、クラスAのインスタンスが必要です。インスタンス化に必要なクラスAのインスタンスを、クラスBのコンストラクタで受け取っています。このようにクラスのコンストラクタで、インスタンス化に必要な別のクラスのインスタンスを受け取ることを依存性注入と呼びます。

NestJS CLI

NestJSには、CLIツールが用意されています。CLIツールとは、コマンドラインからアプリケーションを作成したりテストを実行したりするツールのことです。

NestJS CLIを使うことで、次のようにさまざまなことが実行できます。

  • アプリケーションの作成
  • モジュールの作成
  • コントローラの作成
  • サービスの作成
  • テストの実行
  • ビルド

公式ドキュメントも参照しよう

ここまで、NestJSの基本的な機能を簡単に紹介してきました。こういった解説だけではイメージが湧きにくいかもしれませんが、これから実際にアプリケーションを作成していくと、イメージがつかめると思います。この段階では、NestJSのファイル構成をざっくりと把握しておけばよいでしょう。

次のセクションから、実際に環境構築を行って、アプリケーションを作成していきます。より詳細な説明が必要なときには、NestJS公式のドキュメントなども参照してください。

Documentation | NestJS

NestJS CLIで環境構築を行う

このセクションでは、NestJSの環境構築を行っていきます。次の2つの方法があります。

  • NestJS CLIを使う
  • 手動で環境構築する

今回はNestJS CLIを使います。まず次のコマンドを実行して、NestJS CLIそのものをインストールします。

$ npm i -g @nestjs/cli

-gオプションを付けると、グローバルにインストールします。これで、コマンドラインからNestJS CLIを実行できるようになります。

アプリケーションのプロジェクトを作成

NestJS CLIを使って、アプリケーションを作成するには、次のコマンドを実行します。<アプリケーション名>には、作成したい任意のアプリケーションの名前を指定します。

$ nest new <アプリケーション名>

今回は次のように、nestjs-exampleという名前でアプリケーションを作成します。

$ nest new nestjs-example

実行すると、次のメッセージが表示されます。

Which package manager would you ❤️  to use? npm
✔ Installation in progress... ☕

🚀  Successfully created project nestjs-example
👉  Get started with the following commands:

$ cd nestjs-example
$ npm run start

そして、次のファイル構成でアプリケーションが作成されます。

nestjs-example/
├── README.md
├── nest-cli.json
├── package-lock.json
├── package.json
├── src/
│   ├── app.controller.spec.ts
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   ├──  main.ts
│   └── test
│       └── app.e2e-spec.ts
├── tsconfig.build.json
├── tsconfig.json

作成したディレクトリに移動して、次のコマンドでアプリケーションを起動しましょう。

$ cd nestjs-example
$ npm run start:dev

起動時にnpm run start:devと実行することで、ホットリロードを有効にした状態でアプリケーションを起動できます。ホットリロードとは、ファイルを変更した際にアプリケーションを再起動する必要がなくなる機能のことです。

このコマンドを実行すると、次のメッセージが表示されます。

[Nest] 13473  - 01/15/2023, 3:09:23 AM     LOG [NestFactory] Starting Nest application...
[Nest] 13473  - 01/15/2023, 3:09:24 AM     LOG [InstanceLoader] AppModule dependencies initialized +102ms
[Nest] 13473  - 01/15/2023, 3:09:24 AM     LOG [RoutesResolver] AppController {/}: +12ms
[Nest] 13473  - 01/15/2023, 3:09:24 AM     LOG [RouterExplorer] Mapped {/, GET} route +3ms
[Nest] 13473  - 01/15/2023, 3:09:24 AM     LOG [NestApplication] Nest application successfully started +3ms

この状態でhttp://localhost:3000にアクセスすると、次のメッセージが表示されます。

Hello World!

これでNestJSの環境構築が完了しました。いかがでしょうか? かなり簡単に環境構築できましたね。

MySQLコンテナをDocker Composeで起動する

ここからはアプリケーションで利用するソフトウェアを用意していきます。まず、データベースとしてMySQLを、Docker Composeを利用してコンテナで起動します。なお、Docker Composeそのものの説明が必要な方は、公式のドキュメントなどを参照してください。

コンテナを起動するために、docker-compose.ymlを作成します。

$ touch docker-compose.yml

今回のdocker-compose.ymlには、次の内容を記述します。

version: '3'
services:
  db:
    image: mysql:8.0
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    container_name: db_container
    volumes:
      - mysql-data-volume:/var/lib/mysql
    ports:
      - "3306:3306"
    environment:
      TZ: 'Asia/Tokyo'
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: test
      MYSQL_USER: test
      MYSQL_PASSWORD: password

volumes:
  mysql-data-volume:

作成できたら、次のコマンドを実行してMySQLコンテナを起動します。-dオプションを付けて、バックグラウンドで起動します。

$ docker-compose up -d

コンテナが起動すると、次のメッセージが表示されます。

⠿ Container db_container          Started        1.1s

docker-compose psコマンドを実行すると、次のメッセージが表示されます。

NAME                COMMAND                  SERVICE             STATUS              PORTS
db_container        "docker-entrypoint.s…"   db                  running             0.0.0.0:3306->3306/tcp, 33060/tcp           0.0.0.0:3306->3306/tcp, 33060/tcp

これでMySQLコンテナが起動しました。

TypeORMのセットアップ

起動したMySQLに接続するため、ORマッパーとしてTypeORMを利用します。TypeORMそのものの説明は省略しますが、気になる方は公式サイトなどを参照してください。

TypeORM - Amazing ORM for TypeScript and JavaScript

次のコマンドを実行して、TypeORMをインストールします。TypeORMをインストールすると、typeormコマンドが使えるようになります。

$ npm install --save @nestjs/typeorm typeorm mysql2

続いてTypeORMの設定ファイルを作成します。先ほどのアプリケーションがあるnestjs-exampleディレクトリに、typeOrm.config.tsという設定ファイルを作成します。 内容は次のようになります。

import { DataSource } from 'typeorm';

export default new DataSource({
  type: 'mysql',
  host: 'localhost',
  port: 3306,
  database: 'test',
  username: 'test',
  password: 'password',
  entities: ['dist/**/entities/**/*.entity.js'],
  migrations: ['dist/**/migrations/**/*.js'],

  // ログを出力するかどうか
  logging: true,

  // synchronize は開発時にのみ使用する
  // trueにすると、エンティティの変更を検知して、自動的にテーブルが更新される
  // 本番環境では、falseにすること
});

このうちsynchronizeオプションは、開発環境でのみ使用します。本番環境ではsynchronizeオプションをfalseにしてください。公式のドキュメントにも、synchronizeオプションをtrueにすると本番環境のデータを消してしまう可能性があると書かれています。

Setting synchronize: true shouldn't be used in production - otherwise you can lose production data.

データベースとの接続で使用する環境変数

データベースに接続するために必要な情報は、環境変数でアプリケーションに渡します。NestJSで環境変数を取得するライブラリとして、@nestjs/configを使用します。

次のコマンドを実行してインストールしましょう。

$ npm install --save @nestjs/config

次に、このライブラリの設定ファイルであるconfig/configuration.tsを作成します。今回は、次のような内容になります。

export default () => ({
  database: {
    host: process.env.DATABASE_HOST || 'localhost',
    port: parseInt(process.env.DATABASE_PORT, 10) || 3306,
    username: process.env.DATABASE_USER || 'test',
    password: process.env.password || 'password',
    name: process.env.database || 'test',
  },
});

なお、実行環境がクラウドの場合には、変数はGCPのCloud Runや、AWSのECSから取得することが多いと思います。

環境構築のまとめ

以上がNestJSの環境構築の手順でした。このセクションでの参照URLをまとめておきます。

余談ですが、今回はORMとしてTypeORMを使用しましたが、最近はPrismaを利用する開発者が増えている印象です。Prismaはデータベースから自動でスキーマを生成してくれるので、開発効率が上がるというメリットがあります。個人的にはTypeORMの方が好みですが、Prismaも検討してみてください。

次のセクションでは、実際にNestJSでAPIを作成していきます。

実装するREST APIについて

いよいよNestJSでREST APIを作成していきます。今回は、データベース上のユーザー情報を操作する次のようなAPIを作成します。

メソッド URL 説明
GET /users ユーザー一覧を取得
GET /users/:id ユーザーを取得
POST /users ユーザーを作成
PATCH /users/:id ユーザーを更新
DELETE /users/:id ユーザーを削除

操作するユーザー情報は、次のようにシンプルなテーブルとします。

カラム 内容 備考
id ユーザーID 主キー
name ユーザー名

CRUD処理のひな形を作成する

NestJS CLInestコマンド)を使用することで、リソースに対するCRUD(Create Read Update Delete)処理を次のように簡単に作成できます。

$ nest g resource users

? What name would you like to use for this resource (plural, e.g., "users")? users
? What transport layer do you use? (Use arrow keys)
❯ REST API
  GraphQL (code first)
  GraphQL (schema first)
  Microservice (non-HTTP)
  WebSockets
? What transport layer do you use? REST API

? Would you like to generate CRUD entry points? Yes

CREATE src/users/users.controller.spec.ts (614 bytes)
CREATE src/users/users.controller.ts (1.02 KB)
CREATE src/users/users.module.ts (1.02 KB)
CREATE src/users/users.service.spec.ts (1.02 KB)
CREATE src/users/users.service.ts (1.02 KB)
CREATE src/users/users.entity.ts (1.02 KB)
CREATE src/users/dto/create-user.dto.ts (1.02 KB)
CREATE src/users/dto/update-user.dto.ts (1.02 KB)

ここでは、ユーザーを表すusersというリソース(resource)を作成(generate)しています。

このコマンドで、以下のようなCRUD処理のひな形となる基本的なファイルが、nestjs-example/src/users/ディレクトリに作成されました。

ファイル名 説明
users.controller.ts ルーティングを定義する
users.module.ts モジュールを定義する
users.service.ts ビジネスロジックを定義する
users.entity.ts データベースのテーブルを定義する
users.controller.speck.ts コントローラのテストを定義する
users.service.speck.ts サービスのテストを定義する
create-user.dto.ts クライアントからの作成リクエストのバリデーションを定義する
update-user.dto.ts クライアントからの更新リクエストのバリデーションを定義する

なお、CLIを使用しなくても同じようなファイルを作成することはできますが、CLIを使用すれば簡単にCRUDを作成できます。

データベースと接続してテーブルを作成

ユーザー情報を保存するMySQLは先ほどコンテナで起動し、このデータベースと接続できるようTypeORMも設定もしています。ここではアプリケーションからTypeORMを利用して、テーブルを作成します。

エンティティを定義する

まずsrc/users/users.entity.tsを編集して、データベースのテーブルを定義します。

import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn({
    comment: 'アカウントID',
  })
  readonly id: number;

  @Column('varchar', { comment: 'アカウント名' })
  name: string;

  constructor(name: string) {
    this.name = name;
  }
}

このコードでは、TypeORMから次のデコレータをインポートしています。

デコレータ 説明
@Entity データベースのテーブルを定義する
@PrimaryGeneratedColumn 主キーを定義する
@Column カラムを定義する

これで定義されるテーブルの構成は、先に説明したような2カラムのシンプルなものです。

なお、TypeORMのデコレータを詳しく知りたい方は公式ドキュメントを参照してください。

Decorator reference - typeorm/typeorm

TypeORMモジュールにエンティティを登録する

先ほど作成したエンティティをTypeORMモジュールに登録し、両者を紐付けます。

src/users/users.module.tsを編集します。

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  imports: [TypeOrmModule.forFeature([User])],
})
export class UsersModule {}

src/users/users.entity.tsからインポートしたUserを、@ModuleTypeOrmModule.forFeatureで登録しています。

TypeORMモジュールをアプリケーションに登録する

次にsrc/app.module.tsを編集して、TypeORMモジュールをアプリケーションに登録します。

import { ConfigModule, ConfigService } from '@nestjs/config';
import config from '../config/configuration';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { AppController } from './app.controller';
import { Module } from '@nestjs/common';

@Module({
  imports: [
    AppModule,
    ConfigModule.forRoot({
      isGlobal: true,
      load: [config],
    }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        type: 'mysql',
        host: configService.get('database.host'),
        port: configService.get('database.port'),
        username: configService.get('database.username'),
        password: configService.get('database.password'),
        database: configService.get('database.name'),
        entities: ['dist/**/entities/**/*.entity.js'],
      }),
      inject: [ConfigService],
    }),
    UsersModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

TypeORMでマイグレーションを実行する準備

src/users/users.module.tsで定義したエンティティがTypeORMモジュールに紐付いたので、TypeORMでマイグレーションを実施してMySQLデータベースにテーブルを作成していきます。そのために必要なマイグレーションファイルをまず作成します。

今回はマイグレーションファイルの作成と実行に、TypeORMのCLIツールを使用します。ただし、TypeORMのCLIツールはJavaScriptで書かれているため、今回のようなTypeScriptのファイルには、次のドキュメントに従ってts-nodeを使用する必要があります。

Using CLI - If entities files are in typescript | TypeORM

また、TypeORMのCLIツールをnpmスクリプトとして実行できるよう、nestjs-exampleディレクトリにあるpackage.jsonファイルに以下の通り追記してください。

{
  "scripts": {
    "typeorm": "ts-node ./node_modules/typeorm/cli",
    "typeorm:run-migrations": "npm run typeorm migration:run -- -d ./typeOrm.config.ts",
    "typeorm:generate-migration": "npm run typeorm -- -d ./typeOrm.config.ts migration:generate ./migrations/$npm_config_name",
    "typeorm:revert-migration": "npm run typeorm -- -d ./typeOrm.config.ts migration:revert"
  }
}

ここではts-nodeによるTypeORM CLInode_modules/typeorm/cliの実行のほか、それを使用したマイグレーションの実行とリバート、マイグレーションファイルの作成を定義しています。

マイグレーションファイルを作成

さっそくマイグレーションファイルを作成します。先ほど記載したtypeorm:generate-migrationをnpmスクリプトとして実行してください。

実行前にdistディレクトリを生成しておく必要があります。

再度、npm run start:devを実行してください。

$ npm run start:dev
$ npm run typeorm:generate-migration --name=CreateUsers

src/users/users.entity.tsで定義したテーブルのマイグレーションファイルが、migrationsディレクトリに作成されます。

マイグレーションを実行してテーブルを作成する

次に、作成したマイグレーションファイルをtypeorm:run-migrationsで実行します。

$ npm run typeorm:run-migrations                       
query: START TRANSACTION
query: CREATE TABLE `users` (`id` int NOT NULL AUTO_INCREMENT COMMENT 'アカウントID', `name` varchar(255) NOT NULL COMMENT 'アカウント名', PRIMARY KEY (`id`)) ENGINE=InnoDB
query: INSERT INTO `test`.`migrations`(`timestamp`, `name`) VALUES (?, ?) -- PARAMETERS: [1673728396326,"CreateUsers1673728396326"]
Migration CreateUsers1673728396326 has been  executed successfully.
query: COMMIT

これでデータベースにテーブルが作成されました。 以下のコマンドで確認できます。

$ docker-compose exec db mysql -u root -ppassword -D test -e "DESC users"
mysql: [Warning] Using a password on the command line interface can be insecure.
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int          | NO   | PRI | NULL    | auto_increment |
| name  | varchar(255) | NO   |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+

DTOの作成とバリデーション

続いてDTO(Data Transfer Object)を作成します。 DTOは、データの構造を定義するクラスです。

データ構造をバリデーションするパッケージとして、class-validatorを使用します。

$ npm i --save class-validator class-transformer

これを使用するようにsrc/users/create-user.dto.tsを編集します。

import { IsNotEmpty, MaxLength } from 'class-validator';

export class CreateUserDto {
  @MaxLength(255)
  @IsNotEmpty()
  name: string;
}

ここでは次のデータ構造を定義しています。

デコレータ 説明
@MaxLength 文字数の最大値を定義する
@IsNotEmpty 空文字を許可しない

update-user.dto.tsもクラス名を除いて同じ内容に編集してください。

import { IsNotEmpty, MaxLength } from 'class-validator';

export class UpdateUserDto {
  @MaxLength(255)
  @IsNotEmpty()
  name: string;
}

定義したDTOでバリデーションを行うパイプとしてValidationPipeを使用するように、main.tsで設定します。useGlobalPipesでグローバルに設定できます。

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
 const app = await NestFactory.create(AppModule);
 app.useGlobalPipes(new ValidationPipe());
 await app.listen(3000);
}
bootstrap();

なお、定義できるバリデーションの種類は次のドキュメントを参照してください。

typestack/class-validator: Decorator-based property validation for classes.

ユーザーを作成するAPIの実装

データベースが用意できたので、実際にユーザー情報を操作できるAPIを実装していきましょう。どのようなAPIを作成するかは、前半の「実装するREST APIについて」のセクションを参照してください。

まず始めに、ユーザーを作成するAPIから作成します。

コントローラにPostメソッドを追加

HTTPリクエストを処理するコントローラは、src/users/users.controller.tsに記述します。

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  async create(@Body() createUserDto: CreateUserDto): Promise<User> {
    return await this.usersService.create(createUserDto);
  }
}

記事冒頭の「NestJSはどういうフレームワークか?」で説明したように、ここではUsersControllerクラスに@Postデコレータを追加することで、POSTメソッドを定義しています。@Bodyで、リクエストボディを取得しています。

サービスにcreateメソッドを追加

次にsrc/users/users.service.tsを編集して、ユーザーを作成するロジックを記述していきます。

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User) private userRepository: Repository<User>,
  ) {}

  async create({ name }: CreateUserDto): Promise<User> {
    return await this.userRepository
      .save({
        name: name,
      })
      .catch((e) => {
        throw new InternalServerErrorException(
          `[${e.message}]:ユーザーの登録に失敗しました。`,
        );
      });
  }
}

ここではUsersServiceクラスに、create()メソッドを作成しています。

await this.usersRepository.save(createUserDto);で、データベースに保存しています。

ユーザーを作成するAPIを実行

いま作成したAPIを実行してみましょう。前述したようにアプリケーションをホットリロードで起動しているため再起動などは必要ありませんが、フロントエンド部分がないためコマンドラインツールでアプリケーションに接続します。

# ユーザーを作成する
$ curl -X POST -H "Content-Type: application/json" -d '{"name": "test"}' http://localhost:3000/users
{"id":1,"name":"test"} # 戻り値として定義しているUserエンティティが返却される

# nameが空文字の場合は、エラーになる
$ curl -X POST -H "Content-Type: application/json" -d '{"name": ""}' http://localhost:3000/users
{"statusCode":400,"message":"Bad Request","error":"Bad Request"}

サービスのテストを作成

実装が終わったら、ユニットテストをJestで作成していきます。

ユニットテストの役割は、実装したコードが正しく動作しているかを確認することです。ユニットテストを書くことで、コードの品質を保つことができます。積極的にユニットテストを書くようにしましょう。

それではsrc/users/users.service.spec.tsを編集します。

describe('UsersService', () => {
  let service: UsersService;
  beforeEach(async () => {
    // テスト用のモジュールを作成する
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useClass: Repository,
        },
      ],
    }).compile();

    // テスト用のモジュールから、UsersServiceを取得する
    service = module.get<UsersService>(UsersService);
  });

  // テストケース
  describe('create()', () => {
    it('should successfully insert a user', () => {
      const dto: CreateUserDto = {
        name: '太郎',
      };

      jest
        .spyOn(service, 'create')
        .mockImplementation(async (dto: CreateUserDto) => {
          const user: User = {
            id: 1,
            ...dto,
          };
          return user;
        });

      expect(service.create(dto)).resolves.toEqual({
        id: 1,
        ...dto,
      });
    });
  });
});

テストを実行してみましょう。

$ npm run test
 PASS  src/users/users.service.spec.ts (9.464 s)
 FAIL  src/users/users.controller.spec.ts (9.474 s)
  ● UsersController › should be defined

サービスのテストは成功していることが確認できました。

コントローラーのテストを作成

次にコントローラーのテストを作成するため、src/users/users.controller.spec.tsを編集します。

describe('UsersController', () => {
  let controller: UsersController;
  let service: UsersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useClass: Repository,
        },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

  describe('create()', () => {
    it('should create a user', () => {
      const dto: CreateUserDto = {
        name: '太郎',
      };

      jest
        .spyOn(service, 'create')
        .mockImplementation(async (dto: CreateUserDto) => {
          const user: User = {
            id: 1,
            ...dto,
          };
          return user;
        });

      expect(controller.create(dto)).resolves.toEqual({
        id: 1,
        ...dto,
      });
    });
  });
})

テストを実行してみましょう。

$ npm run test
 PASS  src/users/users.service.spec.ts (9.464 s)
 PASS  src/users/users.controller.spec.ts (9.474 s)

コントローラーのテストも成功していることが確認できました。

これで、ユーザーを作成するAPIの実装が終わりました。

ユーザー情報を取得するAPIの実装

次に、ユーザー情報を取得するAPIを実装していきます。全てのユーザー情報を取得するAPIと、特定のユーザー情報を取得するAPIの2種類を作成します。

コントローラーにGetメソッドを追加

コントローラーにユーザー情報を取得するAPIのリクエストを追加するため、src/users/users.controller.tsを編集します。

  @Get()
  async findAll(): Promise<User[]> {
    return await this.usersService.findAll();
  }

  @Get(':id')
  async findOne(@Param('id') id: number): Promise<User> {
    return this.usersService.findOne(+id);
  }

2つの@Getデコレータを追加しています。引数がなければユーザー一覧を返し(findAll)、引数でユーザーIDを指定すればそのユーザーの個別情報を返します(findOne)。

サービスにfindAll()メソッドとfindOne()メソッドを追加

ユーザー情報を取得するメソッドを追加するため、src/users/users.service.tsを編集します。

async findAll(): Promise<User[]> {
  return await this.userRepository.find().catch((e) => {
    throw new InternalServerErrorException(
      `[${e.message}]:ユーザーの取得に失敗しました。`,
    );
  });
}

async findOne(id: number): Promise<User> {
  return await this.userRepository
    .findOne({
      where: { id: id },
    })
    .then((res) => {
      if (!res) {
        throw new NotFoundException();
      }
      return res;
    })
}

ここでfindAllがユーザー一覧を返し、findOneが個別のユーザー情報を取得するメソッドです。

ユーザー情報を取得するAPIを実行

それではAPIを実行してみましょう。

# 全てのユーザーを取得するAPI
$ curl -X GET http://localhost:3000/users
[{"name":"hoge","id":1}] # 先ほど作成したユーザーが取得できている

# 特定のユーザーを取得するAPI
$ curl -X GET http://localhost:3000/users/1
{"name":"hoge","id":1}

# 存在しないユーザーを取得しようとするとエラーになる
$ curl -X GET http://localhost:3000/users/2
{"statusCode":404,"message":"Not Found"}

先ほど実装したユーザーを作成するAPIで作成したユーザーが取得できていることが確認できました。

これで、ユーザーの登録も取得も問題なく動作していることが確認できました。

サービスのテストを作成

最後にテストを作成していきます。 まず、src/users/users.service.spec.tsを編集します。

describe('findAll()', () => {
    it('should return users', () => {
      const user: User = {
        id: 1,
        name: '太郎',
      };

      jest.spyOn(service, 'findAll').mockImplementation(async () => {
        return [user];
      });

      expect(service.findAll()).resolves.toEqual([user]);
    });

    it('should return empty array by Not found users', () => {
      const user: User[] = [];

      jest.spyOn(service, 'findAll').mockImplementation(async () => {
        return user;
      });

      expect(service.findAll()).resolves.toEqual(user);
    });
  });

  describe('findOne()', () => {
    it('should return user', () => {
      const user: User = {
        id: 1,
        name: '太郎',
      };

      jest.spyOn(service, 'findOne').mockImplementation(async () => {
        return user;
      });

      expect(service.findOne(1)).resolves.toEqual(user);
    });

    it('should return not found exception', () => {
      jest.spyOn(service, 'findOne').mockRejectedValue({
        statusCode: 404,
        message: 'Not Found',
      });

      expect(service.findOne(2)).rejects.toEqual({
        statusCode: 404,
        message: 'Not Found',
      });
    });
  });

コントローラーのテストを作成

次に、src/users/users.controller.spec.tsを編集します。

  describe('findAll()', () => {
    it('should return users', () => {
      const user: User = {
        id: 1,
        name: '太郎',
      };

      jest.spyOn(service, 'findAll').mockImplementation(async () => {
        return [user];
      });

      expect(controller.findAll()).resolves.toEqual([user]);
    });
    it('should return empty array by Not found users', () => {
      const user: User[] = [];

      jest.spyOn(service, 'findAll').mockImplementation(async () => {
        return user;
      });

      expect(controller.findAll()).resolves.toEqual(user);
    });
  });

  describe('findOne()', () => {
    it('should return user', () => {
      const user: User = {
        id: 1,
        name: '太郎',
      };

      jest.spyOn(service, 'findOne').mockImplementation(async () => {
        return user;
      });

      expect(controller.findOne(1)).resolves.toEqual(user);
    });

    it('should return not found exception', () => {
      jest.spyOn(service, 'findOne').mockRejectedValue({
        statusCode: 404,
        message: 'Not Found',
      });

      expect(controller.findOne(2)).rejects.toEqual({
        statusCode: 404,
        message: 'Not Found',
      });
    });
  });

テストを実行

テストを実行してみましょう。

$ npm run test
 PASS  src/app.controller.spec.ts (7.641 s)
 PASS  src/users/users.controller.spec.ts (11.238 s)
 PASS  src/users/users.service.spec.ts (11.238 s)

Test Suites: 3 passed, 3 total
Tests:       11 passed, 11 total
Snapshots:   0 total
Time:        11.973 s, estimated 12 s

問題なく動作していることが確認できました。

ユーザーを更新・削除するAPIの実装

最後に、ユーザーを更新・削除するAPIを実装していきます。

コントローラーにPatch・Deleteメソッドを追加

HTTPリクエストを記述するため、src/users/users.controller.tsを編集します。

  @Patch(':id')
  async update(
    @Param('id') id: string,
    @Body() createUserDto: CreateUserDto,
  ): Promise<User> {
    return this.usersService.update(+id, createUserDto);
  }

  @Delete(':id')
  async remove(@Param('id') id: string): Promise<DeleteResult> {
    return this.usersService.remove(+id);
  }

update()メソッドは@Patchデコレータで、remove()メソッドは@Deleteデコレータです。

サービスにupdate()メソッドとremove()メソッドを追加

更新と削除のメソッドを追加するため、src/users/users.service.tsを編集します。

async update(id: number, createUserDto: UpdateUserDto): Promise<User> {
  const user = await this.userRepository.findOne({ where: { id: id } });
    if (!user) {
        throw new NotFoundException();
    }

    user.name = createUserDto.name;
    return await this.userRepository.save(user);
}

async remove(id: number): Promise<DeleteResult> {
  const user = await this.userRepository.findOne({ where: { id: id } });
  if (!user) {
    throw new NotFoundException();
  }
  return await this.userRepository.delete(user);
}

ここではupdate()メソッドとremove()メソッドを追加しています。

APIを実行

それでは、APIを実行してみましょう。

# ユーザーを更新するAPI
$ curl -X PATCH http://localhost:3000/users/1 -H "Content-Type: application/json" -d '{"name": "太郎"}'
{"id":1,"name":"太郎"}

# ユーザーを削除するAPI
$ curl -X DELETE http://localhost:3000/users/1
{"raw":[],"affected":1}

ユーザを更新して削除できることが確認できました。

サービスのテストを作成

最後にテストを定義していきます。

まず、src/users/users.service.spec.tsを編集します。

describe('update()', () => {
  it('should return update result user', () => {
    const dto: CreateUserDto = {
      name: '太郎2',
    };

    const user: User = {
      id: 1,
      name: '太郎2',
    };

    jest.spyOn(service, 'update').mockImplementation(async () => {
      return user;
    });

    expect(service.update(1, dto)).resolves.toEqual(user);
  });

  it('should return not found exception', () => {
    jest.spyOn(service, 'update').mockRejectedValue({
      statusCode: 404,
      message: 'Not Found',
    });

    const dto: CreateUserDto = {
      name: '太郎2',
    };

    expect(service.update(2, dto)).rejects.toEqual({
      statusCode: 404,
      message: 'Not Found',
    });
  });
});

describe('remove()', () => {
  it('should return remove result', () => {
    const result: DeleteResult = {
      raw: [],
      affected: 1,
    };

    jest.spyOn(service, 'remove').mockImplementation(async () => {
      return result;
    });

    expect(service.remove(1)).resolves.toEqual(result);
  });

  it('should return not found exception', () => {
    jest.spyOn(service, 'remove').mockRejectedValue({
      statusCode: 404,
      message: 'Not Found',
    });

    expect(service.remove(2)).rejects.toEqual({
      statusCode: 404,
      message: 'Not Found',
    });
  });
});

コントローラーのテストを作成

続いて、src/users/users.controller.spec.tsを編集していきます。

describe('update()', () => {
    it('should return update result user', () => {
      const dto: CreateUserDto = {
        name: '太郎2',
      };

      const user: User = {
        id: 1,
        name: '太郎2',
      };

      jest.spyOn(service, 'update').mockImplementation(async () => {
        return user;
      });

      expect(controller.update('1', dto)).resolves.toEqual(user);
    });

    it('should return not found exception', () => {
      jest.spyOn(service, 'update').mockRejectedValue({
        statusCode: 404,
        message: 'Not Found',
      });

      const dto: CreateUserDto = {
        name: '太郎2',
      };

      expect(controller.update('2', dto)).rejects.toEqual({
        statusCode: 404,
        message: 'Not Found',
      });
    });
  });

  describe('remove()', () => {
    it('should return remove result', () => {
      const result: DeleteResult = {
        raw: [],
        affected: 1,
      };

      jest.spyOn(service, 'remove').mockImplementation(async () => {
        return result;
      });

      expect(controller.remove('1')).resolves.toEqual(result);
    });

    it('should return not found exception', () => {
      jest.spyOn(service, 'remove').mockRejectedValue({
        statusCode: 404,
        message: 'Not Found',
      });

      expect(controller.remove('2')).rejects.toEqual({
        statusCode: 404,
        message: 'Not Found',
      });
    });
  });

テストの実行

テストを実行してみましょう。

$ npm run test
 PASS  src/app.controller.spec.ts (6.911 s)
 PASS  src/users/users.controller.spec.ts (9.366 s)
 PASS  src/users/users.service.spec.ts (9.383 s)

Test Suites: 3 passed, 3 total
Tests:       15 passed, 15 total
Snapshots:   0 total
Time:        10.136 s, estimated 12 s
Ran all test suites.

問題なく動作していることが確認できました。以上で、CRUDの実装は完了です。いかがでしたか? 全体的な流れのイメージが少しでもつかめたでしょうか?

今回は、CRUDの実装を行いましたが、実際の業務ではもっと複雑な処理を実装することになります。その際には、NestJSのドキュメントを参考にしてください。

また今回は割愛しましたが、Jestを使ったテスト作成で分からない部分があれば、Jestのドキュメントを参考にしてください。

はじめましょう · Jest

まとめ

以上で、NestJSの基本的な使い方を紹介しました。NestJSは、Expressをベースにしているので、Expressの知識があれば、すぐに使いこなせると思います。今回触れなかった部分も多々ありますが、今後の参考にしていただければと思います。

興味を持った方は、ぜひ、NestJSを使ってみてください。一緒にNestJSを盛り上げていきましょう! ご興味を持っていただけた方は、ぜひ、NestJS Japan Users Groupへのご参加をお待ちしております。

過去のイベントアーカイブにも、NestJSに関する動画が載っていますので、ぜひご覧ください。

羽馬 直樹(HABA Naoki)Twitter: @NaokiHaba, GitHub: NaokiHaba

NaokiHaba
2020年から都内の受託企業においてNestJSによるバックアップシステムやLaravelによる業務システム開発を経験し、2022年より株式会社エブリーのDELISH KITCHEN開発本部に所属するバックエンドエンジニア。NestJS Japan Users Groupの運営メンバーを務める。
Zenn Naoki.Haba, Qiita: NaokiHaba

編集:中薗 昴
制作:はてな編集部

若手ハイキャリアのスカウト転職