コンテンツにスキップ

NestJS でテストを行う

NestJS は Jest でコードを書いてテストを行うらしい。

Jest ではモックを使うことができる。Controllerをテストする際は、Serviceをモックに置き換えて単体テストし、Serviceをテストする際は、Repositoryをモックに置き換えて単体テストするようだ。

NestJS でモックをつかってテストする #初心者 - Qiita

[Express, NestJS 対応] バックエンドのテスト ~ユニットテスト編~

Jest におけるモックの作り方は以下が参考になる。

モック関数 · Jest

以下のように Controller が Service に依存していて、Service が TypeORM を使った Repository に依存している場合を考える。

uml diagram

Controller をテストする

UsersController の単体テストをする場合、UsersController が依存している UsersService をモックに差し替えてテストすることになる。

NestJS のテストコードではテスト対象のクラスと、テスト対象のクラスが依存しているクラスを定義したモジュールというものを作成する必要がある。

UsersService のモックを作る

src/users/users.controller.spec.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { HttpException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { User } from "./entities/user.entity";
import { UserNotFoundError } from "./errors/user-not-found-error";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

// UsersService の持つメソッドを羅列する。
const mockUsersService = {
  create: jest.fn(),
  findAll: jest.fn(),
  findOne: jest.fn(),
  update: jest.fn(),
  remove: jest.fn(),
};

describe("UsersController", () => {
  let controller: UsersController;

  // 各it()を実行する前に実行される関数
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [
        UsersController, // テスト対象
      ],
      providers: [
        // UsersServiceをmockUsersServiceに置き換える
        { provide: UsersService, useValue: mockUsersService },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);

    // spyOn()をクリアする
    jest.resetAllMocks();
  });

  // 以下にit()でテストコードを書く
});

単純なモックメソッドを作成する

src/users/users.controller.spec.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 略
describe("UsersController", () => {
  // 略
  it("create()のテスト (正常)", async () => {
    const user: User = {
      id: "exampleId",
      name: "exampleUser",
    };

    // モックのcreate()の返り値を設定する
    const spy = jest.spyOn(mockUsersService, "create").mockResolvedValue(user);

    // テスト対象のメソッドをコールして、戻り値を確認する
    expect(await controller.create({ name: "exampleUser" })).toEqual(user);

    // モックのメソッドが呼ばれたか確認する
    expect(spy).toHaveBeenCalled();
  });
});

例外を確認する

src/users/users.controller.spec.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 略
describe("UsersController", () => {
  // 略
  it("create()のテスト (データベースが異常)", async () => {
    // モックのメソッド内で例外を返す
    const spy = jest
      .spyOn(mockUsersService, "create")
      .mockRejectedValue(new Error());

    // awaitはつけずrejects.toThrow()でチェックする
    expect(controller.create({ name: "exampleUser" })).rejects.toThrow(
      new HttpException("Internal server error", 500)
    );
    expect(spy).toHaveBeenCalled();
  });
});

Service をテストする

UsersService の単体テストをする場合、UsersService が参照している Repository をモックに差し替えてテストすることになる。(UsersService はテスト前でバグがあるかもしれないコードであるから、本番環境のデータベースに接続したくないはずだ)

やり方は Controller と同じ。

src/users/users.service.spec.ts
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from "@nestjs/typeorm";
import { User } from "./entities/user.entity";
import { UserNotFoundError } from "./errors/user-not-found-error";
import { UsersService } from "./users.service";

const mockUsersRepository = {
  save: jest.fn(),
  find: jest.fn(),
  findAll: jest.fn(),
  findOneBy: jest.fn(),
  update: jest.fn(),
  delete: jest.fn(),
};

describe("UsersService", () => {
  let service: UsersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService, // テスト対象のService
        { provide: getRepositoryToken(User), useValue: mockUsersRepository },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);

    jest.resetAllMocks();
  });

  it("create()のテスト", async () => {
    const spy = jest.spyOn(mockUsersRepository, "save");

    // テスト対象のメソッドをコールする
    const result = await service.create({ name: "exampleUser" });

    // Repositoryをコールしているかを確認する
    expect(spy).toHaveBeenCalled();

    // 戻り値チェック
    expect(result.id).toBeDefined();
    expect(result.name).toBe("exampleUser");
  });

  it("findAll()のテスト", async () => {
    const spy = jest.spyOn(mockUsersRepository, "find").mockResolvedValue([]);
    // テスト対象のメソッドをコールする
    const result = await service.findAll();
    // Repositoryをコールしているかを確認する
    expect(spy).toHaveBeenCalled();
    // 戻り値チェック
    expect(result).toEqual([]);
    spy.mockRestore();
  });

  it("findOne()のテスト (正常)", async () => {
    const user: User = {
      id: "54b4b67f-f84f-4371-8928-089803113201",
      name: "exampleUser",
    };
    const spy = jest
      .spyOn(mockUsersRepository, "findOneBy")
      .mockResolvedValue(user);
    // テスト対象のメソッドをコールする
    const result = await service.findOne(user.id);
    // Repositoryをコールしているかを確認する
    expect(spy).toHaveBeenCalled();
    // 戻り値チェック
    expect(result).toEqual(user);
  });

  it("findOne()のテスト (ユーザーIDが存在しない)", async () => {
    const spy = jest
      .spyOn(mockUsersRepository, "findOneBy")
      .mockRejectedValue(new UserNotFoundError());

    expect(service.findOne("exampleId")).rejects.toThrow(
      new UserNotFoundError()
    );
    expect(spy).toHaveBeenCalled();
  });

  it("update()のテスト (正常)", async () => {
    const user: User = {
      id: "54b4b67f-f84f-4371-8928-089803113201",
      name: "exampleUser",
    };

    const spyFindOneBy = jest
      .spyOn(mockUsersRepository, "findOneBy")
      .mockResolvedValue(user);

    const spyUpdate = jest.spyOn(mockUsersRepository, "update");

    await service.update(user.id, { name: user.name });

    expect(spyFindOneBy).toHaveBeenCalled();
    expect(spyUpdate).toHaveBeenCalled();
  });

  it("update()のテスト (ユーザーIDが存在しない)", async () => {
    const spy = jest
      .spyOn(mockUsersRepository, "findOneBy")
      .mockRejectedValue(new UserNotFoundError());

    expect(
      service.update("exampleId", { name: "exampleName" })
    ).rejects.toThrow(new UserNotFoundError());
    expect(spy).toHaveBeenCalled();
  });

  it("remove()のテスト (正常)", async () => {
    const user: User = {
      id: "54b4b67f-f84f-4371-8928-089803113201",
      name: "exampleUser",
    };

    const spyFindOneBy = jest
      .spyOn(mockUsersRepository, "findOneBy")
      .mockResolvedValue(user);

    const spyDelete = jest.spyOn(mockUsersRepository, "delete");

    await service.remove(user.id);

    expect(spyFindOneBy).toHaveBeenCalled();
    expect(spyDelete).toHaveBeenCalled();
  });

  it("remove()のテスト (ユーザーIDが存在しない)", async () => {
    const spy = jest
      .spyOn(mockUsersRepository, "findOneBy")
      .mockRejectedValue(new UserNotFoundError());

    expect(service.remove("exampleId")).rejects.toThrow(
      new UserNotFoundError()
    );
    expect(spy).toHaveBeenCalled();
  });
});

テストが動かない

constructor で async 関数を動かすとテストが動いたり動かなかったりした。テストのセットアップ中に非同期処理が動くせいでうまくいかない可能性がある。

constructor で async 関数を動かすのを止めると解決した。

テストコードの src から始まる import が通らない

【Jest】テストファイルから module を絶対パスで import したときのエラー: Cannot find module 'src/...' の解決 #React - Qiita

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ npx jest ./src/todos/todos.service.spec.ts
 FAIL  src/todos/todos.service.spec.ts
   Test suite failed to run

    Cannot find module 'src/users/errors/user-not-found-error' from 'todos/todos.service.spec.ts'

      2 | import { CreateUserDto } from 'src/users/dto/create-user.dto';
      3 | import { User } from 'src/users/entities/user.entity';
    > 4 | import { UserNotFoundError } from 'src/users/errors/user-not-found-error';
        | ^
      5 | import { IUsersService } from 'src/users/users.service';
      6 | import { TodosService } from './todos.service';
      7 |

      at Resolver._throwModNotFoundError (../node_modules/jest-resolve/build/resolver.js:427:11)
      at Object.<anonymous> (todos/todos.service.spec.ts:4:1)

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        1.32 s
Ran all test suites matching /.\/src\/todos\/todos.service.spec.ts/i.

package.jsonに以下をマージする。

package.json
1
2
3
4
5
6
7
{
  "jest": {
    "moduleNameMapper": {
      "src(.*)$": "<rootDir>/$1"
    }
  }
}

カバレッジを確認する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
$ npm run test:cov # npx jest --coverageと同義
# 略
--------------------------|---------|----------|---------|---------|----------------------------
File                      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------------|---------|----------|---------|---------|----------------------------
All files                 |   61.08 |    57.14 |   57.57 |   59.79 |
 src                      |    32.5 |        0 |      50 |   26.47 |
  app.controller.ts       |     100 |      100 |     100 |     100 |
  app.module.ts           |       0 |        0 |       0 |       0 | 1-39
  app.service.ts          |     100 |      100 |     100 |     100 |
  main.ts                 |       0 |      100 |       0 |       0 | 1-11
 src/todos                |    37.8 |     37.5 |   21.42 |   35.52 |
  todos.controller.ts     |   57.89 |       60 |   42.85 |   55.55 | 35-41,47-50,59-63,70-73,79
  todos.module.ts         |       0 |      100 |     100 |       0 | 1-13
  todos.service.ts        |   25.71 |        0 |       0 |   21.21 | 14-78
 src/todos/dto            |     100 |      100 |     100 |     100 |
  create-todo.dto.ts      |     100 |      100 |     100 |     100 |
  update-todo.dto.ts      |     100 |      100 |     100 |     100 |
 src/todos/entities       |     100 |      100 |     100 |     100 |
  todo.entity.ts          |     100 |      100 |     100 |     100 |
 src/todos/errors         |     100 |      100 |     100 |     100 |
  todo-not-found-error.ts |     100 |      100 |     100 |     100 |
 src/users                |   87.69 |      100 |     100 |   89.83 |
  users.controller.ts     |     100 |      100 |     100 |     100 |
  users.module.ts         |       0 |      100 |     100 |       0 | 1-13
  users.service.ts        |     100 |      100 |     100 |     100 |
 src/users/dto            |     100 |      100 |     100 |     100 |
  create-user.dto.ts      |     100 |      100 |     100 |     100 |
  update-user.dto.ts      |     100 |      100 |     100 |     100 |
 src/users/entities       |     100 |      100 |     100 |     100 |
  user.entity.ts          |     100 |      100 |     100 |     100 |
 src/users/errors         |     100 |      100 |     100 |     100 |
  user-not-found-error.ts |     100 |      100 |     100 |     100 |
--------------------------|---------|----------|---------|---------|----------------------------

コマンド実行後coverageディレクトリが生成される。HTML から棒グラフでカバレッジを確認する事もできる。

単体テストでは Module はテストしない。