NestJSにおけるテスト戦略アイディア
社内向けにドキュメント書いたのでついでに公開します。
APIテスト(NestJSでいうところのe2eテスト)をどうするか
nestjsの最初のテンプレートだとsrcディレクトリとtestディレクトリがあり、APIテストはapp.e2e-spec.tsという名前でtestディレクトリのなかに入っている。

選択してね
選択肢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を使うか、
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!');
  });
});
そのリソースの関連モジュールだけを使うかです。
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('test@example.com');
} else {
  req.user = userRepository.findByEmail(req.authUser);
}
もうすでにややこしいことになってる。ただ、テスト用リクエストユーザーを一番最初に固定して作ってしまうと、例えばユーザーの権限がアドミンのときや一般のときのテストができなくなる。そこでリクエストユーザーは各テストごとにbeforeフックで作成していた。各テストは直列かつ、テストごとにデータを削除していたので問題なかった。
export const createRequestUser = async (permission: string) => {
  const user = buildDummyUser({
    permission,
    email: 'test@example.com'
  });
  
  await userRepository.upsert(user);
}
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ストラテジーを作成する。
 
テストごとにデータを削除せずグローバルセットアップとグローバルティアダウンを活用する
グローバルセットアップにテストフィクスチャ系の処理をして、グローバルティアダウンで全データを削除するようにしました。これで、各テストごとのオーバーヘッドがなくなります。
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;
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('一般ユーザーのとき', () => {
    // 省略
  })
})
リクエストユーザーは各テストごとにその都度作成する
もうメールアドレスをtest@example.comに固定することはやめた。
export const createUser = async (options?: UserEntityConstructor) => {
  // メールアドレスは固定しない。
  const user = buildDummyUser(options);
  
  await userRepository.save(user);
}
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にユーザーを代入することができる。
つぎのように利用する想定。
test('api test', () => {
  agent
    .get('/resource1')
    .set('Authorization', `Bearer ${reqUser.email}`)
    .expect(200, done);
})
実際のコード
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 {}
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;
  }
}
import { AuthGuard } from '@nestjs/passport';
export class JwtForTestGuard extends AuthGuard('jwtTest') {
  constructor() {
    super();
  }
}
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テストはどちらかというと
- 認証チェック
 - 権限チェック
 - バリデーションチェック
といった入り口のほうを重視している。 
Discussion