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