blog.euxn.me

NestJS でダミーの Service を注入し、外部依存のないテストを実行する

2019-12-04 Wed.

この記事は NestJS アドベントカレンダー 2019 4 日目の記事です。

はじめに

先日は Module と DI について説明しましたが、本日はもう一歩進んだ DI を活用したテストを実施してみます。 なお、サンプルでは MySQL に接続したり Docker を使用したりしていますが、怖がらないでください。 この記事では MySQL や Docker に依存せずにテストできるようにするテクニックを説明します。

サンプルコードのリポジトリは以下になります。

https://github.com/nestjs-jp/advent-calendar-2019/tree/master/day04-inject-dummy-service-to-avoid-external-dependency

なお、環境は執筆時点での Node.js の LTS である v12.13.1 を前提とします。

サンプルアプリの雛形を作る

今回のサンプルとなるアプリケーションの雛形を cli を用いて作ってゆきます。

1$ nest new day4-inject-dummy-service
2$ nest g module items
3$ nest g controller items
4$ nest g service items

ItemsController には以下のように Post と Get を実装していきます。

items/items.controller.ts
1@Controller('items')
2export class ItemsController {
3 constructor(private readonly itemsService: ItemsService) {}
4
5 @Post()
6 async createItem(@Body() { title, body, deletePassword }: CreateItemDTO) {
7 const item = await this.itemsService.createItem(
8 title,
9 body,
10 deletePassword,
11 );
12
13 return item;
14 }
15
16 @Get()
17 async getItems() {
18 const items = await this.itemsService.getItems();
19
20 return items;
21 }
22}

ItemsService も雛形を作成します。

items/items.service.ts
1@Injectable()
2export class ItemsService {
3 async createItem(title: string, body: string, deletePassword: string) {
4 return;
5 }
6
7 async getItems() {
8 return [];
9 }
10}

MySQL にデータを書き込む箇所を実装する

今回は Service の外部依存先として、 MySQL を例にあげます。 MySQL に接続するため、以下のライブラリをインストールします。

1$ yarn add typeorm mysql

なお、今回は TypeORM の複雑な機能は極力使用せずにサンプルを記述します。 TypeORM についての説明や NestJS との組み合わせ方については別の記事で説明します。 また、本来は constructor で非同期の初期化を行うべきではないのですが、回避策は複雑なので、こちらも別途説明します。

items/items.service.ts
1@Injectable()
2export class ItemsService {
3 connection: Connection;
4
5 constructor() {
6 createConnection({
7 type: 'mysql',
8 host: '0.0.0.0',
9 port: 3306,
10 username: 'root',
11 database: 'test',
12 })
13 .then(connection => {
14 this.connection = connection;
15 })
16 .catch(e => {
17 throw e;
18 });
19 }
20
21 // connection が確立していないタイミングがあるため待ち受ける
22 private async waitToConnect() {
23 if (this.connection) {
24 return;
25 }
26 await new Promise(resolve => setTimeout(resolve, 1000));
27 await this.waitToConnect();
28 }
29
30 async createItem(title: string, body: string, deletePassword: string) {
31 if (!this.connection) {
32 await this.waitToConnect();
33 }
34 await this.connection.query(
35 `INSERT INTO items (title, body, deletePassword) VALUE (?, ?, ?)`,
36 [title, body, deletePassword],
37 );
38 }
39
40 async getItems() {
41 if (!this.connection) {
42 await this.waitToConnect();
43 }
44 const rawItems = await this.connection.query('SELECT * FROM items');
45 const items = rawItems.map(rawItem => {
46 const item = { ...rawItem };
47 delete item.deletePassword;
48
49 return item;
50 });
51
52 return items;
53 }
54}

また、 MySQL を Docker で立ち上げます。

1$ docker-compose up

Docker ではない MySQL で実行する場合、 MySQL に test データベースを作り、 create-table.sql を流してください。

この状態でアプリケーションを起動してみましょう。MySQL が起動していれば、無事起動するはずです。

1$ yarn start:dev

続いて curl でアプリケーションの動作確認をしてみます。

1$ curl -XPOST -H 'Content-Type:Application/json' -d '{"title": "hoge", "body": "fuga", "deletePassword": "piyo"}' localhost:3000/items
1$ curl locaohost:3000/items
2[{"title":"hoge","body":"fuga"}]

無事保存できるアプリケーションができました。

MySQL がない状態でもテストできるようにする

アプリケーションができたので、Mock を使ってテストを記述します。

前回までのサンプルでは特に DI を意識する必要がなかったため new ItemsService() としてテストを記述していましたが、 今回は DI に関連するため、 cli で自動生成される雛形にも用いられている Test モジュールを使用します。

1describe("ItemsController", () => {
2 let itemsController: ItemsController;
3 let itemsService: ItemsService;
4
5 beforeEach(async () => {
6 const testingModule: TestingModule = await Test.createTestingModule({
7 imports: [ItemsModule],
8 }).compile();
9
10 itemsService = testingModule.get<ItemsService>(ItemsService);
11 itemsController = new ItemsController(itemsService);
12 });
13
14 describe("/items", () => {
15 it("should return items", async () => {
16 expect(await itemsController.getItems()).toHaveLength(1);
17 });
18 });
19});

さて、この状態でテストを実行するとどうなるでしょうか。 MySQL を起動している場合はそのままテストが通りますが、 MySQL を停止すると以下のようにテストが落ちてしまいます。

$ jest
 PASS  src/app.controller.spec.ts
 FAIL  src/items/items.controller.spec.ts
  ● ItemsController › /items › should return items

    connect ECONNREFUSED 0.0.0.0:3306

          --------------------
      at Protocol.Object.<anonymous>.Protocol._enqueue (../node_modules/mysql/lib/protocol/Protocol.js:144:48)
      at Protocol.handshake (../node_modules/mysql/lib/protocol/Protocol.js:51:23)
      at PoolConnection.connect (../node_modules/mysql/lib/Connection.js:119:18)
      at Pool.Object.<anonymous>.Pool.getConnection (../node_modules/mysql/lib/Pool.js:48:16)
      at driver/mysql/MysqlDriver.ts:869:18
      at MysqlDriver.Object.<anonymous>.MysqlDriver.createPool (driver/mysql/MysqlDriver.ts:866:16)
      at MysqlDriver.<anonymous> (driver/mysql/MysqlDriver.ts:337:36)
      at step (../node_modules/tslib/tslib.js:136:27)
      at Object.next (../node_modules/tslib/tslib.js:117:57)

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.204s, estimated 3s

ItemsService を Mock していますが、 ItemsService の初期化自体はされており、初期化処理の中で MySQL への接続しようとしているのが原因です。 このような、 外部へ依存する Provider の初期化 をテストから除外するために、 ItemsService を上書きした状態で testingModule を生成する機能が NestJS には備わっています。

以下のように DummyItemsService class を定義し、 overrideProvider を使って上書きします。

1class DummyItemsService {
2 async createItem(title: string, body: string, deletePassword: string) {
3 return;
4 }
5 async getItems() {
6 const item = {
7 id: 1,
8 title: "Dummy Title",
9 body: "Dummy Body",
10 };
11 return [item];
12 }
13}
14
15describe("ItemsController", () => {
16 let itemsController: ItemsController;
17 let itemsService: ItemsService;
18
19 beforeEach(async () => {
20 const testingModule: TestingModule = await Test.createTestingModule({
21 imports: [ItemsModule],
22 })
23 .overrideProvider(ItemsService)
24 .useClass(DummyItemsService)
25 .compile();
26
27 itemsService = testingModule.get<ItemsService>(ItemsService);
28 itemsController = new ItemsController(itemsService);
29 });
30
31 describe("/items", () => {
32 it("should return items", async () => {
33 expect(await itemsController.getItems()).toHaveLength(1);
34 });
35 });
36});

useClass() の代わりに useValue() を使うことで、 class ではなく変数で上書きすることもできます。

この状態でテストを実行すると、 MySQL が起動していなくても問題なく通過します。

yarn run v1.19.0
$ jest
 PASS  src/items/items.controller.spec.ts
 PASS  src/app.controller.spec.ts

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.406s
Ran all test suites.
✨  Done in 2.94s.

おわりに

この記事で NestJS の持つ強力な DI の機能をお伝えできたかと思います。 より詳細な内容は公式のドキュメントの E2E テストの項にあるので、合わせてご確認ください。 https://docs.nestjs.com/fundamentals/testing#end-to-end-testing

また、今回説明できなかった TypeORM との合わせ方や、非同期の初期化を必要とする Service の扱い方については、後日別の記事で説明します。

明日は @potato4d さんが ExceptionFilter についてお話する予定です。