🔧

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

に公開

バイトルPRO開発課の渡邉(@y640drums)です。

今回は社内で導入され始めたNestJSというFWで自動テストの実施方法について試行錯誤したので、そのときに得た知見を共有します。

前提

今回はJestNestJS 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へ接続しマイグレーション、BeforeEachAfterEachメソッドでテストデータの前処理、後処理等を実装するなど調整が必要になると考えられます。

この場合については機会を探し、新たな記事を共有したいと思います。

終わりに

今回はNestJSでJestを使用した際、運用可能な形でテストコードを整備する方法を試行錯誤しました。

実際にDBを使用するテストはどうしても実行速度が遅くなってしまうため、テストコードは基本的にUnitテストを書き、必要であればDBを用いたE2Eテストを行うほうが持続的にテストコードを整備しやすいと感じました。

この記事についてご意見等有りましたらぜひいいね・コメント等お願いいたします。

おまけ

今回使用したプロジェクトのリポジトリ
https://github.com/dip-yasuaki-watanabe/nestjs-typeorm-jest-sample

dipテックブログ

Discussion