✏️

Nest.jsに関するTips

2022/02/23に公開
3

背景

ここ数ヶ月興味本位でNest.jsを使ってのAPI基板開発を始めまして、
その際、効率的な開発環境を整備するにはドキュメントに記載のないような一手間加える必要があったりだったので、
その辺りをユースケースに沿ってまとめてみる。

新たなTipsが発見された場合には随時更新していく予定である。

改訂履歴

  • 2022/02/23 初版投稿
  • 2022/02/24 typeorm-seedingの選定経緯を追記

前提

使用ツール

記述に関して

  • **:任意のパス

Tips

Railsのrake taskのようなタスクランナーを構成したい

  • 以下のように定義ファイルを記述し、node/ts-nodeで実行する

Point

  • testingモジュールを使用する

事前準備

こちらに沿って@nestjs/testingをインストールする

実装

src/**/**.ts
import { Test } from '@nestjs/testing';
import { RequestMethod } from '@nestjs/common';
import { getRepository } from 'typeorm';
import { AppModule } from '~/app.module';

const bootstrap = async (): Promise<void> => {

  // ***** Applicatioinモジュールの読み込み ***** //
  const modules = Reflect.getMetadata('imports', AppModule);
  const controllers = Reflect.getMetadata('controllers', AppModule);
  const providers = Reflect.getMetadata('providers', AppModule);
  
  const testingModule = await Test.createTestingModule({
    imports: modules,
    controllers: controllers,
    providers: providers,
  }).compile();
  
  const app = await testingModule.createNestApplication();
  
  // 前処理など
  app.setGlobalPrefix('api', {
    exclude: [{ path: 'healthz', method: RequestMethod.GET }],
  });
  
  // ***** Repositoryの読み込み ***** //
  const repository = getRepository(/* <任意のEntity> */);
  
  // ***** 行いたい処理 ***** //
  
  // 明示的に終了させないと終了後にプロンプトが起動しない
  process.exit(0);
};

bootstrap();
package.json(該当部のみ抜粋)
"scripts":{
  "task": "yarn build && ts-node dist/tasks/**/**.js"
}
実行
$ yarn task

補足

  • Applicatioinモジュールの読み込み
  • Repositoryの読み込み

これらは別個の処理となるのでどちらかのみ(or どちらも未定義)でも動きます。


ormconfigを一元管理する

  • ormconfigをTSファイルとして定義し、これらを読み込むように各導線に設定を記述する

Point

  • ormconfigをTSファイルとして定義
  • 同ファイルにexportの導線を2つ用意

実装

ormconfig.ts
export const ormconfig = () => ({
  database: {
    type: 'mysql',
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    username: process.env.DB_USER_NAME,
    password: process.env.DB_PASSWORD,
    database: process.env.NODE_ENV === 'test' ? 'database_test' : 'database',
    entities: ['./dist/**/*.entity{.ts,.js}'],
    migrations: ['./dist/**/migrations/*{.ts,.js}'],
    seeds: ['./src/spec/seeds/**/*{.ts,.js}'],
    factories: ['./src/spec/factories/**/*{.ts,.js}'],
    synchronize: false,
    connectTimeout: 30 * 1000,
    logging: process.env.NODE_ENV === 'development',
    cli: {
      entitiesDir: './src/**',
      migrationsDir: './src/migrations',
    },
    bigNumberStrings: false,
    autoLoadEntities: process.env.NODE_ENV === 'test',
  },
});

export default ormconfig().database;
app.module.ts(該当部のみ抜粋)
imports: [
    forwardRef(() =>
      ConfigModule.forRoot({
        ・・・
        load: [ormconfig], // ConfigModuleで読み込む
      }),
    ),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        return configService.get('database'); // forRootAsyncで動的に取り込む
      },
    }),
    ...
   ]
package.json(該当部のみ抜粋)
"scripts":{
  "typeorm": "npx ts-node ./node_modules/.bin/typeorm -f ./src/**/ormconfig.ts",
  "migration:run": "yarn build && yarn typeorm migration:run",
}
実行
$ yarn migration:run

補足

  • ormconfig = Typeormの設定ファイル
  • 普通に使うと、アプリケーションorCLIからとで読み込み階層などの制約がぶつかり、jsonファイルを分ける必要が出てくる

テストにて、RailsのFactoryBotのようなノリでケースを書きたい

  • typeorm-seedingを使用する
    • ちなみに要件を満たすライブラリは他にも存在するが、2022/02/23時点で最新版が新しめかつFactoryBotに近い書き方ができるため採用している。(但しそれでも2021/09が最新リリースとなりあまり更新が活発で無さそうなのが懸念...)

Point

  • typeorm-seedingはコミュニティ主導になる前のfaker.jsに依存してしまっている...
    • faker.jsについての経緯について詳しくはこちら
  • そのため一部処理を最新版のfaker.jsにラップして使う

事前準備

以下インストール

実装

faker.jsをラップしたhelper

src/**/spec/helpers/**.helper.ts
import { ObjectType } from 'typeorm';
import { define as defineOrigin } from 'typeorm-seeding';
import { faker } from '@faker-js/faker';
import { instanceToPlain } from 'class-transformer';

export const define = <Entity, Context>(
  Entity: ObjectType<Entity>,
  factoryFn: (faker) => Entity,
): void => {
  defineOrigin(Entity, (_faker): Entity => {
    // typeorm-seedingに依存しているfakerのバージョンがdeprecateかつ使用できない関数があるため、
    // 最新のバージョンでラップする
    return factoryFn(faker);
  });
};

Factory

src/**/spec/factories/**.ts
import { faker as fakerOrigin } from '@faker-js/faker';
import { define } from '**/helpers/**.helper';
import { User } from '**/**.entity'; // 例

define(User, (faker: typeof fakerOrigin): User => {
  const user = new User();
  user.firstName = faker.name.firstName();
  user.lastName = faker.name.lastName();
  user.email = faker.internet.email();

  return user;
});

export default User;

Spec

**/**.spec.ts
import { HttpStatus, INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import {
  factory,
  tearDownDatabase,
  useRefreshDatabase,
  useSeeding,
} from 'typeorm-seeding';
import * as request from 'supertest';

import { AppModule } from '**/app.module';
import { User } from '**/**.entity';

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

  beforeAll(async () => {
    const modules = Reflect.getMetadata('imports', AppModule);
    const controllers = Reflect.getMetadata('controllers', AppModule);
    const providers = Reflect.getMetadata('providers', AppModule);
    const moduleRef = await Test.createTestingModule({
      imports: modules,
      controllers: controllers,
      providers: providers,
    }).compile();

    app = moduleRef.createNestApplication();
    await app.init();
  });

  beforeEach(async () => {
    // DBに接続&内部のデータをクリア
    await useRefreshDatabase({
      root: './src/configs/', // ormconfigの配置パスを指定(ここでは例として別項の設定が前提の記述にしている)
      configName: 'ormconfig.ts', // ormconfigのファイル名を指定(ここでは例として別項の設定が前提の記述にしている)
    });
    
    // factories loaded
    await useSeeding();
  });

  afterAll(async () => {
    // 後処理
    await app.close();
    await tearDownDatabase();
  });

  it('GET /api/user', async () => {
    const user = await factory(User)().create();
    
    const serialize = (object: any): string => {
      return JSON.stringify(instanceToPlain(object));
    };

    const res = await request(app.getHttpServer()).get('/user');
    expect(res.status).toEqual(HttpStatus.OK);
    expect(res.body).toEqual(JSON.parse(serialize(user)));
  });
});

ormconfig.ts(該当部かつ差異のある部分のみ抜粋)
export const ormconfig = () => ({
  database: {
    ...,
    database: process.env.NODE_ENV === 'test' ? 'database_test' : 'database', // テスト実行時のdatabaseを指定、ここではNODE_ENVで環境を分けている
    seeds: ['./src/**/spec/seeds/**/*{.ts,.js}'], // seedの機能も使用する場合は指定
    factories: ['./src/**/spec/factories/**/*{.ts,.js}'], // factoryファイルのパス
    autoLoadEntities: process.env.NODE_ENV === 'test', // trueにしないと動かない(理由は不明、、)
    ...
  },
});

export default ormconfig().database;

後は通常のフローでテストを実行すればOK。

補足

  • Nest.jsが標準でjestを使った機構を組み込んでいるので、jestを前提とした記述としている
  • typeorm-seedingからimportする関数の引数としてfaker-jsのインスタンスが渡されるためラップ処理を行ったが、内部的に使用されているかは不明..
    • ただ基本デバッグ時にしか使用しない(と思っている)ので、本番運営を見越した場合の懸念にはならないと考えている

テスト時に認証処理をモック化させたい

Now writing...

後書き

Nest.jsはTS標準のため楽にTSの記述で書けるのが良いが、
ライブラリとして痒いところに手が届きにくい仕様となっているのが個人的に惜しいと思ったりしている。
上記のTipsに関してより良い手法や、そもそもこんなことしなくていいよみたいなのがもしあったらコメントいただけたら嬉しいです。

Discussion

村長村長

ちょうどfactory/seeder実装していたので参考になります!!

下記ご存知だったら教えていただきたいです。
factoryを動かそうとしたところ、エラーが出まして・・・
configでは、dist/src/factories/**/*.{ts|js}みたいに定義しています。

root@082c6fdedece:/app# node ./node_modules/typeorm-seeding/dist/cli.js seed  
🌱  TypeORM Seeding v1.6.1
✔ ORM Config loaded
✖ Could not import factories!
/app/dist/src/factories/users.factory.d.ts:1
import { Users } from 'src/entities/users/entity';
^^^^^^

SyntaxError: Cannot use import statement outside a module
    at Object.compileFunction (node:vm:352:18)
    at wrapSafe (node:internal/modules/cjs/loader:1031:15)
    at Module._compile (node:internal/modules/cjs/loader:1065:27)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1153:10)
    at Module.load (node:internal/modules/cjs/loader:981:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Module.require (node:internal/modules/cjs/loader:1005:19)
    at require (node:internal/modules/cjs/helpers:102:18)
    at /app/node_modules/typeorm-seeding/dist/utils/file.util.js:9:141
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
YU-TA-9YU-TA-9

コメントありがとうございます!
そう言っていただけて嬉しいです!

エラー文を拝見しまして、推測にはなってしまいますが、
コンパイル周りの設定に不備があるのかなと感じました。
(記事に関連する設定やtypeorm周りのエラーではなく)

自分もimport周りで不備が起きたことがあり、
その際はfrom以下を「相対パス」で指定したら直りまして、よろしければ併せてご参考にいただけたらと。

このような回答で力になれていたら幸いです。。

村長村長

ありがとうございます!
docker環境なのでCOPYしたファイルのパスなど中心に確認してみます!