[NestJS+Jest] NestJS에 CI 환경 구축

개요


학습회용 NestJS Harson 자료를 만드는 과정에서 여러 가지 자료를 정리해 봤습니다.

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

  • Type Script용 OR 맵
  • https://typeorm.io/

    NestJS


    효율적이고 신축 가능한 노드.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 프로젝트의 구축
  • Docker를 통한 MySQL 환경 구축
  • CRUD 기능의 실현
  • E2E 테스트 설치
  • E2E 테스트를 GiitHubAction 자동화
  • 를 통해 수행

    작업 디렉토리에 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',
      },
    });
    

    import Typorm Module


    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(등록), 읽기(참조), 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
    

    Enity 클래스 정의


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

    서비스 클래스 정의

  • 해싱 암호 설치에 필요한 패키지
  • 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라고도 하며 시스템 전체를 통해 테스트됩니다.
    웹 서비스의 경우 사용자와 마찬가지로 브라우저를 조작하여 동작이 예상과 일치하는지 확인합니다.

    테스트 TypeORM 설정 정의


    자동 마이그레이션 기능이 없으므로 테스트할 때만 Enity를 데이터베이스와 동기화할 수 있습니다.
    //  アプリケーション実行時に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.
    

    GiitHubAction에서 워크플로우 정의


    https://github.co.jp/features/actions
    https://qiita.com/HeRo/items/935d5e268208d411ab5a

    테스트 Docker File 정의


    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

    좋은 웹페이지 즐겨찾기