dip Engineer Blog

Engineer Blog
ディップ株式会社のエンジニアによる技術ブログです。
弊社はバイトル・はたらこねっとなど様々なサービスを運営しています。

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