【NestJS + Jest】NestJSでCI環境構築ハンズオン


概要

勉強会用のNestJSハンズオン資料を作る過程で色々整理してみた

CIって何?

CI 継続的インテグレーション

開発者が自分のコード変更を定期的にセントラルリポジトリにマージし、その後に自動化されたビルドとテストを実行する DevOps ソフトウェア開発の手法

https://aws.amazon.com/jp/devops/continuous-integration/#:~:text=継続的インテグレーションは、開発,ソフトウェア開発の手法です。

前提条件

  • Mac基準で構成していますのでWindows環境だと動作しない可能性もありますのでご了承ください
  • エディタはファイル編集時にお好みのもので開いて作業ください

構築環境、使用技術

Node.js v14.15.4

  • サーバサイドのJavaScript実行環境やクライアントサイドJavaScriptの開発環境として利用される

https://nodejs.org/ja/

https://qiita.com/non_cal/items/a8fee0b7ad96e67713eb

TypeORM ^8.0.3

  • TypeScript用のORマッパー

https://typeorm.io/

NestJS

  • 効率的でスケーラブルなNode.jsサーバーサイドアプリケーションを構築するためのフレームワーク

https://nestjs.com/

MySQL 8.0

  • オープンソースのリレーショナルデータベース管理システム

https://hub.docker.com/_/mysql

https://www.mysql.com/jp

Jest

  • JavaScriptのテストフレームワーク

https://jestjs.io/ja/

事前準備

  • Dockerのインストール確認

https://docs.docker.com/desktop/mac/install/

docker --version                                                                                     
Docker version 20.10.13, build a224086

今回のハンズオンのゴール

  • NestJSプロジェクトの構築
  • MySQL環境をDockerで構築
  • CRUD機能の実装
  • E2Eテストの実装
  • GitHubActionsでE2Eテストを自動化できるようにする

作業ディレクトリでNestJSプロジェクトを作成

/Desktop/Practice
npm i -g @nestjs/cli
nest new meetUpDev

# パッケージ管理はNPMで行います
? Which package manager would you ❤️  to use?

🚀  Successfully created project meet-up-dev
👉  Get started with the following commands:

$ cd meet-up-dev
$ npm run start

最終的なディレクトリ構成

├── ormconfig.test.ts
├── ormconfig.ts
├── package-lock.json
├── package.json
├── src
│   ├── config
│   │   └── configuration.ts
│   ├── controllers
│   │   ├── app.controller.ts
│   │   └── users.controller.ts
│   ├── database
│   │   └── migrations
│   ├── databases
│   │   └── migrations
│   │       └── 1648319214540-user.ts
│   ├── demo.http
│   ├── dto
│   │   └── create-user.dto.ts
│   ├── entities
│   │   └── user.entity.ts
│   ├── main.ts
│   ├── modules
│   │   ├── app.module.ts
│   │   └── users.module.ts
│   └── services
│       ├── app.service.ts
│       └── users.service.ts
├── test
│   ├── jest-e2e.json
│   └── user.e2e-spec.ts
├── tsconfig.build.json
├── tsconfig.json
└── unit-test.yml

リモートリポジトリを作成

https://github.com/new

必要に応じて先程作成したリモートリポジトリへプッシュしてください

NestJSプロジェクトを起動する

/Desktop/Practice/meet-up-dev
# パッケージを node_modules にインストール
npm i 

# コードの変更を検知する
npm run start:dev

[2:39:33 PM] Found 0 errors. Watching for file changes.

# 起動できました!
[Nest] 10374  - 03/26/2022, 2:39:34 PM     LOG [NestFactory] Starting Nest application...
[Nest] 10374  - 03/26/2022, 2:39:34 PM     LOG [InstanceLoader] AppModule dependencies initialized +35ms
[Nest] 10374  - 03/26/2022, 2:39:34 PM     LOG [RoutesResolver] AppController {/}: +11ms
[Nest] 10374  - 03/26/2022, 2:39:34 PM     LOG [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 10374  - 03/26/2022, 2:39:34 PM     LOG [NestApplication] Nest application successfully started +1ms

エンドポイントにGETリクエストを送信する

curl -X GET --location "http://localhost:3000"

# Hello World!と表示されればOKです

Hello World!がレスポンスされる仕組みを解説

https://zenn.dev/morinokami/articles/nestjs-overview

https://docs.nestjs.com/first-steps

アプリケーションのエントリファイル main.ts

コア関数NestFactoryを使用してNestアプリケーションのインスタンスを生成するアプリケーションのエントリファイルです。

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  // モジュールからアプリケーションインスタンスを生成
  const app = await NestFactory.create(AppModule);

  // http://localhost:3000 で起動
  await app.listen(3000);
}

bootstrap();

アプリケーションのルートモジュール。 app.module.ts

アプリケーションのルートモジュール。

/src/modules/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from '../controllers/app.controller';
import { AppService } from '../services/app.service';

@Module({
  // Moduleで使用するProviderをエクスポートしている他のModule
  imports: [],

  // このモジュールで定義されている、インスタンス化する必要のあるコントローラのセット
  controllers: [AppController],

  // モジュール全体で共有される可能性があるプロバイダ
  providers: [AppService],
})
export class AppModule {}

単一ルートを持つコントローラー

クライアントサイドから渡されるリクエスト情報を受け取り適切なサービスに処理を振り分け(ルーティング)します

src/controllers/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from '../services/app.service';
// オプションでルートパスのプレフィックスを定義
@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

単一のメソッドを持つ基本的なサービス

コントローラーから渡される命令を受けてビジネスロジックの処理を行います

src/services/app.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

MySQLコンテナを構築

  • Docker for Macv3.4.0 以上をインストールしておいてください

https://www.docker.com/products/docker-desktop

https://qiita.com/yuta-ushijima/items/d3d98177e1b28f736f04

meet-up-dev/docker-compose.yml
# docker-composeで使用するバージョンを定義
version: '3'

# アプリケーションを動かすための各要素
services:
  # サービス名
  db:
    # 使用するDockerImage
    image: mysql:8.
    # Dockerの公式MySQLの文字コードをutf8mb4にする
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    # コンテナの名前
    container_name: meetup_db_container
    # マウントする設定ファイルのパス
    volumes:
      - mysql-data-volume:/var/lib/mysql
    # ポート番号
    ports:
      - "3306:3306"
    environment:
      TZ: 'Asia/Tokyo'
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: meetup
      MYSQL_USER: app
      MYSQL_PASSWORD: secret

# データの永続化
volumes:
  mysql-data-volume:

コンテナを起動する

docker compose up -d --build

docker compose ps    
NAME                  COMMAND                  SERVICE             STATUS              PORTS
meetup_db_container   "docker-entrypoint.s…"   db                  running             0.0.0.0:3306->3306/tcp

# コンテナに入ってログインできるか確認しておきましょう
docker-compose exec db bash                                                               
root@6481e016e7a8:/# mysql -u root -p
# password
Enter password: 
Welcome to the MySQL monitor.  Commands end with ; or \g.

TypeORMに必要な設定を行う

https://docs.nestjs.com/techniques/database

必要な依存関係をインストール

npm install --save @nestjs/typeorm [email protected] mysql2
npm i --save @nestjs/config

構成ファイルを定義する

https://docs.nestjs.com/techniques/configuration#custom-configuration-files

src/config/configuration.ts
export default () => ({
  // Node実行時のプロセスから値を取得します
  nodeEnv: process.env.NODE_ENV || 'development',
  server: {
    port: parseInt(process.env.PORT) || 3000,
    hostName: process.env.hostname || 'localhost:3000',
  },
  database: {
    host: process.env.DB_HOST || 'localhost',
    port: parseInt(process.env.DB_PORT) || 3306,
    user: process.env.DB_USERNAME || 'root',
    pass: process.env.DB_PASSWORD || 'password',
    name: process.env.DB_NAME || 'meetup',
  },
});

TypeOrmModuleimportする

https://docs.nestjs.com/techniques/database#async-configuration

src/modules/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from '../controllers/app.controller';
import { AppService } from '../services/app.service';
import { join } from 'path';
+ import { ConfigModule, ConfigService } from '@nestjs/config';
+ import { TypeOrmModule } from '@nestjs/typeorm';
+ import configuration from '../config/configuration';

@Module({
  // Moduleで使用するProviderをエクスポートしている他のModule
  imports: [
+    ConfigModule.forRoot({
+      // 他のモジュールでも使用する
+      isGlobal: true,
+      // 設定ファイルを読み込む
+      load: [configuration],
+    }),
+    // TypeORMを構成する
+    TypeOrmModule.forRootAsync({
+      imports: [ConfigModule],
+      useFactory: (configService: ConfigService) => ({
+        type: 'mysql',
+        host: configService.get('database.host'),
+        port: Number(configService.get('database.port')),
+        username: configService.get('database.user'),
+        password: configService.get('database.pass'),
+        database: configService.get('database.name'),
+        entities: [join(__dirname, '../entities/*.entity.{ts,js}')],
+        // アプリケーション実行時にEntityをデータベースに同期しない https://stackoverflow.com/questions/65222981/typeorm-synchronize-in-production
+        synchronize: false,
+        logging: configService.get('nodeEnv') === 'development',
+        extra: {},
+      }),
+      inject: [ConfigService],
+    }),
+  ],

  // このモジュールで定義されている、インスタンス化する必要のあるコントローラのセット
  controllers: [AppController],

  // モジュール全体で共有される可能性があるプロバイダ
  providers: [AppService],
})
export class AppModule {}

簡易的なCRUD機能を実装

CRUD Create(登録)、Read(参照)、Update(更新)、Delete(削除)機能をまとめた表現

https://docs.nestjs.com/recipes/crud-generator

nest g resource

? What name would you like to use for this resource (plural, e.g., "users")? users
# 今回はREST APIを構築します
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? Yes

# 必要な雛形が生成される
ls src/users  
dto                      users.controller.spec.ts users.module.ts          users.service.ts
entities                 users.controller.ts      users.service.spec.ts

Entityクラスを定義する

https://docs.nestjs.com/techniques/database

https://zenn.dev/naonao70/articles/f0399d2baf05cc

src/users/entities/user.entity.ts
import {
  Column,
  CreateDateColumn,
  DeleteDateColumn,
  Entity,
  PrimaryGeneratedColumn,
  Timestamp,
  UpdateDateColumn,
} from 'typeorm';

@Entity('users')
export class User {
  // 主キーを定義
  @PrimaryGeneratedColumn({
    name: 'id',
    unsigned: true,
    type: 'bigint',
    comment: 'ユーザーID',
  })
  readonly id: number;

  @Column({
    type: 'varchar',
    length: 255,
    comment: 'ユーザー名',
  })
  name: string;

  @Column({
    type: 'varchar',
    length: 255,
    comment: 'メールアドレス',
    unique: true,
  })
  email: string;

  @Column({
    type: 'varchar',
    length: 255,
    comment: 'パスワード',
  })
  password: string;

  @CreateDateColumn({ comment: '登録日時' })
  readonly ins_ts?: Timestamp;

  @UpdateDateColumn({ comment: '最終更新日時' })
  readonly upd_ts?: Timestamp;

  @DeleteDateColumn({ comment: '削除日時' })
  readonly delete_ts?: Timestamp;

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

TypeORMの設定ファイルを定義する

  • 設定ファイルを定義しないと以下のエラーが発生します

https://github.com/nestjs/nest/issues/4

No connection options were found in any orm configuration files.
ormconfig.ts
module.exports = {
  type: 'mysql',
  host: process.env.DB_HOST || 'localhost',
  port: process.env.DB_PORT || '3306',
  username: process.env.DB_USERNAME || 'root',
  password: process.env.DB_PASSWORD || 'password',
  database: process.env.DB_NAME || 'meetup',
  // 手動で作成したマイグレーションですべて管理するのでFalse
  synchronize: false,
  logging: true,
  entities: ['src/entities/*.ts'],
  migrations: ['src/databases/migrations/*.ts'],
  seeds: ['src/databases/seeders/*.seed.{js,ts}'],
  factories: ['src/databases/factories/*.factory.{js,ts}'],
  cli: {
    migrationsDir: 'src/databases/migrations',
    entitiesDir: 'src/entities',
    seedersDir: 'src/databases/seeders',
    factoriesDir: 'src/databases/factories',
  },
};

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

npm run build

npx ts-node ./node_modules/.bin/typeorm migration:generate --name user

Migration /Desktop/Practice/meet-up-dev/src/databases/migrations/1648319214540-user.ts has been generated successfully.

作成されたマイグレーションファイルを実行

# コンパイル
npm run build

npx ts-node ./node_modules/.bin/typeorm migration:run

query: START TRANSACTION
query: CREATE TABLE `users` (`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ユーザーID', `name` varchar(255) NOT NULL COMMENT 'ユーザー名', `email` varchar(255) NOT NULL COMMENT 'メールアドレス', `password` varchar(255) NOT NULL COMMENT 'パスワード', `ins_ts` datetime(6) NOT NULL COMMENT '登録日時' DEFAULT CURRENT_TIMESTAMP(6), `upd_ts` datetime(6) NOT NULL COMMENT '最終更新日時' DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), `delete_ts` datetime(6) NULL COMMENT '削除日時', UNIQUE INDEX `IDX_97672ac88f789774dd47f7c8be` (`email`), PRIMARY KEY (`id`)) ENGINE=InnoDB
query: INSERT INTO `meetup`.`migrations`(`timestamp`, `name`) VALUES (?, ?) -- PARAMETERS: [1648319214540,"user1648319214540"]
Migration user1648319214540 has been executed successfully.
query: COMMIT

usersテーブルが作成されていることを確認します

show tables;
+------------------+
| Tables_in_meetup |
+------------------+
| migrations       |
| users            |
+------------------+
2 rows in set (0.00 sec)

Moduleクラスを定義する

src/modules/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from '../services/users.service';
import { UsersController } from '../controllers/users.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../entities/user.entity';

@Module({
  // 現在のスコープに登録されているリポジトリを定義する
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

Dtoクラスでバリデーションを定義

必要な依存関係をインストール

npm i --save class-validator class-transformer

https://docs.nestjs.com/techniques/validation

https://github.com/typestack/class-validator

src/dto/create-user.dto.ts
import {
  IsEmail,
  IsNotEmpty,
  IsOptional,
  Matches,
  MaxLength,
} from 'class-validator';
import { PartialType } from '@nestjs/mapped-types';

export class CreateUserDto {
  @IsNotEmpty({ message: '名前は必ず入力してください' })
  @MaxLength(255, {
    message: '名前は255文字以内で入力してください',
  })
  name: string;

  @IsNotEmpty({ message: 'Emailは必ず入力してください' })
  @MaxLength(255, {
    message: 'Emailは255文字以内で入力してください',
  })
  @IsEmail({ message: '正しいEmail形式で入力してください' })
  email: string;

  @IsNotEmpty({ message: `パスワードは必ず入力してください` })
  @Matches(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,25}$/, {
    message: `パスワードは大文字小文字を含む8文字以上25文字以内で設定してください`,
  })
  password: string;
}

export class UpdateUserDto extends PartialType(CreateUserDto) {
  // 値がNULLならバリデーションを無視する
  @IsOptional()
  @Matches(/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,25}$/, {
    message: `パスワードは大文字小文字を含む8文字以上25文字以内で設定してください`,
  })
  password: string;
}

Controllerクラスを定義する

src/controllers/users.controller.ts
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Patch,
  Post,
} from '@nestjs/common';
import { UsersService } from '../services/users.service';
import { CreateUserDto, UpdateUserDto } from '../dto/create-user.dto';

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

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }

  @Get()
  findAll() {
    return this.usersService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(+id);
  }

  @Patch(':id')
  update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.usersService.update(+id, updateUserDto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.usersService.remove(+id);
  }
}

Serviceクラスを定義する

  • パスワードをハッシュ化するために必要なパッケージをインストールします
npm i bcrypt
npm i -D @types/bcrypt

https://docs.nestjs.com/security/encryption-and-hashing

src/services/users.service.ts
import {
  BadRequestException,
  Injectable,
  InternalServerErrorException,
} from '@nestjs/common';
import { CreateUserDto, UpdateUserDto } from '../dto/create-user.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from '../entities/user.entity';
import { Not, Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';

@Injectable()
export class UsersService {
  constructor(
    // EntityをDIすることでRepositoryを経由してDBに問い合わせを行う
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

  /**
   * 登録
   * @param createUserDto
   */
  async create(createUserDto: CreateUserDto): Promise<{ message: string }> {
    if (
      (await this.usersRepository.find({ email: createUserDto.email })).length
    ) {
      throw new BadRequestException('既に登録ずみのメールアドレスです');
    }

    await this.usersRepository
      .save({
        name: createUserDto.name,
        email: createUserDto.email,
        password: await bcrypt.hash(createUserDto.password, 10),
      })
      .catch((e) => {
        throw new InternalServerErrorException(
          `[${e.message}]ユーザー登録に失敗しました。`,
        );
      });

    return { message: 'ユーザーの登録に成功しました' };
  }

  /**
   * ユーザー一覧を取得
   */
  async findAll(): Promise<User[]> {
    return this.usersRepository.find();
  }

  /**
   * @description IDに該当するユーザーを取得
   * @param id
   */
  async findOne(id: number): Promise<User> {
    return this.usersRepository.findOneOrFail(id);
  }

  /**
   * 更新
   * @param id
   * @param updateUserDto
   */
  async update(
    id: number,
    updateUserDto: UpdateUserDto,
  ): Promise<{ message: string }> {
    if (
      (
        await this.usersRepository.find({
          email: updateUserDto.email,
          id: Not(id),
        })
      ).length
    ) {
      throw new BadRequestException('既に登録ずみのメールアドレスです');
    }

    const baseUser = await this.usersRepository.findOneOrFail(id);

    await this.usersRepository
      .update(id, {
        name: updateUserDto.name,
        email: updateUserDto.email,
        password: updateUserDto.password
          ? await bcrypt.hash(updateUserDto.password, 10)
          : baseUser.password,
      })
      .catch((e) => {
        throw new InternalServerErrorException(
          `[${e.message}]ユーザーID「${id}」の更新に失敗しました。`,
        );
      });

    return { message: `ユーザーID「${id}」の更新に成功しました。` };
  }

  /**
   * 削除
   * @param id
   */
  async remove(id: number) {
    await this.usersRepository.delete(id).catch((e) => {
      throw new InternalServerErrorException(
        `[${e.message}]ユーザーID「${id}」の削除に失敗しました。`,
      );
    });
    return { message: `ユーザーID「${id}」の削除に成功しました。` };
  }
}

作成したエンドポイントにリクエストする

# 登録
curl -X POST -H "Content-Type:application/json" localhost:3000/users -d '{"name": "ほげ", "email": "[email protected]", "password": "Password1234"}'   

curl -X POST -H "Content-Type:application/json" localhost:3000/users -d '{"name": "ほげ2", "email": "[email protected]", "password": "Password1234"}'   

# 更新
curl -X PATHC -H "Content-Type:application/json" localhost:3000/users/2 -d '{"name": "ふが", "email": "[email protected]", "password": "Password1234"}' 

# 一覧
curl -X GET --location "http://localhost:3000/users"

# 詳細
curl -X GET --location "http://localhost:3000/users/2"

# 削除
curl -X DELETE --location "http://localhost:3000/users/2"

E2Eテストの実装

https://qiita.com/mt0m/items/7e18d8802843d9f60d28#E2Eテストとは

E2E(End to End)Testは、User Interface Testとも呼ばれ、システム全体を通してテストをおこないます。
Webサービスの場合は、ユーザと同じようにブラウザを操作し、挙動が期待通りになっているか確認します。

テスト用TypeORM設定を定義

マイグレーションを自動で行う機能が無いのでテスト時のみEntityをデータベースに同期します

//  アプリケーション実行時にEntityをデータベースに同期する
synchronize: true,
ormconfig.test.ts
module.exports = {
  type: 'mysql',
  host: process.env.DB_HOST || 'localhost',
  port: process.env.DB_PORT || '3306',
  username: process.env.DB_USERNAME || 'root',
  password: process.env.DB_PASSWORD || 'password',
  database: process.env.DB_NAME || 'meetup',
  //  アプリケーション実行時にEntityをデータベースに同期する
  synchronize: true,
  // 実行されるSQLをログとして吐く
  logging: true,
  migrationsRun: true,
  dropSchema: true,
  entities: ['src/entities/*.ts'],
  migrations: ['src/databases/migrations/*.ts'],
  seeds: ['src/databases/seeders/*.seed.{js,ts}'],
  factories: ['src/databases/factories/*.factory.{js,ts}'],
  cli: {
    migrationsDir: 'src/databases/migrations',
    entitiesDir: 'src/entities',
    seedersDir: 'src/databases/seeders',
    factoriesDir: 'src/databases/factories',
  },
};

テストコードを実装

今回は登録機能にフォーカスしてテストコードを書いています

バリデーションで利用するパッケージをインストールする

npm i randomstring
npm i typeorm-seeding

https://www.npmjs.com/package/randomstring

https://docs.nestjs.com/fundamentals/testing

test/user.e2e-spec.ts
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../src/entities/user.entity';
import { ConfigModule } from '@nestjs/config';
import { AppModule } from '../src/modules/app.module';
import { UsersController } from '../src/controllers/users.controller';
import { CreateUserDto } from '../src/dto/create-user.dto';
import * as request from 'supertest';
import * as randomstring from 'randomstring';
import { UsersService } from '../src/services/users.service';
import { useRefreshDatabase } from 'typeorm-seeding';

describe('UserController(E2E)', () => {
  // モジュール設定準備します
  let app: INestApplication;

  // テスト実行時に毎回実行
  beforeEach(async () => {
    // DBに接続&内部のデータをリフレッシュ
    await useRefreshDatabase();
  });

  // テスト前に1回だけ実行
  beforeAll(async () => {
    const moduleFixure: TestingModule = await Test.createTestingModule({
      imports: [
        // EntityをDIする
        TypeOrmModule.forFeature([User]),

        // テスト専用のTypeORM設定を読み込む
        ConfigModule.forRoot({ envFilePath: 'ormconfig.test.ts' }),

        AppModule,
      ],

      controllers: [UsersController],
      providers: [UsersService],
    }).compile();

    // 実行環境をインスタンス化
    app = moduleFixure.createNestApplication();

    // ValidationPipe有効化
    app.useGlobalPipes(new ValidationPipe());

    // モジュールの初期化
    await app.init();
  });

  // テスト後に1回だけ実行
  afterAll(async () => {
    // テスト終了
    await app.close();
  });

  describe('ユーザー登録テスト', () => {
    // API の実行に成功する
    it('OK /users (POST)', async () => {
      const body: CreateUserDto = {
        name: 'Test',
        email: '[email protected]',
        password: 'Password1234',
      };

      const res = await request(app.getHttpServer())
        .post('/users')
        .set('Accept', 'application/json')
        .send(body);
      expect(res.status).toEqual(201);
    });

    it('NG /users 重複判定 (POST)', async () => {
      const body: CreateUserDto = {
        name: 'Test',
        email: '[email protected]',
        password: 'Password1234',
      };

      const res = await request(app.getHttpServer())
        .post('/users')
        .set('Accept', 'application/json')
        .send(body);

      const DuplicateBody: CreateUserDto = {
        name: 'Test',
        email: '[email protected]',
        password: 'Password1234',
      };

      const ErrorRes = await request(app.getHttpServer())
        .post('/users')
        .set('Accept', 'application/json')
        .send(body);

      expect(ErrorRes.status).toEqual(400);
    });

    it('NG /users name255文字以上(POST)', async () => {
      const body: CreateUserDto = {
        name: randomstring.generate(256),
        email: '[email protected]',
        password: 'Password1234',
      };

      const res = await request(app.getHttpServer())
        .post('/users')
        .set('Accept', 'application/json')
        .send(body);
      expect(res.status).toEqual(400);
    });

    it('NG /users nameNull(POST)', async () => {
      const body: CreateUserDto = {
        name: null,
        email: '[email protected]',
        password: 'Password1234',
      };

      const res = await request(app.getHttpServer())
        .post('/users')
        .set('Accept', 'application/json')
        .send(body);
      expect(res.status).toEqual(400);
    });

    it('NG /users email256文字(POST)', async () => {
      const body: CreateUserDto = {
        name: 'test',
        email: `${randomstring.generate(256)}@example.com`,
        password: 'Password1234',
      };

      const res = await request(app.getHttpServer())
        .post('/users')
        .set('Accept', 'application/json')
        .send(body);
      expect(res.status).toEqual(400);
    });

    it('NG /users email形式エラー(POST)', async () => {
      const body: CreateUserDto = {
        name: 'test',
        email: `${randomstring.generate(10)}`,
        password: 'Password1234',
      };

      const res = await request(app.getHttpServer())
        .post('/users')
        .set('Accept', 'application/json')
        .send(body);
      expect(res.status).toEqual(400);
    });

    it('NG /users emailNull(POST)', async () => {
      const body: CreateUserDto = {
        name: 'test',
        email: null,
        password: 'Password1234',
      };

      const res = await request(app.getHttpServer())
        .post('/users')
        .set('Accept', 'application/json')
        .send(body);
      expect(res.status).toEqual(400);
    });

    it('NG /users passwordNull(POST)', async () => {
      const body: CreateUserDto = {
        name: 'test',
        email: '[email protected]',
        password: null,
      };

      const res = await request(app.getHttpServer())
        .post('/users')
        .set('Accept', 'application/json')
        .send(body);
      expect(res.status).toEqual(400);
    });

    it('NG /users password形式エラー(POST)', async () => {
      const body: CreateUserDto = {
        name: 'test',
        email: '[email protected]',
        password: 'pass',
      };

      const res = await request(app.getHttpServer())
        .post('/users')
        .set('Accept', 'application/json')
        .send(body);
      expect(res.status).toEqual(400);
    });
  });
});

E2Eテストを実行するスクリプトコマンドを更新

https://qiita.com/naoki-haba/items/4c47c1972fa2bc988182

https://jestjs.io/ja/docs/cli

# 現在のプロセスで全てのテストを1つずつ実行
--runInBand

# 全テストが終了した後にJestを強制的に終了
--forceExit 

# Jest が何も出力せずに終了するのを防ぐ
--detectOpenHandles

# テストの探索と実行の方法を指定する Jest の設定ファイルのパスを引数に指定
--config
package.json
{
  "name": "meet-up-dev",
  "version": "0.0.1",
  "description": "",
  "author": "",
  "private": true,
  "license": "UNLICENSED",
  "scripts": {
+    "test:e2e": "jest --runInBand --forceExit --detectOpenHandles --config ./test/jest-e2e.json"
  },

E2Eテストを実行する

npm run test:e2e 

Test Suites: 1 passed, 1 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        8.53 s
Ran all test suites.

GitHubActionsにワークフローを定義する

https://github.co.jp/features/actions

https://qiita.com/HeRo/items/935d5e268208d411ab5a

テスト用DockerFileを定義

DockerfileTest
# FROM ベースイメージを指定する
# as build-stage ビルド用のイメージと実行用のイメージを分ける
# -alpine 軽量イメージ
FROM node:14.16.1-alpine as build-stage

# 作業ディレクトリを作成
WORKDIR /work

COPY . /work/

RUN npm install

# コマンドを実行する
CMD ["npm","run","test:e2e"]

テスト実行用のMySQLコンテナを構築

unit-test.yml
# docker-composeで使用するバージョン
version: '3'

# アプリケーションを動かすための各要素
services:
  # コンテナ名
  app:
    # ComposeFileを実行し、ビルドされるときのpath
    build:
      # docker buildコマンドを実行した場所
      context: "."
      # Dockerfileのある場所
      dockerfile: "DockerfileTest"
    # コンテナ名
    container_name: github-actions-api-test
    # ポート番号
    ports:
      - '3000:3000'
      # 環境変数
    environment:
      PORT: 3000
      TZ: 'Asia/Tokyo'
      DB_HOST: 'testdb'
      DB_PORT: '3306'
      DB_USERNAME: 'root'
      DB_PASSWORD: 'password'
      DB_NAME: 'meetup'
      # サービス間の依存関係
    depends_on:
      - testdb

  testdb:
    image: mysql:8.0
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    container_name: db_container_e2e_test
    ports:
      - 3306:3306
    environment:
      TZ: 'Asia/Tokyo'
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: meetup
      MYSQL_USER: app
      MYSQL_PASSWORD: secret

ワークフローを定義

run_test.yml
# ワークフローをトリガーするGitHubイベントの名前
on:
  # mainブランチにプッシュすると実行する
  push:
    branches:
      - main
# ジョブ定義
jobs:
  run-test:
    name: Run Test
    # ubuntu環境で動作
    runs-on: ubuntu-latest
    # アクションを定義
    steps:
      - name: checkout pushed commit
        # ソースコードのチェックアウト
        uses: actions/checkout@v2
        with:
          # PRのHEADブランチを使う
          ref: ${{ github.event.pull_request.head.sha }}
      # E2E テストを Docker Compose で実行する
      - name: run test on docker-compose
        run: |
          docker-compose -f ./unit-test.yml build
          docker-compose -f ./unit-test.yml up --abort-on-container-exit
        working-directory: ./

mainブランチにプッシュする

定義したE2Eテストを行うワークフローが実行されていることが確認できます

スクリーンショット 2022-03-27 21.52.34.png