😽

ユニットテストでInfrastructure層をモックする方法を比較した

2022/08/05に公開

概要

ユニットテストでは、実行速度観点からも Infrastructure 層はモックすることが多いでしょう。
モックにもいくつか方法があり、テストライブラリでモックする方法やテスト用のクラスを使用して差し替えるなどがあります。

そこで、今回は下記 2 つの方法を比較してみます。

  • Jest の spyOn で対象のメソッドをモックする
  • インメモリを使ったテスト用の Repository を使う

内容

前提

  • TypeScript でのコード
  • テストライブラリは Jest を使用

テスト対象コード

今回はユーザ情報を更新するユースケースを例に考えていきます。

具体的な実装は下記になります。

  • User Class
    • ユーザのドメインモデル
  • UserRepository Interface
    • ユーザを永続化するための Repository のインターフェース
  • UserMySQLRepository Class
    • MySQL で永続化する Repository の実装
  • UpdateUser Class
    • ユーザの更新する UseCase
// ユーザのドメインモデル
export class User {
  constructor(public id: string, public name: string, public email: string) {}

  changeName(name: string): User {
    return new User(this.id, name, this.email);
  }

  changeEmail(email: string): User {
    return new User(this.id, this.name, email);
  }
}

// ユーザを永続化するための Repository のインターフェース
export interface UserRepository {
  findById(id: string): Promise<User | undefined>;
  update(user: User): Promise<void>;
}

// MySQL に永続化するための Repository の実装
export class UserMySQLRepository implements UserRepository {
  findById(_id: string): Promise<User | undefined> {
    // MySQLのデータベースからユーザを取得する
    throw new Error('Method not implemented.');
  }
  update(_user: User): Promise<void> {
    // MySQLのデータベースにユーザを更新する
    throw new Error('Method not implemented.');
  }
}

// ユーザ情報を更新するユースケース
export class UpdateUser {
  constructor(private userRepository: UserRepository) {}

  async execute(userId: string, updateParam: { name: string; email: string }): Promise<void> {
    const user = await this.userRepository.findById(userId);

    if (!user) {
      throw new Error('ユーザーが存在しません');
    }

    const updatedUser = user.changeName(updateParam.name).changeEmail(updateParam.email);

    await this.userRepository.update(updatedUser);
  }
}

テストコード

UpdateUser にテストを書きます。実装から下記 2 つのケースをテストします。

  • userId に該当する User が存在しない場合、エラーになること
  • 正常に名前とメールアドレスを更新すること

spyOn で対象のメソッドをモックする

Jest では対象メソッドをモックするための spyOn があるので、それを使ってテストします。

describe('UpdateUser', () => {
  it('`userId` に該当する User が存在しない場合、エラーになる', async () => {
    const userRepository = new UserMySQLRepository();
    jest.spyOn(userRepository, 'findById').mockImplementation(async (_user: string) => {
      return undefined;
    });
    const useCase = new UpdateUser(userRepository);

    await expect(useCase.execute('1', { name: '太郎', email: 'taro@example.com' })).rejects.toThrow(Error);
  });

  it('名前とメールアドレスを更新する', async () => {
    const userRepository = new UserMySQLRepository();
    jest.spyOn(userRepository, 'findById').mockImplementation(async (_user: string) => {
      return new User('1', '太郎', 'taro@example.com');
    });
    const updateSpy = jest.spyOn(userRepository, 'update').mockResolvedValue();
    const useCase = new UpdateUser(userRepository);

    await useCase.execute('1', { name: '太郎太郎', email: 'tarotaro@example.com' });

    expect(updateSpy).toHaveBeenCalled();
    expect(updateSpy).toHaveBeenCalledWith(new User('1', '太郎太郎', 'tarotaro@example.com'));
  });
});

spyOn ではメソッドを実行した際の返り値も指定できるため、実際の挙動を想定した値を設定します。
また、モックを受け取れるため更新したかどうかは update メソッドが呼ばれたか?その際の引数は正しいか?を確認しているのが特徴です。

インメモリを使ったテスト用の Repository を使う

UpdateUser では constructor で UserRepository を DI しているため、実行時に切り替えることができます。
そのため、 UserRepository をインメモリで実装したものを作成し、それを使ってテストを行います。

// インメモリで永続化する Repository
class UserMockRepository implements UserRepository {
  // constructor 時に永続化されているユーザを設定する
  constructor(private users: User[]) {}

  async findById(id: string) {
    return this.users.find((user) => user.id === id);
  }

  async update(user: User): Promise<void> {
    this.users = this.users.reduce((acc, cur) => [...acc, cur.id === user.id ? user : cur], [] as User[]);
    return;
  }
}

describe('UpdateUser', () => {
  it('`userId` に該当する User が存在しない場合、エラーになる', async () => {
    const userRepository = new UserMockRepository([
      new User('1', '太郎', 'taro@example.com'),
      new User('2', '二郎', 'ziro@example.com'),
    ]);
    const useCase = new UpdateUser(userRepository);

    await expect(useCase.execute('3', { name: '三郎', email: 'saro@example.com' })).rejects.toThrow(Error);
  });

  it('名前とメールアドレスを更新する', async () => {
    const userRepository = new UserMockRepository([
      new User('1', '太郎', 'taro@example.com'),
      new User('2', '二郎', 'ziro@example.com'),
    ]);
    const userId = '2';
    const name = '二郎';
    const email = 'ziroziro@example.com';
    const useCase = new UpdateUser(userRepository);

    await useCase.execute(userId, { name, email });

    const updateUser = await userRepository.findById(userId);
    expect(updateUser?.name).toBe(name);
    expect(updateUser?.email).toBe(email);
  });
});

spyOn でモックした際と比較して、こちらでは永続化されているものを constructor 時に定義しています。
また、振る舞いの挙動をテストをしているのも特徴になります。更新されたかに関しては、 findById メソッドを使用して再度取得して確認する形になっています。

まとめ

テストの確認方法が大きく変わるので、どっちを使うは Pros, Cons を比較してプロジェクト毎に方針を決める形になりそうです。個人的には、ユースケースでは振る舞いを確認したいので、インメモリを使ったテスト用の Repository を使う 方が好みです。

spyOn で対象のメソッドをモックする

  • pros
    • 対象のメソッドをモックして、テストで必要な値を定義すれば良い。
    • モックしているため、関数が呼ばれたかや引数が正しいかを確認できる。
    • モック用に新たに実装をする必要がない。
  • cons
    • 振る舞いをテストするのではなく、メソッドを実行したか?その時の引数は正しいか?などの実装の詳細をテストすることになる。
    • モックはテストライブラリに依存する。

インメモリを使ったテスト用の Repository を使う

  • pros
    • 振る舞いの確認をできる。今回だとユーザを更新したことを確認した。
    • モックはテストライブラリに依存しない。
  • cons
    • テスト用に interface を実装したものを作成する必要がある。
    • 実装したものが正しく動いていることを保証する必要がある。

Discussion