コンテンツにスキップ

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はテストしない。