11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【ハンズオン】NestJSで構築するCI環境 NestJS meetup #2

Last updated at Posted at 2022-05-20

概要

こちらの記事はイベント登壇時のデモプロジェクトをハンズオンで構築できるように整理したものになります

プロジェクトの作成

任意の作業ディレクトリでプロジェクトをセットアップします

パッケージ管理システムはnpmを利用します

npm i -g @nestjs/cli

nest new meetup_demo

⚡  We will scaffold your app in a few seconds..
? Which package manager would you ❤️  to use? npm
✔ Installation in progress... ☕

🚀  Successfully created project meetup_demo
👉  Get started with the following commands:

$ cd meetup_demo
$ npm run start

プロジェクトを起動します

プロジェクトの起動に成功したら任意のレポジトリを作成してコミットを行なってください

cd meetup_demo

# 差分を自動検知する watchモードで起動します
npm run start:dev

[3:44:27 AM] Starting compilation in watch mode...

[3:44:31 AM] Found 0 errors. Watching for file changes.

[Nest] 6003  - 04/20/2022, 3:44:32 AM     LOG [NestFactory] Starting Nest application...
[Nest] 6003  - 04/20/2022, 3:44:32 AM     LOG [InstanceLoader] AppModule dependencies initialized +51ms
[Nest] 6003  - 04/20/2022, 3:44:32 AM     LOG [RoutesResolver] AppController {/}: +67ms
[Nest] 6003  - 04/20/2022, 3:44:32 AM     LOG [RouterExplorer] Mapped {/, GET} route +5ms
[Nest] 6003  - 04/20/2022, 3:44:32 AM     LOG [NestApplication] Nest application successfully started +4ms

Dockerファイルを作成します

Dockerfile
# ベースイメージ
FROM node:14.16.1-alpine as build-develop

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

# プロジェクト配下を/work配下に配置
COPY . /work/

# プログレスバーを非表示にして高速化
RUN npm install --no-progress

# eslintを実行
RUN npm run lint

# 型定義ファイルを作成
RUN npm run build

FROM node:14.16.1-alpine as build-stage

# localeを日本語に
ENV LANG C.UTF-8

ENV TZ Asia/Tokyo

WORKDIR /app

COPY ./package.json ./package-lock.json ./

# --cache /tmp/empty-cache 一時的なキャッシュを利用
RUN npm install --production --no-progress --cache /tmp/empty-cache && rm -rf /tmp/empty-cache
COPY --from=build-stage /work/dist ./dist

# https://github.com/krallin/tini
# PID 1対策
RUN apk add --no-cache tini

ENTRYPOINT ["/sbin/tini", "--"]

USER node

# ホストマシン上にポートを露出(expose)しますが、公開はしません
EXPOSE 3000

ENV NODE_ENV prod

CMD ["node", "dist/src/main"]
docker-compose.yml
services:
  db:
    # 使用イメージ
    image: mysql:8.0
    # Dockerの公式MySQLの文字コードをutf8mb4にする
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    container_name: meetup_db_container
    # ホスト(mysql-data-volume)をコンテナ(/var/lib/mysql)にマウント
    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:

コンテナを起動します

MySQLコンテナが起動できていることを確認したら一旦コミットしましょう

 docker-compose up -d                                                                                                ✔  04:40:34  
Creating meetup_db_container ... done

docker-compose ps                                                                                                   ✔  04:40:37  
       Name                      Command               State                 Ports              
------------------------------------------------------------------------------------------------
meetup_db_container   docker-entrypoint.sh mysql ...   Up      0.0.0.0:3306->3306/tcp, 33060/tcp

環境変数を定義します

依存関係をインストールします

npm i --save @nestjs/config

mkdir src/config && vi src/config/configuration.ts

環境変数を定義する設定ファイルを作成します

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',
  },
});

TyprORMの構成ファイルを定義していきます

依存関係をインストールします

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

TypeORMの設定ファイルに接続内容を定義します

※ この手順を飛ばすと接続オプションが見つからないためエラーとなります

No connection options were found in any orm configuration files

設定ファイルを定義します

vi ormconfig.ts 

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',

  // Entityの自動同期無効
  synchronize: false,

  // クエリおよびエラーのロギングを有効にする
  logging: true,
  entities: ['src/**/**/*.entity{.ts,.js}'],
  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',
  },
};

ルートモジュールで構成ファイルを読み込みます

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

@Module({
  imports: [
    ConfigModule.forRoot({
      // 他のモジュールでConfigModuleをインポートせずに利用できるように
      isGlobal: true,

      // 環境変数を定義したファイルをインポート
      load: [configuration],
    }),

    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}')],
        synchronize: false,
        logging: configService.get('nodeEnv') === 'development',
        extra: {},
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

TypeORMの設定が完了したのでプロジェクトを起動します

TypeOrmModuleの初期化が成功していることを確認できればOKです。
コミットしておきましょう

docker-compose up -d  
npm run start:dev

[Nest] 6243  - 04/22/2022, 4:14:07 AM     LOG [NestFactory] Starting Nest application...
[Nest] 6243  - 04/22/2022, 4:14:07 AM     LOG [InstanceLoader] TypeOrmModule dependencies initialized +38ms

CRUD機能を実装します

CRUD generatorで雛形を作ります

 nest g resource cats                                                                                            ✔  19s   00:11:53  
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? Yes
CREATE src/cats/cats.controller.spec.ts (556 bytes)
CREATE src/cats/cats.controller.ts (873 bytes)
CREATE src/cats/cats.module.ts (240 bytes)
CREATE src/cats/cats.service.spec.ts (446 bytes)
CREATE src/cats/cats.service.ts (595 bytes)
CREATE src/cats/dto/create-cat.dto.ts (29 bytes)
CREATE src/cats/dto/update-cat.dto.ts (165 bytes)
CREATE src/cats/entities/cat.entity.ts (20 bytes)
UPDATE package.json (2148 bytes)
UPDATE src/app.module.ts (1404 bytes)

Entityを定義します

  • Entityとは
    データベースのテーブルに対応するクラスのことです

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

@Entity('cats')
export class Cat {
  @PrimaryGeneratedColumn({
    name: 'id',
    unsigned: true,
    type: 'smallint',
    comment: 'ID',
  })
  readonly id: number;

  @Column('varchar', { comment: '猫の名前' })
  name: string;

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

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

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

マイグレーションファイルを自動で生成します

# npx package.jsonにインストールせず使うのでnpxを利用しています 
# ts-node tsファイルを直接実行するので、ts-node を利用します
npx ts-node ./node_modules/.bin/typeorm migration:generate -n cats

# 自動で作成されれば成功です
Migration /Users/habanaoki/Desktop/Practice/meetup_demo/src/databases/migrations/1650814095673-cats.ts has been generated successfully.

マイグレーションファイルを実行します

# 現在の型情報をdist配下にビルドします
npm run build

# マイグレーションを実行します
npx ts-node ./node_modules/.bin/typeorm migration:run

# 成功すればOKです
Migration cats1650814095673 has been executed successfully.
query: COMMIT

テーブルが作成されていることをMySQLコンテナ内で確認します

# DBコンテナに入ります
docker-compose exec db bash                                                                                      ✔  00:38:50 
# ログインします 
root@e6d879736822:/# mysql -u root -p
# password を入力します
Enter password:

# meetup を利用します
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| meetup             |
| mysql              |
| performance_schema |
| sys                |
+--------------------+

mysql> use meetup;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed

# 先ほど定義したEntityのデータ型通りに作成されていることが確認できたらOKです
mysql> DESC cats;
+--------+-------------------+------+-----+----------------------+--------------------------------------------------+
| Field  | Type              | Null | Key | Default              | Extra                                            |
+--------+-------------------+------+-----+----------------------+--------------------------------------------------+
| id     | smallint unsigned | NO   | PRI | NULL                 | auto_increment                                   |
| name   | varchar(255)      | NO   |     | NULL                 |                                                  |
| ins_ts | datetime(6)       | NO   |     | CURRENT_TIMESTAMP(6) | DEFAULT_GENERATED                                |
| upd_ts | datetime(6)       | NO   |     | CURRENT_TIMESTAMP(6) | DEFAULT_GENERATED on update CURRENT_TIMESTAMP(6) |
+--------+-------------------+------+-----+----------------------+--------------------------------------------------+

ここまで確認できましたらコミットしておきましょう

モジュールを実装します

src/cats/cats.module.ts
import { Module } from '@nestjs/common';
import { CatsService } from './cats.service';
import { CatsController } from './cats.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Cat } from './entities/cat.entity';

@Module({
  // 特定のEntityのみimportします
  imports: [TypeOrmModule.forFeature([Cat])],
  // モジュール外でも利用できるようにします
  exports: [TypeOrmModule],
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

バリデーションクラスを実装します

依存関係をインストールします

npm install class-validator --save
src/cats/dto/create-cat.dto.ts
import { IsNotEmpty, MaxLength } from 'class-validator';

export class CreateCatDto {
  @IsNotEmpty({ message: '名前は必須項目です' })
  @MaxLength(255, { message: '名前は255文字以内で入力してください' })
  name: string;
}
src/cats/dto/update-cat.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateCatDto } from './create-cat.dto';
import { IsNotEmpty, MaxLength } from 'class-validator';

export class UpdateCatDto extends PartialType(CreateCatDto) {
  @IsNotEmpty({ message: '名前は必須項目です' })
  @MaxLength(255, { message: '名前は255文字以内で入力してください' })
  name: string;
}

バリデーションを適用する処理をmain.tsに追加します

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

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // クロスオリジンリソース共有を有効にする
  app.enableCors();

  // バリデーションを自動で適用
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap().catch((err) => {
  process.exit(1);
});

コントローラークラスを実装します

想定する戻り値の型と非同期で実行する処理を定義します

現時点ではサービスクラスの戻り値と一致しないのでエラーになります

src/cats/cats.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
} from '@nestjs/common';
import { CatsService } from './cats.service';
import { CreateCatDto } from './dto/create-cat.dto';
import { UpdateCatDto } from './dto/update-cat.dto';
import { Cat } from './entities/cat.entity';
import { DeleteResult, UpdateResult } from 'typeorm';

@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Post()
  async create(@Body() createCatDto: CreateCatDto): Promise<Cat> {
    return await this.catsService.create(createCatDto);
  }

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

  @Get(':id')
  async findOne(@Param('id') id: string): Promise<Cat> {
    return await this.catsService.findOne(+id);
  }

  @Patch(':id')
  async update(
    @Param('id') id: string,
    @Body() updateCatDto: UpdateCatDto,
  ): Promise<UpdateResult> {
    return await this.catsService.update(+id, updateCatDto);
  }

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

サービスクラスを実装します

import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { CreateCatDto } from './dto/create-cat.dto';
import { UpdateCatDto } from './dto/update-cat.dto';
import { Cat } from './entities/cat.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { DeleteResult, Repository, UpdateResult } from 'typeorm';

@Injectable()
export class CatsService {
  constructor(@InjectRepository(Cat) private catRepository: Repository<Cat>) {}

  /**
   * @summary 登録機能
   * @param createCatDto
   */
  async create(createCatDto: CreateCatDto): Promise<Cat> {
    return await this.catRepository
      .save({ name: createCatDto.name })
      .catch((e) => {
        throw new InternalServerErrorException(e.message);
      });
  }

  /**
   * @summary 全件取得
   */
  async findAll(): Promise<Cat[]> {
    return await this.catRepository.find().catch((e) => {
      throw new InternalServerErrorException(e.message);
    });
  }

  /**
   * @summary 該当ID取得
   * @param id
   */
  async findOne(id: number): Promise<Cat> {
    const cat = await this.catRepository.findOne(id);

    if (!cat) {
      throw new NotFoundException(
        `${id}に一致するデータが見つかりませんでした。`,
      );
    }
    return cat;
  }

  /**
   * @summary 該当ID更新
   * @param id
   */
  async update(id: number, updateCatDto: UpdateCatDto): Promise<UpdateResult> {
    {
      return await this.catRepository
        .update(id, { name: updateCatDto.name })
        .catch((e) => {
          throw new InternalServerErrorException(e.message);
        });
    }
  }

  /**
   * @summary 削除
   * @param id
   */
  async remove(id: number): Promise<DeleteResult> {
    return await this.catRepository.delete(id).catch((e) => {
      throw new InternalServerErrorException(e.message);
    });
  }
}

APIエンドポイントにリクエストを投げてみましょう

# create
curl -X POST --location "http://localhost:3000/cats" \
    -H "Content-Type: application/json" \
    -d "{
          \"name\": \"Hoge\"
        }"

# findAll
curl -X GET --location "http://localhost:3000/cats" \
    -H "Accept: application/json"

# findOne
curl -X GET --location "http://localhost:3000/cats/1" \
    -H "Accept: application/json"


# update
curl -X PATCH --location "http://localhost:3000/cats/1" \
    -H "Content-Type: application/json" \
    -d "{
          \"name\": \"Hogehoge\"
        }"

# delete
curl -X DELETE --location "http://localhost:3000/cats/1" \
    -H "Content-Type: application/json"

E2Eテストを実装していきます

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

テストで利用するモジュールをインストールします

  • typeorm-seeding モジュール内にデータベースをリフレッシュするメソッドが入っているので利用します
  • randomstring:ランダムな文字列を生成するために利用します

npm i --save-dev @nestjs/testing randomstring typeorm-seeding

テストの設定を行います

test/cats.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { tearDownDatabase, useRefreshDatabase } from 'typeorm-seeding';
import { CatsModule } from '../src/cats/cats.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Cat } from '../src/cats/entities/cat.entity';
import { AppModule } from '../src/app.module';
import { CatsController } from '../src/cats/cats.controller';

describe('CatsController (e2e)', () => {
  let app: INestApplication;

  // 毎回実行される処理
  beforeEach(async () => {
    // DBをリフレッシュ
    await useRefreshDatabase();
  });

  // テスト前に1回だけ実行する処理
  beforeAll(async () => {
    // テストモジュールにテスト対象モジュールに必要な対象をDIする
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [TypeOrmModule.forFeature([Cat]), CatsModule, AppModule],
      controllers: [CatsController],
    }).compile();

    app = moduleFixture.createNestApplication();

    // バリデーションを有効にする
    app.useGlobalPipes(new ValidationPipe());
    await app.init();

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

      // DB接続終了
      await tearDownDatabase();
    });
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });
});

テストを実装していきます

test/cats.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { tearDownDatabase, useRefreshDatabase } from 'typeorm-seeding';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Cat } from '../src/cats/entities/cat.entity';
import { AppModule } from '../src/app.module';
import { CatsController } from '../src/cats/cats.controller';
import { CreateCatDto } from '../src/cats/dto/create-cat.dto';
import { CatsService } from '../src/cats/cats.service';
import * as randomstring from 'randomstring';

describe('CatsController (e2e)', () => {
  let app: INestApplication;

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

  // テスト前に1回だけ実行します
  beforeAll(async () => {
    // モジュールでDIしている対象を定義します
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [TypeOrmModule.forFeature([Cat]), AppModule],
      controllers: [CatsController],
      providers: [CatsService],
    }).compile();

    // モジュールからインスタンスを作成します
    app = moduleFixture.createNestApplication();

    // Auto-validation
    app.useGlobalPipes(new ValidationPipe());

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

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

    // DBとの接続を終了する
    await tearDownDatabase();
  });

  async function saveCat(body: CreateCatDto): Promise<request.Response> {
    return request(app.getHttpServer())
      .post('/cats')
      .set('Accept', 'application/json')
      .send(body);
  }

  async function getById(id: number): Promise<request.Response> {
    return request(app.getHttpServer()).get(`/cats/${id}`);
  }

  it('/ create(OK)(POST)', async () => {
    const dto: CreateCatDto = { name: 'テスト' };
    const res = await saveCat(dto);
    expect(res.body.name).toEqual(dto.name);
    expect(res.status).toEqual(201);
  });

  it('/ create(NG)-名前未入力(POST)', async () => {
    const dto: CreateCatDto = { name: null };
    const res = await saveCat(dto);
    expect(res.status).toEqual(400);
    expect(res.body.message).toEqual([
      '名前は255文字以内で入力してください',
      '名前は必須項目です',
    ]);
  });

  it('/ create(NG)-名前255文字以上(POST)', async () => {
    const dto: CreateCatDto = { name: randomstring.generate({ length: 256 }) };
    const res = await saveCat(dto);
    expect(res.status).toEqual(400);
    expect(res.body.message).toEqual(['名前は255文字以内で入力してください']);
  });

  it('/ findAll(OK)(GET)', async () => {
    const dto: CreateCatDto = { name: randomstring.generate({ length: 10 }) };
    await saveCat(dto);
    const res = await request(app.getHttpServer()).get('/cats');
    expect(res.status).toEqual(200);
    const { name } = res.body[0];
    expect(name).toEqual(dto.name);
  });

  it('/ findOne(OK)(GET)', async () => {
    const dto: CreateCatDto = { name: randomstring.generate({ length: 10 }) };
    const cat = await saveCat(dto);
    const res = await getById(cat.body.id);
    expect(res.status).toEqual(200);
    expect(res.body.name).toEqual(dto.name);
  });

  it('/ findOne(NG)-ID該当なし(GET)', async () => {
    const res = await getById(9999);
    expect(res.status).toEqual(404);
  });

  it('/ update(OK)(PATCH)', async () => {
    const body: CreateCatDto = {
      name: randomstring.generate({ length: 10 }),
    };

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

    await request(app.getHttpServer())
      .patch(`/cats/${cat.body.id}`)
      .set('Accept', 'application/json')
      .send({ name: '更新' });

    const data = await request(app.getHttpServer()).get(`/cats/${cat.body.id}`);
    expect(data.status).toEqual(200);
    expect(data.body.name).toEqual('更新');
  });

  it('/ remove(OK)(DELETE)', async () => {
    const body: CreateCatDto = {
      name: randomstring.generate({ length: 10 }),
    };

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

    const res = await request(app.getHttpServer()).delete(
      `/cats/${cat.body.id}`,
    );

    expect(res.status).toEqual(200);
    const data = await getById(cat.body.id);
    expect(data.status).toEqual(404);
  });
});

テストを実行してみます

テストが全て通ればOKです

PASS  test/cats.e2e-spec.ts (11.379 s)
PASS  test/app.e2e-spec.ts

Test Suites: 2 passed, 2 total
Tests:       9 passed, 9 total
Snapshots:   0 total
Time:        12.42 s

テスト用DBコンテナを構築します

ここまででテストの自動化は完了しましたが、毎回手動でテストを実行するのは微妙なのでGitHubActionsで自動的に実行されるようにしていきます
そのため、GitHubActions経由で行うCI用のDBコンテナを構築していきます

DockerfileTest
FROM node:14.16.1-alpine as build-test-db

# workディレクトリ内にマウントします
WORKDIR /work

COPY . /work/

RUN npm install

CMD ["npm","run","test:e2e"]
e2e-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

テストを実行するスクリプトを編集します

並列でテストが実行されることで、マイグレーションやデータベースのリフレッシュ処理とテストのタイミングがバッティングしてしまいテストが失敗することがあります。そのため、以下のオプションを追加しておきます

  • --runInBand:現在のプロセスで全てのテストを1つずつ実行
  • --forceExit :全テストが終了した後にJestを強制的に終了

package.json
"test:e2e": "jest --runInBand --forceExit --config ./test/jest-e2e.json"

GitHubのワークフローを定義します

.github/workflows/run_test.yml
# ワークフローをトリガーするGitHubイベントの名前
on:
  # mainブランチにPRすると実行する
  pull_request:
    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 ./e2e-test.yml build
          docker-compose -f ./e2e-test.yml up --abort-on-container-exit
        working-directory: ./

mainブランチにプッシュしてみましょう

自動でテストが実行されれば成功です

スクリーンショット 2022-05-02 3.57.39.png

まとめ

ここまででテストを書いてGitHubActionsでテストを実行するCI環境を構築できました

いかがでしょうか? テストコードを書くことで自身のコードが最低限動作していることを担保できます。
またリファクタリングのハードルも下げることができるので是非導入してみてはいかがでしょうか??

11
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?