NestJS + Jest + GitHub ActionsでCI環境を構築する実践ガイド
NestJSプロジェクトにJestを使ったE2Eテストを導入し、GitHub Actionsで自動化する手順を解説します。TypeORMとMySQLを使ったCRUDアプリケーションの実装も含む実践的なハンズオン記事です。
#githubactions #ci #jest #nestjs #e2e
はじめに
NestJSプロジェクトにJestを使ったE2Eテストを導入し、GitHub Actionsで自動化するまでの手順を解説します。
CIとは
CI(継続的インテグレーション)は、開発者がコード変更を定期的にリポジトリにマージし、自動化されたビルドとテストを実行するDevOps開発手法です。
ハンズオンのゴール
- NestJSプロジェクトの構築
- Docker + MySQLでの開発環境構築
- TypeORMを使ったCRUD機能の実装
- E2Eテストの実装
- GitHub ActionsでのCI自動化
前提条件
- macOS環境(Windows環境では一部動作が異なる可能性あり)
- Docker Desktop v3.4.0以上がインストール済み
- Node.js v14以上がインストール済み
技術スタック
- Node.js: サーバーサイドJavaScript実行環境
- NestJS: スケーラブルなNode.jsフレームワーク
- TypeORM: TypeScript用ORマッパー
- MySQL 8.0: リレーショナルデータベース
- Jest: JavaScriptテストフレームワーク
- Docker: コンテナ仮想化プラットフォーム
プロジェクトセットアップ
NestJS CLIのインストールとプロジェクト作成
# グローバルにCLIをインストール
npm install -g @nestjs/cli
# プロジェクトを作成(npmを選択)
nest new meetup-dev
# プロジェクトディレクトリに移動
cd meetup-dev
# 依存関係をインストール
npm install
# 開発サーバーを起動
npm run start:dev
動作確認:
curl http://localhost:3000
# "Hello World!" が返ればOK
NestJSの基本構造
NestJSは以下のコンポーネントで構成されます:
main.ts - エントリーポイント
// src/main.ts
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
await app.listen(3000)
}
bootstrap()
Module - 依存関係の管理
// src/modules/app.module.ts
import { Module } from '@nestjs/common'
import { AppController } from '../controllers/app.controller'
import { AppService } from '../services/app.service'
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Controller - ルーティング
// 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()
}
}
Service - ビジネスロジック
// src/services/app.service.ts
import { Injectable } from '@nestjs/common'
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!'
}
}
MySQL環境の構築
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: 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
# 起動確認
docker compose ps
# コンテナに入って接続確認
docker compose exec db bash
mysql -u root -p
# password: password
TypeORMの設定
必要なパッケージのインストール
npm install --save @nestjs/typeorm typeorm@0.2 mysql2
npm install --save @nestjs/config
設定ファイルの作成
// src/config/configuration.ts
export default () => ({
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',
},
})
AppModuleへの統合
// src/modules/app.module.ts
import { Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { TypeOrmModule } from '@nestjs/typeorm'
import configuration from '../config/configuration'
import { join } from 'path'
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get('database.host'),
port: 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}')],
synchronize: false,
logging: configService.get('nodeEnv') === 'development',
}),
inject: [ConfigService],
}),
],
// ...
})
export class AppModule {}
CRUDアプリケーションの実装
リソースの生成
nest generate resource users
# REST APIを選択
# CRUD entry pointsを有効化
Entity定義
// src/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設定ファイル
// 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',
synchronize: false,
logging: true,
entities: ['src/entities/*.ts'],
migrations: ['src/databases/migrations/*.ts'],
cli: {
migrationsDir: 'src/databases/migrations',
entitiesDir: 'src/entities',
},
}
マイグレーションの実行
# ビルド
npm run build
# マイグレーションファイルを生成
npx ts-node ./node_modules/.bin/typeorm migration:generate --name user
# マイグレーションを実行
npx ts-node ./node_modules/.bin/typeorm migration:run
DTO定義
npm install --save class-validator class-transformer
// src/dto/create-user.dto.ts
import { IsEmail, IsNotEmpty, Matches, MaxLength } from 'class-validator'
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
}
Service実装
npm install bcrypt
npm install -D @types/bcrypt
// src/services/users.service.ts
import { Injectable, BadRequestException } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { User } from '../entities/user.entity'
import { CreateUserDto } from '../dto/create-user.dto'
import * as bcrypt from 'bcrypt'
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<{ message: string }> {
const existingUser = await this.usersRepository.findOne({
where: { email: createUserDto.email },
})
if (existingUser) {
throw new BadRequestException('既に登録済みのメールアドレスです')
}
await this.usersRepository.save({
name: createUserDto.name,
email: createUserDto.email,
password: await bcrypt.hash(createUserDto.password, 10),
})
return { message: 'ユーザーの登録に成功しました' }
}
async findAll(): Promise<User[]> {
return this.usersRepository.find()
}
async findOne(id: number): Promise<User> {
return this.usersRepository.findOneOrFail(id)
}
// update, removeメソッドも同様に実装
}
E2Eテストの実装
テスト用TypeORM設定
// 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',
synchronize: true, // テスト環境のみ有効化
logging: true,
dropSchema: true,
entities: ['src/entities/*.ts'],
migrations: ['src/databases/migrations/*.ts'],
}
E2Eテストコード
npm install randomstring 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 * as request from 'supertest'
import { useRefreshDatabase } from 'typeorm-seeding'
import { User } from '../src/entities/user.entity'
import { AppModule } from '../src/modules/app.module'
import { CreateUserDto } from '../src/dto/create-user.dto'
describe('UserController (E2E)', () => {
let app: INestApplication
beforeEach(async () => {
await useRefreshDatabase()
})
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [TypeOrmModule.forFeature([User]), AppModule],
}).compile()
app = moduleFixture.createNestApplication()
app.useGlobalPipes(new ValidationPipe())
await app.init()
})
afterAll(async () => {
await app.close()
})
describe('ユーザー登録テスト', () => {
it('正常にユーザーを登録できる', async () => {
const body: CreateUserDto = {
name: 'Test User',
email: 'test@example.com',
password: 'Password1234',
}
const res = await request(app.getHttpServer())
.post('/users')
.set('Accept', 'application/json')
.send(body)
expect(res.status).toEqual(201)
expect(res.body.message).toEqual('ユーザーの登録に成功しました')
})
it('重複メールアドレスでエラーになる', async () => {
const body: CreateUserDto = {
name: 'Test User',
email: 'test@example.com',
password: 'Password1234',
}
await request(app.getHttpServer()).post('/users').send(body)
const res = await request(app.getHttpServer()).post('/users').send(body)
expect(res.status).toEqual(400)
})
})
})
テストスクリプトの設定
// package.json
{
"scripts": {
"test:e2e": "jest --runInBand --forceExit --detectOpenHandles --config ./test/jest-e2e.json"
}
}
テスト実行:
npm run test:e2e
GitHub Actionsの設定
テスト用Dockerfile
# DockerfileTest
FROM node:14.16.1-alpine as build-stage
WORKDIR /work
COPY . /work/
RUN npm install
CMD ["npm","run","test:e2e"]
テスト用docker-compose
# unit-test.yml
version: '3'
services:
app:
build:
context: "."
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
GitHub Actionsワークフロー
# .github/workflows/run_test.yml
name: Run E2E Tests
on:
push:
branches:
- main
jobs:
run-test:
name: Run E2E Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Run tests with docker-compose
run: |
docker-compose -f ./unit-test.yml build
docker-compose -f ./unit-test.yml up --abort-on-container-exit
working-directory: ./
まとめ
NestJS + Jest + GitHub ActionsでCI環境を構築する手順を解説しました。
構築したもの:
- NestJSを使ったCRUDアプリケーション
- TypeORMとMySQLを使ったデータベース連携
- Jestを使ったE2Eテスト
- GitHub Actionsによる自動テスト実行
重要なポイント:
- TypeORMの
synchronizeはテスト環境のみで有効化 - E2Eテストでは
beforeEachでデータベースをリセット - docker-composeで本番環境に近い構成でテスト
- GitHub Actionsでプッシュごとに自動テスト実行
このCI環境により、コード変更による不具合を早期に検出できます。