AdonisJS(アドニスJS)でスタブを使ったユニットテストをやってみた

# はじめに
[前回の記事](https://qiita.com/namasa/items/8bc15ef73780ca7c2355)でユニットテストを試してみましたが、今回はAdonisJSでスタブを作ったユニットテストを試してみることにします。

まず、なぜスタブを使う必要になるかですが、以下のような管理者を登録するユースケースクラスがあるとします。
処理としては、以下のようになります。
- 管理者テーブルから`username`をキーにデータを取得する
  - すでに、同名の`username`の管理者データがあればエラー
- ロールテーブルから`id`をキーにデータを取得する
  - ロールデータが存在しない場合はエラー
- ユーザが入力したパスワードをハッシュ化
- 管理者テーブルにデータを新規登録 

```typescript:HandleStoreAdministratorUseCase.ts
import Administrator from '#Entities/Administrator';
import AdministratorRepository from '#Repositories/AdministratorRepository';
import RoleRepository from '#Repositories/RoleRepository';
import HandleStoreAdministratorCommand from '#Commands/Administrator/HandleStoreAdministratorCommand';
import hash from '@adonisjs/core/services/hash';
import { inject } from '@adonisjs/core';

@inject()
export default class HandleStoreAdministratorUseCase {

  constructor(
    private administratorRepository: AdministratorRepository,
    private roleRepository: RoleRepository,
  ) {}

  public async execute(command: HandleStoreAdministratorCommand): Promise<Administrator> {
    const { username, password, name, roleId } = command;

    const existingUsername = await this.administratorRepository.findByUsername(username);
    if (existingUsername) {
      throw new Error('Administrator already exists');
    }
    const role = await this.roleRepository.findById(roleId);
    if (!role) {
      throw new Error('Role is not exists');
    }

    const hashedPassword = await hash.make(password);
    const admin = Administrator.create(username, hashedPassword, name, role);
    await this.administratorRepository.create(admin);
    return admin;
  }
}
```

データを取得・登録するのでDBへ接続することになるのですが、テスト実行時にDBや外部サービスへ繋いでしまうと、以下のような問題が発生します。

- テスト実行が遅くなる
- テストデータの準備が大変
- DBのデータに依存するので、(開発しながらテストをしていると)正常にテストが動かなくなるかもしれない
- DBのデータが書き換わってしまうので、開発に影響が出てしまうかもしれない
- 外部サービスが何かしらの問題で落ちていたら、テストが失敗してしまう

こういった問題へ対応するために、多くのテストフレームワークにはスタブ/モック機能が存在します。
これらを使うことによって、メソッドの挙動やレスポンスなどを任意の値に固定することが出来るようになります。

:::note info
DBやAWSなど外部サービスに接続するテストを書いたら、1つのクラスやメソッドのテストコードだとしても、それはユニットテストではなくなるらしいです:thinking: 
:::

# やってみた
では、実際に試してみます。
今回は、以下の3パターンを実装します。
- 正常系：管理者登録成功
- 異常系：既に存在する管理者のためエラー
- 異常系：ロールが存在しないためエラー

また、スタブにはAdonisJS推奨の[sinon](https://sinonjs.org/)という`スタブやモック`に特化したライブラリを使っていきます。

## テストファイルの作成
```typescript:HandleStoreAdministratorUseCase.spec.ts
import { test } from '@japa/runner';
import sinon from 'sinon';
import AdministratorRepository from '@mypackages/core/Repositories/AdministratorRepository';
import RoleRepository from '@mypackages/core/Repositories/RoleRepository';
import HandleStoreAdministratorUseCase from '@mypackages/core/UseCases/Administrator/HandleStoreAdministratorUseCase';
import Role from '@mypackages/core/Entities/Role';
import Administrator from '@mypackages/core/Entities/Administrator';
import hash from '@adonisjs/core/services/hash';

test.group('管理者登録ユースケース', (group) => {
  let administratorRepository: AdministratorRepository;
  let roleRepository: RoleRepository;
  let useCase: HandleStoreAdministratorUseCase;
  let sandbox: sinon.SinonSandbox;

  group.each.setup(() => {
    sandbox = sinon.createSandbox();

    // リポジトリのスタブ作成
    administratorRepository = {} as AdministratorRepository;
    roleRepository = {} as RoleRepository;

    // メソッドのスタブ化
    administratorRepository.findByUsername = sandbox.stub();
    administratorRepository.create = sandbox.stub();
    roleRepository.findById = sandbox.stub();

    // ユースケースのインスタンス作成
    useCase = new HandleStoreAdministratorUseCase(
      administratorRepository,
      roleRepository
    );
  })

  // 各テストの後に実行
  group.each.teardown(() => {
    sandbox.restore();
  });
  test('【正常系】管理者登録成功', async ({ assert }) => {
    // テストデータ
    const username = 'testadmin';
    const password = 'password123';
    const hashedPassword = 'hashed_password123';
    const name = 'Test Admin';
    const roleId = '1';

    const role = Role.create('admin', 'Administrator Role', []);
    const administrator = Administrator.create(username, hashedPassword, name, role);

    // スタブの設定
    (administratorRepository.findByUsername as sinon.SinonStub)
      .withArgs(username)
      .resolves(null);

    (roleRepository.findById as sinon.SinonStub)
      .withArgs(roleId)
      .resolves(role);

    (administratorRepository.create as sinon.SinonStub)
      .resolves();

    const hashStub = sandbox.stub(hash, 'make').resolves(hashedPassword);

    const command = {
      username: username,
      password: password,
      name: name,
      roleId: roleId,
    }
    const result = await useCase.execute(command);

    // 返り値確認
    assert.instanceOf(result, Administrator);
    assert.deepEqual(result, administrator);

    // スタブの呼び出し確認
    sinon.assert.calledOnceWithExactly(
      administratorRepository.findByUsername as sinon.SinonStub,
      username,
    );
    sinon.assert.calledOnceWithExactly(
      roleRepository.findById as sinon.SinonStub,
      roleId,
    );
    sinon.assert.calledOnceWithExactly(hashStub, password);
    sinon.assert.calledOnceWithExactly(
      administratorRepository.create as sinon.SinonStub,
      administrator,
    );
  });
  test('【異常系】管理者が存在する', async ({ assert }) => {
    // テストデータ
    const username = 'testadmin';
    const password = 'password123';
    const hashedPassword = 'hashed_password123';
    const name = 'Test Admin';
    const roleId = '1';

    const role = Role.create('admin', 'Administrator Role', []);
    const administrator = Administrator.create(username, hashedPassword, name, role);

    // スタブの設定
    (administratorRepository.findByUsername as sinon.SinonStub)
      .withArgs(username)
      .resolves(administrator);

    const command = {
      username: username,
      password: password,
      name: name,
      roleId: roleId,
    }

    // 返り値確認
    await assert.rejects(
      async () => await useCase.execute(command),
      'Administrator already exists'
    );

    // スタブの呼び出し確認
    sinon.assert.calledOnceWithExactly(
      administratorRepository.findByUsername as sinon.SinonStub,
      username,
    );
  });
  test('【異常系】ロールが存在しない', async ({ assert }) => {
    // テストデータ
    const username = 'testadmin';
    const password = 'password123';
    const name = 'Test Admin';
    const roleId = '1';

    // スタブの設定
    (administratorRepository.findByUsername as sinon.SinonStub)
      .withArgs(username)
      .resolves(null);

    (roleRepository.findById as sinon.SinonStub)
      .withArgs(roleId)
      .resolves(null);

    const command = {
      username: username,
      password: password,
      name: name,
      roleId: roleId,
    }

    // 返り値確認
    await assert.rejects(
      async () => await useCase.execute(command),
      'Role is not exists'
    );

    // スタブの呼び出し確認
    sinon.assert.calledOnceWithExactly(
      administratorRepository.findByUsername as sinon.SinonStub,
      username,
    );
    sinon.assert.calledOnceWithExactly(
      roleRepository.findById as sinon.SinonStub,
      roleId
    );
  });
});
```

下記のように、
リポジトリクラスの任意のメソッドをスタブ化し、特定の引数を渡した場合の返却値を固定化(`username`を渡したら、nullを返却)できます。
また、`calledOnceWithExactly`でメソッドを呼ばれているかも確認できます。

```typescript
    administratorRepository = {} as AdministratorRepository;
    administratorRepository.findByUsername = sandbox.stub();
    (administratorRepository.findByUsername as sinon.SinonStub)
      .withArgs(username)
      .resolves(null);

    sinon.assert.calledOnceWithExactly(
      administratorRepository.findByUsername as sinon.SinonStub,
      username,
    );
```

## 実行してみる
実行してみると、全てのテストが成功しました:smile:
```
npm run test

[ info ] booting application to run tests...
test start

unit / 管理者登録ユースケース (tests/unit/app/HandleStoreAdministratorUseCase.spec.ts)
  ✔ 【正常系】管理者登録成功 (7ms)
  ✔ 【異常系】管理者が存在する (1.71ms)
  ✔ 【異常系】ロールが存在しない (1.96ms)
test end

 PASSED 

Tests  3 passed (3)
 Time  112ms
```



# おわりに

今回は、スタブを使ったユニットテストを試してみました。
もちろん実際にDBなどに繋いだテスト(インテグレーションテスト)をしたほうが、`忠実性`は高くなりますが、ユニットテストは`速度と決定性`が重要視されるので、必ず使ったほうが良さそうです！

次回はDB接続をしてテストを行う、**インテグレーションテスト**を試してみようと思います:smile: