バイトル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