Jest + Nest.jsで始める!  E2Eテスト

# 概要

`NestJs`で`Jest`による`E2E`テストを導入したので知見をまとめる

# ディレクトリ構造

```bash
e2e
│   ├── users.e2e-spec.ts
│   ├── jest-e2e.json
│   ├── mocks
│   │   └── contractorReps
│   │       └── mock.ts
│   └── utilities
│       ├── common.utility.ts
│       └── master.utility.ts
```

## 設定用のjsonファイルを作成

- `moduleFileExtensions`:モジュールが使用するファイル拡張子の配列
- `rootDir`:Jestの設定ファイルが置かれているディレクトリ
- `testEnvironment`:テスト環境
- `testRegex`:Jestがテストファイルを検出する際に使用するパターン
- `transform`:パス、トランスフォーマーへのマップ
- `moduleNameMapper`:試験対象のファイルパス

```json:/e2e/jest-e2e.json
{
    "moduleFileExtensions": [
        "js",
        "json",
        "ts"
    ],
    "rootDir": ".",
    "testEnvironment": "node",
    "testRegex": ".e2e-spec.ts$",
    "transform": {
        "^.+\\.(t|j)s$": "ts-jest"
    },
    
    "moduleNameMapper": {
        "^src/(.*)$": "<rootDir>/../../$1"
    }
}
```

https://jestjs.io/ja/docs/configuration#modulenamemapper-objectstring-string--arraystring

## TypeORM用のテスト設定ファイルを作成

:::message alert
TypeORMにはmigrationを自動的に行う機能が存在しないので、`synchronize: true`とし強制的にmigrationを実行させています

本番環境のデータが失われる可能性があるので必ず本番用の設定ファイルでは`synchronize: false`とすること
:::

> typeormが持つsynchronizeの機能を使えば、ヒトがテーブルを作成せずともスキーマさえあればテーブル定義を自動生成してくれる。
同期-アプリケーションの起動ごとにデータベーススキーマを自動作成する必要があるかどうかを示します。このオプションには注意してください。本番環境では使用しないでください。本番環境のデータが失われる可能性があります。このオプションは、デバッグおよび開発中に役立ちます。

https://github.com/typeorm/typeorm/blob/master/docs/faq.md

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

## テスト用モジュールのインストール
```bash:bash
npm i --save-dev @nestjs/testing
```

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

## テストするAPIについて

以下の形式でリクエストを投げるとユーザー情報を返すAPIをテストしてみます

#### リクエストデータ
`http://localhost:xxxx/users?name=テスト`

#### レスポンスデータ

```json
{
    "statusCode": 200,
    "message": "SUCCESS",
    "data": [
        {
            "id": 1,
            "name": "テスト",
            "ins_ts": "2021/11/29 13:47"
        }
}
```


## テストコード

バリデーションテストは割愛しています

```ts:users.e2e-spec.ts
import { INestApplication, ValidationPipe } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { APP_GUARD } from '@nestjs/core'
import { Test, TestingModule } from '@nestjs/testing'
import { TypeOrmModule } from '@nestjs/typeorm'
import * as request from 'supertest'

describe('サンプルテスト(E2E)', () => {
  // モジュール設定開始
  let app: INestApplication

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

    // ダミーデータをシードする
    await runTestDataSeeder()
  })

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

        // テスト用の設定ファイル読み込み
        ConfigModule.forRoot({
          envFilePath: ENV_FILE_PATH,
        }),

        // メインモジュールを読み込む
        AppModule,
      ],

      controllers: [UserController],

      // アクセス権限Guardをセットします
      providers: [
        ContractorService,
        {
          provide: APP_GUARD,
          useExisting: RoleGuard,
        },
        RoleGuard,
      ],
    }).compile()

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

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

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

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

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

  /**
   * @summary ユーザー一覧を取得します
   * @param account
   * @returns request.Response
   */
  const index = async (account?: E2eLoginData): Promise<request.Response> => {
    const res = account
      ? await request(app.getHttpServer())
          .get(API_END_POINTS.USER)
          .set(
            'Authorization',
            `Bearer ${await getJwtToken(request, app, account)}`
          )
      : await request(app.getHttpServer()).get(API_END_POINTS.USER)

    return res
  }

  /**
   * @summary ユーザーを取得します
   * @param account
   * @param id
   * @returns request.Response
   */
  const show = async (
    id: number,
    account?: E2eLoginData
  ): Promise<request.Response> => {
    const res = account
      ? await request(app.getHttpServer())
          .get(`${API_END_POINTS.USER}/${id}`)
          .set(
            'Authorization',
            `Bearer ${await getJwtToken(request, app, account)}`
          )
      : await request(app.getHttpServer()).get(
          `${API_END_POINTS.USER}/${id }`
        )

    return res
  }

  /**
   * @summary ユーザーを作成します
   * @param account
   * @param body
   * @returns request.Response
   */
  const create = async (
    dto: CreateUserDto,
    account?: E2eLoginData
  ): Promise<request.Response> => {
    const res = account
      ? await request(app.getHttpServer())
          .post(API_END_POINTS.USER)
          .set(
            'Authorization',
            `Bearer ${await getJwtToken(request, app, account)}`
          )
          .set('Accept', 'application/json') // responseデータをjsonとして扱ってください
          .send(dto)
      : await request(app.getHttpServer())
          .post(API_END_POINTS.USER)
          .set('Accept', 'application/json')
          .send(dto)

    return res
  }

  /**
   * @summary ユーザーを更新します
   * @param account
   * @param  id: number,
   * @param body
   * @returns request.Response
   */
  const update = async (
    dto: UpdateUserDto,
    id: number,
    account?: E2eLoginData
  ): Promise<request.Response> => {
    const res = account
      ? await request(app.getHttpServer())
          .patch(`${API_END_POINTS.USER}/${id}`)
          .set(
            'Authorization',
            `Bearer ${await getJwtToken(request, app, account)}`
          )
          .set('Accept', 'application/json') // responseデータをjsonとして扱ってください
          .send(dto)
      : await request(app.getHttpServer())
          .patch(`${API_END_POINTS.USER}/${id}`)
          .set('Accept', 'application/json')
          .send(dto)

    return res
  }

  /**
   * @summary ユーザーを論理削除します
   * @param account
   * @param  id: number,
   * @param body
   * @returns request.Response
   */
  const softDelete = async (
    id: number,
    account?: E2eLoginData
  ): Promise<request.Response> => {
    const res = account
      ? await request(app.getHttpServer())
          .delete(`${API_END_POINTS.USER}/${id}`)
          .set(
            'Authorization',
            `Bearer ${await getJwtToken(request, app, account)}`
          )
      : await request(app.getHttpServer()).delete(
          `${API_END_POINTS.USER}/${id}`
        )

    return res
  }

  describe('ユーザー一覧テスト', () => {
    it('OK /users(GET)', async () => {
      const res = await index(LOGIN_DATA.SERVICE_ADMIN)
      expect(res.status).toEqual(HTTP_STATUS_CODES.OK)
      expect(res.body).toEqual(INDEX_USERS)
    })
  })

  describe('ユーザー編集テスト', () => {
    it('OK /users/:id (GET)', async () => {
      const id = (await index(LOGIN_DATA.SERVICE_ADMIN)).body[0]
        .id

      const res = await show(id, LOGIN_DATA.SERVICE_ADMIN)
      expect(res.status).toEqual(HTTP_STATUS_CODES.OK)
      expect(res.body).toEqual(SHOW_USER_DATA)
    })
  })

  describe('ユーザー登録テスト', () => {
    it('OK /users (POST)', async () => {
      const body: CreateUserDto = {
        name: 'hoge',
        password: 'password',
        password_confirm: 'password',
      }

      const res = await create(body, LOGIN_DATA.SERVICE_ADMIN)
      expect(res.status).toEqual(HTTP_STATUS_CODES.CREATED)
      expect(res.body.message).toEqual(RESPONSE_MESSAGES.USER)
    })
  })

  describe('ユーザー更新テスト', () => {
    it('OK users/:id (PATCH)', async () => {
      const id = (await index(LOGIN_DATA.SERVICE_ADMIN)).body[0]
        .id

      const body: UpdateUserDto = {
        name: 'fuga',
        password: 'password',
        password_confirm: 'password',
      }

      const res = await update(
        body,
        id,
        LOGIN_DATA.SERVICE_ADMIN
      )
      expect(res.status).toEqual(HTTP_STATUS_CODES.OK)
      expect(res.body.message).toEqual(
        `ユーザーID「${id}」の更新に成功しました。`
      )
    })
  })

  describe('ユーザー論理削除テスト', () => {
    it('OK /users/:id (DELETE)', async () => {
      const id = (await index(LOGIN_DATA.SERVICE_ADMIN)).body[0]
        .id

      const res = await softDelete(contractor_rep_id, LOGIN_DATA.SERVICE_ADMIN)
      expect(res.status).toEqual(HTTP_STATUS_CODES.OK)
      expect(res.body.message).toEqual(
        `ユーザーID「${contractor_rep_id}」の論理削除に成功しました。`
      )
    })
  })
```

## テストを実行

```bash:bash
# 2. E2E テストを実行する
npm run test:e2e

# 成功
PASS  src/test/e2e/user.e2e-spec.ts (33.623 s)
  サンプルテスト(E2E)
    ユーザー一覧テスト
      ✓ OK /users(GET) (1748 ms)
    ユーザー編集テスト
      ✓ OK /users/:id (GET) (1735 ms)
    ユーザー登録テスト
      ✓ OK /users (POST) (1493 ms)
    ユーザー更新テスト
      ✓ OK users/:id (PATCH) (1327 ms)
    ユーザー論理削除テスト
      ✓ OK /users/:id (DELETE) (1557 ms)
```

# E2EテストをGitHub Actionsで実行できるようにしました

https://zenn.dev/naonao70/articles/67d61979886553