NestJS x Jest x TypeORMでUnit~E2Eテストを行う

バイトルPRO開発課の渡邉(@y640drums)です。
今回は社内で導入され始めたNestJSというFWで自動テストの実施方法について試行錯誤したので、そのときに得た知見を共有します。
前提
今回はJestでNestJS x TypeORMアプリをDocker上でテストします。
使用する技術のバージョン情報です。
/backend $ npx nest i
\_ \_ \_ \_\_\_ \_\_\_\_\_ \_\_\_\_\_ \_ \_\_\_\_\_
| \\ | | | | |\_ |/ \_\_\_|/ \_\_ \\| | |\_ \_|
| \\| | \_\_\_ \_\_\_ | |\_ | |\\ \`--. | / \\/| | | |
| . \` | / \_ \\/ \_\_|| \_\_| | | \`--. \\| | | | | |
| |\\ || \_\_/\\\_\_ \\| |\_ /\\\_\_/ //\\\_\_/ /| \\\_\_/\\| |\_\_\_\_\_| |\_
\\\_| \\\_/ \\\_\_\_||\_\_\_/ \\\_\_|\\\_\_\_\_/ \\\_\_\_\_/ \\\_\_\_\_/\\\_\_\_\_\_/\\\_\_\_/
\[System Information\]
OS Version : Linux 5.10
NodeJS Version : v18.4.0
NPM Version : 8.12.1
\[Nest CLI\]
Nest CLI Version : 8.2.8
\[Nest Platform Information\]
platform-express version : 8.4.7
mapped-types version : 1.0.1
schematics version : 8.0.11
typeorm version : 8.1.4
testing version : 8.4.7
common version : 8.4.7
config version : 2.1.0
core version : 8.4.7
cli version : 8.2.8
/backend $ npx jest --version
28.1.2
/backend $ npx typeorm -v
0.3.7
User情報のCRUD機能を実装します。
nest generate resourceコマンドでRESTエンドポイントの雛形を作成します。
/backend $ npx nest generate resource
? What name would you like to use for this resource (plural, e.g., "users")? users
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? Yes
CREATE src/users/users.controller.spec.ts (596 bytes)
CREATE src/users/users.controller.ts (957 bytes)
CREATE src/users/users.module.ts (268 bytes)
CREATE src/users/users.service.spec.ts (474 bytes)
CREATE src/users/users.service.ts (651 bytes)
CREATE src/users/dto/create-user.dto.ts (33 bytes)
CREATE src/users/dto/update-user.dto.ts (181 bytes)
CREATE src/users/entities/user.entity.ts (24 bytes)
UPDATE src/app.module.ts (1400 bytes)
生成されたモジュールのディレクトリ構成は以下のようになります。
src
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
├── main.ts
└── users
├── dto
│ ├── create-user.dto.ts
│ └── update-user.dto.ts
├── entities
│ └── user.entity.ts
├── users.controller.spec.ts
├── users.controller.ts
├── users.module.ts
└── users.service.ts
生成されたファイルに簡単なAPIを実装します。
users.service.ts (抜粋)
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepository: Repository<User\>, //TypeORM Repository
) {}
async findOne(id: number): Promise<User\> {
const user \= await this.usersRepository.findOne({ where: { id } });
if (user \=== null) {
throw new NotFoundException("Specified user doesn't exists");
}
return user;
}
}
users.controller.ts (抜粋)
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
findOne(@Param('id') id: string): Promise<User\> {
return this.usersService.findOne(+id);
}
}
Unitテスト
さてここから本題のテストです。
今回はusers.service.tsのUnitテストを行うため、クラス内で注入されているTypeORMのRepositoryをmockします。
基本
ユーザー全件取得機能のテストケースを用意します。
users.controller.spec.ts (抜粋)
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { getRepositoryToken } from '@nestjs/typeorm';
import { generateMockUser } from '../../test/faker/users-faker';
describe('UsersController', () \=> {
let mockRepository: Repository<User\>;
let controller: UsersController;
// 各テストケース実行前に必要なインスタンスを生成
beforeEach(async () \=> {
const module: TestingModule \= await Test.createTestingModule({
controllers: \[UsersController\],
providers: \[
UsersService,
{
provide: getRepositoryToken(User),
useClass: Repository,
},
\],
}).compile();
mockRepository \= module.get<Repository<User\>>(getRepositoryToken(User));
controller \= module.get<UsersController\>(UsersController);
});
it('findOne method returns a user.', async () \=> {
// 独自実装したメソッドでtestデータの生成
const user \= generateMockUser().pop();
user.id \= 123;
// モックしたい関数のモック実装を渡す
jest.spyOn(mockRepository, 'findOne').mockImplementation(async () \=> user);
// 期待される結果であるか検証する
expect(await controller.findOne(user.id.toString())).toEqual(user);
});
});
Unitテストを実行します。
/backend $ npm run test ./src/users/users.controller.spec.ts
> nest-js-sample@0.0.1 test
> NODE\_ENV=test jest "./src/users/users.controller.spec.ts"
PASS src/users/users.controller.spec.ts
UsersController
✓ should be defined (10 ms)
✓ findAll method returns users array. (4 ms)
✓ findOne method throws not found error when specified user does not exists. (11 ms)
✓ findOne method returns a user. (2 ms)
✓ create method returns user entity when user is successfully created. (2 ms)
✓ update method throws not found error when specified user does not exists. (2 ms)
✓ update method executed successfully. (2 ms)
✓ remove method throws not found error when specified user does not exists. (2 ms)
✓ remove method executed successfully. (5 ms)
Test Suites: 1 passed, 1 total
Tests: 9 passed, 9 total
Snapshots: 0 total
Time: 2.944 s, estimated 3 s
応用
非同期関数や例外発生時のテストはネット上に記事が豊富なため、ここではそれ以外のケースについて言及します。
戻り値がvoidであることをテスト
MatcherのtoBeUndefinedメソッドを使用します。
expect(await controller.remove('1234567890')).toBeUndefined();
出力されたオブジェクトが、期待されるオブジェクトを部分的に内包していることをテスト
MatcherのobjectContainingメソッドを使用します。
const dto \= generateCreateUserDto();
expect(await controller.create(dto)).toEqual(expect.objectContaining(dto));
E2Eテスト
今までの例ではDBが絡む処理をmockすることによってテストをしていましたが、
更新処理等では実際にDBを使ってテストしたくなるかもしれません。
しかし、テストコードでMySQLのようなDBを使用すると
- テスト実行環境やCIのセットアップの複雑化。
- DBの起動などによるオーバーヘッドの増大。
- Jestはテストを並列実行するためテストデータの不整合が生じる可能性。
など、コストに見合ったリターンが得られないと考え、今回は実際に使用されるMySQLでなくインメモリのSQLiteを使用します。
SQLite用のドライバを追加します。
npm install sqlite3 -D
テストスイートを用意します。
test/users.e2e-spec.ts (抜粋)
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import \* as request from 'supertest';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../src/users/entities/user.entity';
import { UsersModule } from '../src/users/users.module';
import { generateCreateUserDto } from './faker/users-faker';
import { UpdateUserDto } from '../src/users/dto/update-user.dto';
describe('UserController (e2e)', () \=> {
let app: INestApplication;
let moduleFixture: TestingModule;
beforeEach(async () \=> {
moduleFixture \= await Test.createTestingModule({
imports: \[
UsersModule,
// TypeORMでSQLiteを使用するよう設定
TypeOrmModule.forRoot({
type: 'sqlite',
database: process.env.DB\_FILENAME,
entities: \[User\],
synchronize: true,
}),
\],
}).compile();
// Nestアプリケーションの起動
app \= moduleFixture.createNestApplication();
await app.init();
});
// ユーザー登録~更新のテストスイートを実行
it('can register and update specific user', async () \=> {
const createRes: { body: User } \= await request(app.getHttpServer())
.post('/users')
.send(generateCreateUserDto())
.expect(201);
const created \= createRes.body;
const updateData: UpdateUserDto \= {
firstName: 'modified first name',
lastName: 'modified last name',
isActive: false,
};
const putRes: { body: User } \= await request(app.getHttpServer())
.put(\`/users/${created.id}\`)
.send(updateData)
.expect(200);
expect(putRes.body).toEqual(expect.objectContaining(updateData));
});
// テストで起動したNestアプリを終了しないとJestで警告が発生するため、以下のコードで終了
afterEach(async () \=> {
await app.close();
await moduleFixture.close();
});
});
E2Eテストを実行します。
/backend $ npm run test:e2e ./test/users.e2e-spec.ts
> nest-js-sample@0.0.1 test:e2e
> NODE\_ENV=test jest --config ./test/jest-e2e.json "./test/users.e2e-spec.ts"
PASS test/users.e2e-spec.ts
UserController (e2e)
✓ can get user has specific id (192 ms)
✓ should return 404 if specified id does not exist. (13 ms)
✓ can persist one user (14 ms)
✓ can persist multiple users (21 ms)
✓ can register and update specific user (15 ms)
✓ should return 404 if specified update target id does not exist. (12 ms)
✓ can delete specific user (14 ms)
✓ should return 404 if specified delete target id does not exist. (10 ms)
Test Suites: 1 passed, 1 total
Tests: 8 passed, 8 total
Snapshots: 0 total
Time: 3.019 s, estimated 4 s
またテストケース数が増大した場合の実行速度を検証したいので、テストケースをコピペで増やし再度実行します。
Test Suites: 101 passed, 101 total
Tests: 808 passed, 808 total
Snapshots: 0 total
Time: 59.737 s
今回はユーザーのCRUD機能のみの実装ですが、DBにアクセスするテストが808個ある場合でも約1分でテストすることができました。
開発が進むにつれ、マイグレーション時間が増大する場合は、BeforeAllメソッドでSQLiteへ接続しマイグレーション、BeforeEach、AfterEachメソッドでテストデータの前処理、後処理等を実装するなど調整が必要になると考えられます。
この場合については機会を探し、新たな記事を共有したいと思います。
終わりに
今回はNestJSでJestを使用した際、運用可能な形でテストコードを整備する方法を試行錯誤しました。
実際にDBを使用するテストはどうしても実行速度が遅くなってしまうため、テストコードは基本的にUnitテストを書き、必要であればDBを用いたE2Eテストを行うほうが持続的にテストコードを整備しやすいと感じました。
この記事についてご意見等有りましたらぜひいいね・コメント等お願いいたします。
おまけ
今回使用したプロジェクトのリポジトリ
https://github.com/dip-yasuaki-watanabe/nestjs-typeorm-jest-sample
Discussion