【NestJS】ServiceとControllerの単体テストを実装する

#はじめに
新規登録とログインの実装が完了したので、ServiceとContollerで単体テストを実装してみました。

https://qiita.com/suzuki0430/items/d15c8642b46b57cdf91b

https://qiita.com/suzuki0430/items/697601370b078cc65fd6

実装したコードを確認したい方は以下よりご確認ください。

https://github.com/suzuki0430/car-api-nest

#実装
AuthServiceとUsersControllerのテストを実装します。

##AuthService
`auth.service.ts`のテストを行うため、同じディレクトリ階層に`auth.service.spec.ts`を作成します。

テストはAuthServiceのメソッドが正しく動作しているかどうかを確認することが目的です。
そのため、AuthServiceはUsersServiceの`find()` `create()`を使用しているのですが、これらのメソッドが返すデータについてはテスト用のモックで十分です。
`fakeUsersService`として、`find()`と`create()`がテスト用に返すデータを作成します。

次に、テストに使用するServiceを`module`としてまとめます。
作成した`fakeUsersService`については`UsersService`の`useValue`として登録します。

そして、この一連の処理を`beforeEach()`にまとめます。
`beforeEach()`はテストの前に毎回実行される処理となります。

```ts:auth.service.spec.ts
  let service: AuthService;
  let fakeUsersService: Partial<UsersService>;

  beforeEach(async () => {
    const users: User[] = [];
    fakeUsersService = {
      find: (email: string) => {
        const filteredUsers = users.filter((user) => user.email === email);
        return Promise.resolve(filteredUsers);
      },
      create: (email: string, password: string) => {
        const user = {
          id: Math.floor(Math.random() * 999999),
          email,
          password,
        } as User;
        users.push(user);
        return Promise.resolve(user);
      },
    };

    const module = await Test.createTestingModule({
      providers: [
        AuthService,
        {
          provide: UsersService,
          useValue: fakeUsersService,
        },
      ],
    }).compile();

    service = module.get(AuthService);
  });
```

テストケースは何パターンかありますが、1つには「新規登録したときにパスワードがハッシュされているかどうか」を確認するものがあります。
具体的には以下のアサーションを行っています。

- `signup()`で返ってきたデータ`user`の`password`が入力内容`asdf`のままではない
- `password`をsalt部分とhash部分に分けたときにそれぞれに値が入っている

```ts
  it('creates a new user with a salted and hashed password', async () => {
    const user = await service.signup('asdf@asdf.com', 'asdf');

    expect(user.password).not.toEqual('asdf');
    const [salt, hash] = user.password.split('.');
    expect(salt).toBeDefined();
    expect(hash).toBeDefined();
  });
```

また、「間違ったパスワードでログインしたときにエラーが正しく返ってくるかどうか」を確認するテストもあります。
`try~catch`で`signin()`が失敗したときにエラーをキャッチするかどうか、キャッチしたエラー内容`bad password`が正しいかどうかを確認します。

```ts
  it('throws if an invalid password is provided', async () => {
    await service.signup('asdf@asdf.com', 'sddpetf');

    try {
      await service.signin('asdf@asdf.com', 'sddpetf');
    } catch (err) {
      expect(err).toBeInstanceOf(BadRequestException);
      expect(err.message).toBe('bad password');
    }
  });
```
`beforeEach()`や各テスト`it()`はすべて`describe()`にまとめます。
説明を割愛したテストについても記述すると以下のようになります。

```ts:auth.service.spec.ts
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AuthService } from './auth.service';
import { User } from './user.entity';
import { UsersService } from './users.service';

describe('AuthService', () => {
  let service: AuthService;
  let fakeUsersService: Partial<UsersService>;

  beforeEach(async () => {
  ...
  });

  it('can create an instance of auth service', async () => {
    expect(service).toBeDefined();
  });

  it('creates a new user with a salted and hashed password', async () => {
    const user = await service.signup('asdf@asdf.com', 'asdf');

    expect(user.password).not.toEqual('asdf');
    const [salt, hash] = user.password.split('.');
    expect(salt).toBeDefined();
    expect(hash).toBeDefined();
  });

  it('throws an error if user signs up with email that is in use', async () => {
    await service.signup('asdf@asdf.com', 'asdf');

    expect.assertions(2);

    try {
      await service.signup('asdf@asdf.com', 'asdf');
    } catch (err) {
      expect(err).toBeInstanceOf(BadRequestException);
      expect(err.message).toBe('email in use');
    }
  });

  it('throws if signin is called with an unused email', async () => {
    try {
      await service.signin('asdf@asdf.com', 'asdf');
    } catch (err) {
      expect(err).toBeInstanceOf(NotFoundException);
      expect(err.message).toBe('user not found');
    }
  });

  it('throws if an invalid password is provided', async () => {
    await service.signup('asdf@asdf.com', 'sddpetf');

    try {
      await service.signin('asdf@asdf.com', 'sddpetf');
    } catch (err) {
      expect(err).toBeInstanceOf(BadRequestException);
      expect(err.message).toBe('bad password');
    }
  });

  it('returns a user if correct password is provided', async () => {
    await service.signup('asdf@asdf.com', 'mypassword');

    const user = await service.signin('asdf@asdf.com', 'mypassword');
    expect(user).toBeDefined();
  });
});
```

##UsersController
次にControllerのテストを実装します。
やっていることはServiceのテストとほとんど変わりませんが、今回はUsersControllerの動作を確認することが目的なので、使用するAuthServiceのメソッドについても`fakeAuthService`を作成します。

テストケースを1つ紹介すると、例えば最後のテストでは、「ログインしたときにセッションの`userID`が更新されるかどうか」を確認しています。

```ts:users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { AuthService } from './auth.service';
import { User } from './user.entity';
import { NotFoundException } from '@nestjs/common';

describe('UsersController', () => {
  let controller: UsersController;
  let fakeUsersService: Partial<UsersService>;
  let fakeAuthService: Partial<AuthService>;

  beforeEach(async () => {
    fakeUsersService = {
      findOne: (id: number) => {
        return Promise.resolve({
          id,
          email: 'asdf@asdf.com',
          password: 'asdf',
        } as User);
      },
      find: (email: string) => {
        return Promise.resolve([{ id: 1, email, password: 'asdf' } as User]);
      },
      remove: () => {},
      update: () => {},
    };
    fakeAuthService = {
      signup: () => {},
      signin: (email: string, password: string) => {
        return Promise.resolve({ id: 1, email, password } as User);
      },
    };

    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: fakeUsersService,
        },
        {
          provide: AuthService,
          useValue: fakeAuthService,
        },
      ],
    }).compile();

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

  it('should be defined', () => {
    expect(controller).toBeDefined();
  });

  it('findAllUsers returns a list of users with the given email', async () => {
    const users = await controller.findAllUsers('asdf@asdf.com');
    expect(users.length).toEqual(1);
    expect(users[0].email).toEqual('asdf@asdf.com');
  });

  it('findUser returns a single user with the given id', async () => {
    const user = await controller.findUser('1');
    expect(user).toBeDefined();
  });

  it('findUser throws an error if user with given id is not found', async () => {
    fakeUsersService.findOne = () => null;

    try {
      await controller.findUser('1');
    } catch (err) {
      expect(err).toBeInstanceOf(NotFoundException);
      expect(err.message).toBe('user not found');
    }
  });

  it('signin updates session object and returns user', async () => {
    const session = { userId: -10 };
    const user = await controller.signin(
      {
        email: 'asdf@asdf.com',
        password: 'asdf',
      },
      session,
    );

    expect(user.id).toEqual(1);
    expect(session.userId).toEqual(1);
  });
});
```

#参考資料

https://www.udemy.com/share/104SA43@KbgGpVb6kMoi3Lp819HRXD6a9QudBcjd1LUZmtWR6uofO1yLoLJrjqReEVevlYU0/