🗂️
【NestJS + Jest】NestJSでCI環境構築ハンズオン
概要
勉強会用のNestJSハンズオン資料を作る過程で色々整理してみた
CIって何?
CI 継続的インテグレーション
開発者が自分のコード変更を定期的にセントラルリポジトリにマージし、その後に自動化されたビルドとテストを実行する DevOps ソフトウェア開発の手法
前提条件
- Mac基準で構成していますのでWindows環境だと動作しない可能性もありますのでご了承ください
- エディタはファイル編集時にお好みのもので開いて作業ください
構築環境、使用技術
Node.js v14.15.4
- サーバサイドのJavaScript実行環境やクライアントサイドJavaScriptの開発環境として利用される
TypeORM ^8.0.3
- TypeScript用のORマッパー
NestJS
- 効率的でスケーラブルなNode.jsサーバーサイドアプリケーションを構築するためのフレームワーク
MySQL 8.0
- オープンソースのリレーショナルデータベース管理システム
Jest
- JavaScriptのテストフレームワーク
事前準備
- Dockerのインストール確認
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
リモートリポジトリを作成
必要に応じて先程作成したリモートリポジトリへプッシュしてください
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!がレスポンスされる仕組みを解説
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 以上をインストールしておいてください
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
に必要な設定を行う
必要な依存関係をインストール
npm install --save @nestjs/typeorm typeorm@0.2 mysql2
npm i --save @nestjs/config
構成ファイルを定義する
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',
},
});
TypeOrmModule
をimport
する
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(削除)機能をまとめた表現
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
クラスを定義する
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
の設定ファイルを定義する
- 設定ファイルを定義しないと以下のエラーが発生します
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
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
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": "example@co.jp", "password": "Password1234"}'
curl -X POST -H "Content-Type:application/json" localhost:3000/users -d '{"name": "ほげ2", "email": "example2@co.jp", "password": "Password1234"}'
# 更新
curl -X PATHC -H "Content-Type:application/json" localhost:3000/users/2 -d '{"name": "ふが", "email": "example2@co.jp", "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テスト
の実装
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
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: 'hogeTest@example.com',
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: 'hogeTest@example.com',
password: 'Password1234',
};
const res = await request(app.getHttpServer())
.post('/users')
.set('Accept', 'application/json')
.send(body);
const DuplicateBody: CreateUserDto = {
name: 'Test',
email: 'hogeTest@example.com',
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: 'hogeTest@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 nameNull(POST)', async () => {
const body: CreateUserDto = {
name: null,
email: 'hogeTest@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 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: 'hogeTest@example.com',
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: 'hogeTest@example.com',
password: 'pass',
};
const res = await request(app.getHttpServer())
.post('/users')
.set('Accept', 'application/json')
.send(body);
expect(res.status).toEqual(400);
});
});
});
E2Eテストを実行するスクリプトコマンドを更新
# 現在のプロセスで全てのテストを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
にワークフローを定義する
テスト用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テストを行うワークフローが実行されていることが確認できます
Discussion