NestJSにおけるテスト戦略アイディア


社内向けにドキュメント書いたのでついでに公開します。

APIテスト(NestJSでいうところのe2eテスト)をどうするか

nestjsの最初のテンプレートだとsrcディレクトリとtestディレクトリがあり、APIテストはapp.e2e-spec.tsという名前でtestディレクトリのなかに入っている。

nestjs初期のディレクトリ構成

選択してね

選択肢1 e2eテストをtestディレクトリ配下におくか各モジュールと一緒に置くか

testディレクトリ配下におくメリット

  • testディレクトリを指定してe2eテストを実行できる(ただjestはtestRegexpの設定でファイルの拡張子指定できるのであまり意味ないかも)。

testディレクトリ配下におくデメリット

  • srcディレクトリ配下のリソースにアクセスしたいとき、ディレクトリをまたぐことになるのでパスが複雑になる。
  • せっかく全部がモジュール単位でまとまっているのに、apiテストだけtestディレクトリと分断されるので、コンテキストが途切れる。

各モジュールと一緒に置くメリット

  • srcディレクトリ配下のリソースにアクセスしやすい。
  • すべての関連ファイルがリソース単位でまとまっているので、管理しやすい。

各モジュールと一緒に置くデメリット

  • testディレクトリ配下におくよりも、該当テストか探索するのに時間かかりそう?(要検証)

よって各モジュールと一緒に置くことにした。

選択肢2 APIテストを1つのファイルにまとめるか、各リソースごとに分断するか

app.e2e-spec.tsにすべてのテストを書くのか、resource1.e2e-spec.ts``resource2.e2e-spec.tsというふうにファイルを分割するのか。

すべて1ファイルにするメリット

  • DBのセットアップとティアダウンが楽。
  • リクエストユーザー使い回せる。
  • 並行テストにならないので、並列テスト固有の問題がなくなる(同じリソースつくってユニーク成約に引っかかるとか)。

すべて1ファイルにするデメリット

  • 各リソースのテストの順番を考えないと、あるテストで生成したリソースが他のテストに影響を与えてしまう(メリットでもある??)。
  • jestはファイル内部では直列実行するので、テストが長くなると当然実行時間も長くなる。
  • 1ファイルが長くなるので、エディターが重い。

分割するメリット

  • 1ファイルあたりが短くなるので、エディターが軽い。
  • 並列実行できるので、リソースが増えても実行時間が直列よりも長くならない。
  • 直列実行したい場合もjestであれば--runInBandというオプション1つで可能。

分割するデメリット

  • セットアップとティアダウンのオーバーヘッドがある(jestはグローバルセットアップとグローバルティアダウンを設定できるのでうまく設計すればオーバヘッドを少なくできる)。
  • 並列実行できるようにテストを設計しないといけない。

よって各リソースごとに分割することにした。そのため並列実行できるようにテストを設計する必要がでてきた。並列実行のための戦略はあとのほうで言及してる。

選択肢3 APIテストでインポートするモジュールをどれにするか

AppModuleをインポートするか、関連するモジュールをリソースごとにインポートするか。

ちょっとわかりにくいので説明。例えばあるリソースのAPIテストで全部入りのAppModuleを使うか、

resource1.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../app.module';

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

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

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

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

そのリソースの関連モジュールだけを使うかです。

resource1.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { Resource1Module } from './resource1.module';

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

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [Resource1Module],
    }).compile();

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

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

AppModuleをインポートするメリット

  • 全部入りなのでグローバルなやつをインポートしなくても良い。
  • 各テストがにたようなcreateTestingModuleになるので、共通化できる。

AppModuleをインポートするデメリット

  • セットアップに時間かかりそう(要検証)。

関連するモジュールをリソースごとにインポートするメリット

  • セットアップはやそう(要検証)。

関連するモジュールをリソースごとにインポートするデメリット

  • グローバルにエクスポートされてあるモジュールなどは、都度インポートしないといけないのがめんどい。

よってAPIテストはAppModuleをインポートして、共通化関数を作った。

APIテストを並列実行できるためにやったこと(あきらめたこと)

直列テストの時代の戦略

jestを利用する前はmochaを使って直列なAPIテストを書いていた。

APIテストにおいて厄介なのが認証部分。適当にユーザーを作って、そのユーザーでリクエストを送っても認証が通らない。以前は、expressを利用しており、APIテストのときは認証部分をスキップしていた。

認証部分イメージ
if(process.env.NODE_ENV !== test){
  app.use(authCheck);
}

しかし認証部分でreq.userにJWTから取得したemailをもとに、DBからユーザーを取得していた。

現在ユーザー
// 認証後req.authUserにメールアドレスをいれてた
req.user = userRepository.findByEmail(req.authUser);

認証をスキップするとemailが取れない。そこでテスト専用のユーザーを作成して使いまわしていた。

現在ユーザー
// 認証後req.authUserにメールアドレスをいれてた
if(process.env.NODE_ENV === test){
  // テスト用ユーザー
  // セットアップでユーザー作成してある前提
  req.user = userRepository.findByEmail('[email protected]');
} else {
  req.user = userRepository.findByEmail(req.authUser);
}

もうすでにややこしいことになってる。ただ、テスト用リクエストユーザーを一番最初に固定して作ってしまうと、例えばユーザーの権限がアドミンのときや一般のときのテストができなくなる。そこでリクエストユーザーは各テストごとにbeforeフックで作成していた。各テストは直列かつ、テストごとにデータを削除していたので問題なかった。

createRequestUser
export const createRequestUser = async (permission: string) => {
  const user = buildDummyUser({
    permission,
    email: '[email protected]'
  });
  
  await userRepository.upsert(user);
}
resource1.e2e-spec.ts
desribe(('/resource1') => {
  let reqUser: UserEntity;
  let app: INestApplication;
  
  describe('ADMINのとき', () => {
      beforeAll(async () => {
        const module: TestingModule = await Test.createTestingModule({
        imports: [Resource1Module],
      }).compile();

      app = moduleFixture.createNestApplication();
      await app.init();
    
      reqUser = await createRequestUser('ADMIN');
    })
  
    afterAll((done) => {
      // すべてのデータを削除
      deleteAllTable(done)
    })
    
    test('/ (GET)', () => {
      return request(app.getHttpServer())
        .get('/resource1')
        .expect(200)
        .expect('Hello World!');
    });
  })
  
  describe('一般ユーザーのとき', () => {
    // 省略
  })
})

並列で実行するとこの戦略がとれなくなる。

並列テストの時代の新しい戦略

そこで以下の戦略を取ることにした。

  • テストごとにデータを削除せずグローバルセットアップとグローバルティアダウンを活用する。
  • リクエストユーザーは各テストごとにその都度作成する
  • トークン作成が外部サービスなので、JWT認証をうまくその作成したリクエストユーザーごとに通す。テスト専用のJWTストラテジーを作成する。

テストごとにデータを削除せずグローバルセットアップとグローバルティアダウンを活用する

グローバルセットアップにテストフィクスチャ系の処理をして、グローバルティアダウンで全データを削除するようにしました。これで、各テストごとのオーバーヘッドがなくなります。

jest.config.js
const config = {
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: './',
  modulePaths: ['<rootDir>'],
  testRegex: '.*\\.(e2e-spec|spec)\\.ts$', // e2eSpecはe2eテストでspecはunitテスト。
  globalTeardown: '<rootDir>/src/share/teardownJest.ts',
  globalSetup: '<rootDir>/src/share/setupJest.ts',
  transform: {
    '^.+\\.(t|j)s$': 'ts-jest',
  },
  collectCoverageFrom: ['src/**/*.(t|j)s', '!src/**/*.d.ts'],
  coverageDirectory: './coverage',
  testEnvironment: 'node',
};

module.exports = config;
resource1.e2e-spec.ts
desribe(('/resource1') => {
  let reqUser: UserEntity;
  let app: INestApplication;
  
  describe('ADMINのとき', () => {
      beforeAll(async () => {
        const module: TestingModule = await Test.createTestingModule({
        imports: [Resource1Module],
      }).compile();

      app = moduleFixture.createNestApplication();
      await app.init();
    
      reqUser = await createUser({
        permission: 'ADMIN'
      });
    })
  
    // afterAllがなくなった
    
    test('/ (GET)', () => {
      return request(app.getHttpServer())
        .get('/resource1')
        .expect(200)
        .expect('Hello World!');
    });
  })
  
  describe('一般ユーザーのとき', () => {
    // 省略
  })
})

リクエストユーザーは各テストごとにその都度作成する

もうメールアドレスを[email protected]に固定することはやめた。

createUser
export const createUser = async (options?: UserEntityConstructor) => {
  // メールアドレスは固定しない。
  const user = buildDummyUser(options);
  
  await userRepository.save(user);
}
resource1.e2e-spec.ts
desribe(('/resource1') => {
  let reqUser: UserEntity;
  let app: INestApplication;
  
  describe('ADMINのとき', () => {
      beforeAll(async () => {
        const module: TestingModule = await Test.createTestingModule({
        imports: [Resource1Module],
      }).compile();

      app = moduleFixture.createNestApplication();
      await app.init();
    
      await createUser({
        permission: 'ADMIN'
      });
    })
  
    afterAll((done) => {
      // すべてのデータを削除
      deleteAllTable(done)
    })
    
    test('/ (GET)', () => {
      return request(app.getHttpServer())
        .get('/resource1')
        .expect(200)
        .expect('Hello World!');
    });
  })
  
  describe('一般ユーザーのとき', () => {
    // 省略
  })
})

テスト専用のJWTストラテジーを作成する

認証チェックがあるかはチェックしたいけど有効なダミートークンを生成するのが面倒だった。そこでちょっと工夫したJWTストラテジーとガードを作成することにした。トークンの代わりにメールアドレスを渡すことで、トークン認証がチェックされていることを確認しつつ、req.userにユーザーを代入することができる。

つぎのように利用する想定。

resource1.e2e-spec.ts
test('api test', () => {
  agent
    .get('/resource1')
    .set('Authorization', `Bearer ${reqUser.email}`)
    .expect(200, done);
})

実際のコード

auth.module.ts
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';

import { UserModule } from '@/account/user.module';

import {
  JwtStrategy,
  JwtTestStrategy,
} from './strategy';

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    UserModule,
  ],
  providers: [
    JwtStrategy,
    JwtTestStrategy,
  ],
})
export class AuthModule {}
auth/strategy/jwtForTest.strategy.ts
import {
  Injectable,
  UnauthorizedException,
  Logger,
  InternalServerErrorException,
} from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import { Strategy } from 'passport-custom';

import { UserEntity } from '@/account/entity';
import { UserService } from '@/account/user.service';

@Injectable()
export class JwtTestStrategy extends PassportStrategy(Strategy, 'jwtTest') {
  private logger = new Logger(JwtTestStrategy.name);

  constructor(
      private userService: UserService,
  ) {
    super();
  }

  // NOTE: validateでnullを含むfalsyを返すと401エラーが返る
  public async validate(req: Request): Promise<UserEntity> {
    const auth = req.get('Authorization');
    
    // Bearerトークンかチェック。
    if (!auth || auth.length < 10) {
      throw new UnauthorizedException(
        "Invalid or missing Authorization header. Expected to be in the format 'Bearer <your_JWT_token>'.",
      );
    }

    const authPrefix = auth.substring(0, 7).toLowerCase();
    if (authPrefix !== 'bearer ') {
      throw new UnauthorizedException(
        "Authorization header is expected to be in the format 'Bearer <your_JWT_token>'.",
      );
    }

    const email = auth.substring(7);

    const currentUser = await this.userService.findUserByEmail({
      email,
      requestId,
    });

    if (!currentUser) {
      throw new InternalServerErrorException('ユーザーが見つかりません。');
    }

    return currentUser;
  }
}
auth/guard/jwtForTest.guard.ts
import { AuthGuard } from '@nestjs/passport';

export class JwtForTestGuard extends AuthGuard('jwtTest') {
  constructor() {
    super();
  }
}
resource1.e2e-spec.ts
desribe(('/resource1') => {
  let reqUser: UserEntity;
  let app: INestApplication;
  
  describe('ADMINのとき', () => {
      beforeAll(async () => {
        const module: TestingModule = await Test.createTestingModule({
          imports: [AppModule],
        })
	.overrideGuard(JwtGuard) // JwtGuardを上書き
	.useClass(JwtForTestGuard)

      app = moduleFixture.createNestApplication();
      await app.init();
    
      reqUser = await createUser({
        permission: 'ADMIN'
      });
    })
  
    // afterAllがなくなった
    
    test('/ (GET)', () => {
      return request(app.getHttpServer())
        .get('/resource1')
        .set('Authorization', `Bearer ${reqUser.email}`)
        .expect(200)
        .expect('Hello World!');
    });
  })
  
  describe('一般ユーザーのとき', () => {
    // 省略
  })
})

これで、直列テストでできなかったことを克服した。

あとは、これはチームによるかもしれないが、以下の方針をとっている。

  • テストの時間とリソースの節約のため、なるべく200サクセス系と401エラー系と403エラー系をメインにテストして、400エラー系は部分的にチェックする。ほかはバリデーション専用にユニットテストで対応する。
  • テストの負担をなるべく軽くするために、APIテストでチェックするのはステータスコードのみにする。データベースやレスポンスはチェックしない。

レスポンスのチェックをしないのは、openapiからDTOを作成して、レスポンスにかぶせてるから。データベースのチェックをしないのは、テストが複雑にしんどくなるからと、DB周りロジックはなるべくサービスレイヤやモデルにおけるユニットテストでカバーしたかったから。

APIテストはどちらかというと

  • 認証チェック
  • 権限チェック
  • バリデーションチェック
    といった入り口のほうを重視している。

https://zenn.dev/dove/articles/bdf73092fa00a9