【Firebase】Firebase Functionsのテストを書く時に、DIするServiceクラスの内容によってテストの内容を切り替えたい場合のTips

# DIしたいインターフェースの内容

テストを行いたい呼び出し可能関数が、次のインターフェースを実装したクラスに依存しているとします。

```typescript
import type { User } from "~/domain/model/User";

export interface UserService {
  updateEmail: (uid: string, email: string) => Promise<User | undefined>;
  updateName: (uid: string, name: string) => Promise<User | undefined>;
}

```

この時に、`updateEmail()`メソッドの返り値によって呼び出し可能関数の結果が変わるとします。

```typescript
import { container } from 'tsyringe';
...

export const updateEmail: CallableMethod = async (
  data,
  context,
): Promise<UpdateEmailResponseData> => {
  const userService = container.resolve<UserService>('UserService');

  const result = await userService.updateEmail(uid, newEmail);

  if (!result) {
    throw new functions.https.HttpsError('internal', 'メールアドレスの更新を行うことができません');
  }

  return {
    newEmail: newEmail,
  };
}
```

# 偽の実装クラスの作成

この場合、`UserService`インターフェースのクラスをテストごとに何度も実装するのを避けるため、偽の実装クラスを作成します。

```typescript
import type { UserService } from "../UserService";

import { User } from "~/domain/model/User";
import type { User as UserModel } from '~/infrastructure/database/model/User';

const userModel: UserModel = {
    ...
};

export class FakeUserServiceImpl implements UserService {
  async updateEmail(uid: string, email: string): Promise<User | undefined> {
    const user: User = new User(userModel);
    return user;
  }

  async updateName(uid: string, name: string): Promise<User | undefined> {
    const user: User = new User(userModel);
    return user
  }
}
```

# 成功用のテストを実装

そして、成功用のテストを実装します。

```typescript
import 'jest';
import 'reflect-metadata';
import firebaseFunctionsTest from 'firebase-functions-test';
import * as firebaseFunctions from 'firebase-functions';

const test = firebaseFunctionsTest();

import { updateEmail } from '~/routes/staymile/tab-four/change-email/updateEmail';
import { container } from 'tsyringe';
import { UserService } from '~/application/service/UserService';
import { FakeUserServiceImpl } from '~/application/service/impl/FakeUserServiceImpl';

describe('updateEmail', () => {
  it('成功するテスト', async () => {
    const uid = 'user-id';
    const newEmail = 'newemail@example.com';

    container.register<UserService>('UserService', {
      useClass: FakeUserServiceImpl,
    });
    const wrapped = test.wrap(firebaseFunctions.https.onCall(updateEmail));
    const result = await wrapped(
      {
        newEmail: newEmail,
      },
      {
        auth: {
          uid: uid,
        },
      },
    );

    expect(result.newEmail).toBe(newEmail);
  });
}
```

次に失敗するテストを書きたいので、`updateEmail()`メソッドが`undefined`を返すようにしたいです。そのため、新たに偽のクラスを作成するという方法もあるのですが、あまりに冗長です。

# 偽のクラスの再実装を防ぐ

そこで、偽の実装クラスの再生成を防ぐために`jest.spyOn`を使って、偽クラスのメソッドの返り値を置き換えることにします。

```typescript
jest.spyOn(FakeUserServiceImpl.prototype, 'updateEmail').mockResolvedValue(undefined);
```

# 失敗テストの作成

これにより`updateName()`メソッドが`undefined`を返した場合に、例外が発生することをテストすることができるようになりました。

```typescript
it('メールアドレスの変更に失敗した場合はエラーを発生させる', async () => {
    jest.spyOn(FakeUserServiceImpl.prototype, 'updateEmail').mockResolvedValue(undefined);

    const uid = 'user-id';
    const newEmail = 'newemail@example.com';

    container.register<UserService>('UserService', {
      useClass: FakeUserServiceImpl,
    });
    const wrapped = test.wrap(firebaseFunctions.https.onCall(updateEmail));
    expect(
      wrapped(
        {
          newEmail: newEmail,
        },
        {
          auth: {
            uid: uid,
          },
        },
      ),
    ).rejects.toThrowError();
  });
```

# 参考にした記事

https://naokirin.hatenablog.com/entry/2022/02/23/180811

https://qiita.com/yuma-ito-bd/items/38c929eb5cccf7ce501e