GAミント至上主義

Web Monomaniacal Developer.

NestJSのTest.createTestingModuleの基本的な使い方。ServiceのMock化とかforRoot系の指定とか

NestJSの新規開発で、テストを有効活用しながら開発を進めてるけどTest.createTestingModuleでいろいろハマったのでポイントをメモ。 使い慣れてくると超便利。

今作ってるe2eテストの場合、createTestingModuleはこんな感じ。コメントでメモ。 beforeAll, もしくはbeforeEachでテスト用のNestアプリケーションをつくってそれぞれのケースで使うことになる。

import { Repository } from 'typeorm'
import { Test, TestingModule } from '@nestjs/testing'
import { INestApplication, ValidationPipe } from '@nestjs/common'
import { getRepositoryToken } from '@nestjs/typeorm'
import { SendGridService } from '@anchan828/nest-sendgrid'
// 自分で作ったモック
import { SendGridServiceMock } from './test.lib'

describe('User Authorization (e2e)', () => {
  // 複数のメソッド内で使うやつはここらへんでletしておく
  let app: INestApplication
  let token: string
  let userRepository: Repository<User>
  let usersService: UsersService
  let userEmailVerificationRepository: Repository<UserEmailVerification>

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [],
      imports: [
        UsersModule,
        // テストではこのモジュールがRootになるのでHogeModule.forRoot系はだいたい必要になる
        // 接続名にtestのものを使用するようにしてる、いろんなテストで使うので実際は共通化してimportして使ってる。
        TypeOrmModule.forRootAsync({
          useFactory: async () => {
            return {
              ...(await getConnectionOptions('test')),
              // nameが残ってるとtestConnectionを生成してしまってエラーになるので消してる
              name: null,
            }
          },
        })
        // Entityに紐付いたRepositoryを書き出してくれる。Moduleでやってるときは不要。カスタムレポジトリの場合も別
        TypeOrmModule.forFeature([User, UserProfile, /* 省略*/]),
      ],
    })
    // メール送信に@anchan828/nest-sendgridを使用したけどテスト中は送られると困るのでMockに差し替える 
      .overrideProvider(SendGridService)
      .useClass(SendGridServiceMock)
      .compile()
    app = module.createNestApplication()
    // テスト中に使いたいサービス、レポジトリなどはこの形で抜き出せる
    userEmailVerificationRepository = app.get<
      Repository<UserEmailVerification>
    >(getRepositoryToken(UserEmailVerification))
    userRepository = app.get<Repository<User>>(getRepositoryToken(User))
    usersService = app.get<UsersService>(UsersService)
    // モックのメソッドが呼ばれたか判定したいのでspyを仕込んでおく
 // expect(sendGridService.send).toHaveBeenCalled() のような感じでチェックできる
    sendGridService = app.get<SendGridService>(SendGridService)
    jest.spyOn(sendGridService, 'send')
    // バリデーションなどのPipeも設定が必要
    app.useGlobalPipes(
      new ValidationPipe({
        transform: true,
      }),
    )

imports

そのテストで使うModuleを指定する。自分で作成したModuleの他、 あとテスト中はこのモジュールがRootになるのでTypeOrmModule.forRootAsync()(もしくはforRoot())や、 よく使うConfigModule、EventEmitterModuleなどもそれぞれforRoot()があるので必要な場合は追加する。 テストファイルが増えてくるとだいたい同じのを何度も書くことになるのでサービス単位などで共通化しておくと良さそう。

providers

サービスなどの@Injectable()なClassを指定するけど、基本的に上記のModuleで指定しているものが多いと思うので、サービスの単体テスト等でなければ指定する機会がないかも。 importのModule内と、ここで2重で読み込んだりするとなかなか分かりにくいエラーが出たりするので注意。

サービスなどのMock化

この箇所で行っている。

      .overrideProvider(SendGridService)
      .useClass(SendGridServiceMock)

元のサービスの型をみて今回はこんな感じのにした。送らないけど中身もちょっと目視したいので一旦console.logで出してる。

export class SendGridServiceMock {
  send(data: Partial<MailDataRequired>) {
    console.log(
      `SendGridServiceMock.send() from:${data.from} to:${data.to} subject:${data.subject}`,
    )
  }
  sendMultiple(data: Partial<MailDataRequired>) {
    console.log(
      `SendGridServiceMock.sendMultiple() from:${data.from} to:${data.to} subject:${data.subject}`,
    )
  }
}

TypeORMのcascade, orphanedRowAction等RelationOptionsの違い

TypeORMのEntityで使うRelation系のオプションがいろいろ紛らわしいのでメモ。

バージョン

"typeorm": "^0.2.37"

DBはMySQLを使用。

公式ドキュメントはここらへん

typeorm/relations.md at master · typeorm/typeorm · GitHub

Entityはこんな感じのサンプル通りのやつ

export class User {
  @PrimaryGeneratedColumn()
  id: number

  @OneToOne(() => UserProfile, (profile) => profile.user, {
    cascade: true,
    orphanedRowAction: 'delete',
    onDelete: 'CASCADE',
  })
  @JoinColumn()
  profile: UserProfile

  // 省略
}

cascade

名前的にDB側のテーブル設定かと思いきや違うので、設定を変更してもmigration は発生しない。 親Entityを各種更新したときにいっしょに子も更新するかどうか。 取得時には影響しない

選択肢はboolまたは "insert" | "update" | "remove" | "soft-remove" | "recover"

onDelete, OnUpdate

これがDB構造に影響するもの。 下記のようにほぼそのまんまALTER等される。変更時はmigration必要。 将来アプリ側で制御できなくて困る可能性もあるので慎重にした方がよさそう。

 await queryRunner.query(`ALTER TABLE \`search_db\`.\`user\` ADD CONSTRAINT \`FK_9466682df91534dd95e4dbaa616\` FOREIGN KEY (\`profileId\`) REFERENCES \`search_db\`.\`user_profile\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`);

eagar

取得時にそのEntityも一緒に取得するかどうか。DB構造には影響しない。

orphanedRowAction

ちょっとややこしいのがこれ。orphanは孤児的な意味。DB構造には影響しない。 親Entityで子Entityを消してsaveした場合、いっしょに消すかどうか。

デフォルトでは子に入っている親IDをnullにしようとする(当然nullable: falseの場合はエラー)。

選択肢は"nullify" | "delete"

追加時のプルリクが参考になる。

feat: relations: Orphaned row action by nebkat · Pull Request #7105 · typeorm/typeorm · GitHub

This adds an additional option on one-to-many relationships where child entities are deleted, rather than having their foreign key set to null, when removed from their parent.

上記の通り基本的にはOneToManyのMany側に使用する。 設定しないとOne側が削除された場合、Many側はOneのIDをnullにする動作をするが、deleteにすれば行ごと削除してくれる。 基本的に関連キーはnullable: falseにすることが多いので、これ付けないとOne側削除時にエラーになる場面が大半な気がする。

OneToOneにも設定できてしまうけど意味はなさそう。

テストではCategoryにPostを2つ追加したsaveした後、1つ消して再度save, Postも一緒に消えてるかどうかを検証してる。

feat: relations: Orphaned row action by nebkat · Pull Request #7105 · typeorm/typeorm · GitHub

さいごに

ここらへん、設計時にはなるべく連動して消えたり保存されたりしてほしいと思うものの、実際はいろんな問題起きたりするので、どれを設定するかしないかが結構悩みどころ。 OneToOne でも設定はできるけど、意図した動きしないのでちょっと検証中。

NestJS + CircleCIで各種チェックやテストを一通り実行する

NestJS + TypeORMを使った新規開発で開発がちょっと進み、テストコマンドなどもできてきたのでCircleCIで自動実行できるようにしたのでメモ。

前提としてローカルではテストなどが動いていること。


実行したのは下記。まだデプロイなどは行っていない。

  • lintの実行
  • NestJSのbuild
  • マイグレーション漏れが無いか確認
  • 普通のテスト実行 (テスト用DB使用)
  • E2Eテストの実行(テスト用DB使用)

NestJS系のバージョン

"dependencies": {
    "@nestjs/common": "^8.0.9",
    "@nestjs/config": "^1.0.1",
    "@nestjs/core": "^8.0.9",
    "@nestjs/platform-express": "^8.0.0",
    "@nestjs/typeorm": "^8.0.2",

CircleCIの設定ファイル(.circleci/config.yml)

いろいろなところからのコピペしたままもあり、ツギハギ感あるけど一旦動いたもの。

テスト用DBの接続情報、パスワードとかも直書きだけどCircleCI内部で使われるだけなので問題なさそうなのでそのまま。

version: 2.1

jobs:
  test-server:
    resource_class: medium
    docker:
      - image: circleci/node:14
        environment:
          TEST_DB_HOST: localhost
          TEST_DB_PORT: 3306
          TEST_DB_USERNAME: test_search_user
          TEST_DB_PASSWORD: test_search_password
          TEST_DB_DATABASE: test_search_db

      - image: mysql:8.0
        command:
          - --sql-mode=NO_ENGINE_SUBSTITUTION
        environment:
          MYSQL_DATABASE: test_search_db
          MYSQL_USER: test_search_user
          MYSQL_PASSWORD: test_search_password
          MYSQL_ROOT_PASSWORD: test_search_root_password
          MYSQL_HOST: localhost

    steps:
      - checkout

      - restore_cache:
          name: Restore Yarn Package Cache
          keys:
            - yarn-packages-{{ checksum "./server/yarn.lock" }}
      - run:
          # フロント、サーバーを同一レポジトリでやる予定のため、./serverにNestJSを入れてあるので全体的にそのフォルダを指定してます。
          working_directory: ./server
          name: Install Dependencies
          command: yarn install --immutable
      - save_cache:
          name: Save Yarn Package Cache
          key: yarn-packages-{{ checksum "./server/yarn.lock" }}
          paths:
            - ~/server/.cache/yarn
      - run:
          working_directory: ./server
          name: lint
          command: yarn lint:ci
      - run:
          working_directory: ./server
          name: nest build
          command: yarn nest build
      - run:
          name: dockerize のインストール
          command: wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
          environment:
            DOCKERIZE_VERSION: v0.3.0
      - run:
          name: db の待機
          command: dockerize -wait tcp://localhost:3306 -timeout 1m
      - run:
          working_directory: ./server
          name: migration run
          command: yarn typeorm migration:run -c test
          no_output_timeout: 1m
      - run:
          working_directory: ./server
          name: Verifies that the current database is up to date.
          command: yarn typeorm migration:generate -c test --ch -n Test
          no_output_timeout: 1m
      - run:
          working_directory: ./server
          name: app test
          command: yarn test
          no_output_timeout: 1m
      - run:
          working_directory: ./server
          name: e2e test
          command: yarn test:e2e
          no_output_timeout: 3m

workflows:
  server:
    jobs:
      - test-server

接続設定関係

接続情報にはormconfig.tsを用いておりこんな感じ。

default を環境変数で切り替えでもいいけど、もしもの事故を防ぐ意味も兼ねて明示的にbtestを使うようにしている。
マスタデータ系の投入にもmigrationを用いているため、synchronizeは使わず、migrationsRunでマイグレーションを実行している。
マイグレーションファイルも、DB名を変えたり、不要なデータを省いたり専用に用意している。


ormconfig.ts

// https://github.com/typeorm/typeorm/blob/master/docs/connection-options.md
module.exports = [
  {
    name: 'default',
    type: 'mysql',
    // 省略
  },
  {
    name: 'test',
    type: 'mysql',
    host: process.env.TEST_DB_HOST,
    port: process.env.TEST_DB_PORT,
    username: process.env.TEST_DB_USERNAME,
    password: process.env.TEST_DB_PASSWORD,
    database: process.env.TEST_DB_DATABASE,
    synchronize: false,
    dropSchema: true,
    entities: ['src/**/*.entity.ts'],
    migrationsRun: true,
    migrations: ['src/migration_test/*.ts'],
    logging: false,
  },
]

circleci/mysqlイメージを使ったら固まったので普通のMySQLイメージを使った


CircleCIが用意したMySQLイメージがあり、インメモリ設定も簡単にできるので使っていたが、実行すると途中で固まってしまうため、通常のMySQLイメージを使用した。

https://hub.docker.com/r/circleci/mysql

こっち使ってる
https://hub.docker.com/_/mysql


接続もクエリ発行も問題ないけど下記の箇所で何も返さずに固まってタイムアウトするので困った。

yarn run v1.22.5
$ ts-node $(yarn bin typeorm) migration:show -c test
query: SELECT * FROM `INFORMATION_SCHEMA`.`COLUMNS` WHERE `TABLE_SCHEMA` = 'test_search_db' AND `TABLE_NAME` = 'migrations'
query: CREATE TABLE `test_search_db`.`migrations` (`id` int NOT NULL AUTO_INCREMENT, `timestamp` bigint NOT NULL, `name` varchar(255) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB
query: SELECT * FROM `test_search_db`.`migrations` `migrations` ORDER BY `id` DESC
# このまま音信不通となりタイムアウト・・・

Experimental の記載もあるし、実行速度も問題ないので一旦このまま。将来的にまた試して見ようと思う。

CI用のlintコマンドを追加する

レビューのときもちょくちょく忘れがあったので欲しかったやつ。
yarn lintで実行できるコマンドはあるが--fixが付いていて直せるところは直したあとの結果が返ってしまうので、fixなしのコマンドをつくって実行するようにした。

package.json

  "scripts": {
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "lint:ci": "eslint \"{src,apps,libs,test}/**/*.ts\"",

テスト用DBを使ったテスト実行

AWS で専用に立てなきゃ・・・と思ってたら簡単にDockerで動かせるので超便利だった。時代に追いつけてない。

マイグレーション漏れが無いか確認

現状のマイグレーションを実行後のテーブルと、Entityに差異が無いかをチェックする。
これもレビュー時に手動で確認してて、ちょくちょく問題になったので欲しかった。
typeormのCLIで下記のように--ch をつければ確認できる。なければ0, あれば1でエラー。

  • nは使われることはないが必要なので適当に。

事前にtypeorm migration:runが必要なのでCI側で直前に実行するようにしている。

typeorm migration:generate -c test --ch -n Test

できた!

プッシュすると一通りのテストがCIで動くやつ、5年以上前から憧れてた気がするけどようやく形にできた気がする。
新規のタイミングでないとなかなか難しい。
これを維持できるようにがんばりたい。

f:id:uyamazak:20211004154841p:plain