TypeScript: Jest でインターフェースを元にモックする書き方

TypeScript: Jest でインターフェースを元にモックする書き方

June 17, 2023

全体像 #

こんな感じで、Entity、Repository(のインターフェース)、UseCase があるとします。

// This file is "entity.ts"

export default class UserEntity {
  public readonly id: string;
  public name: string;

  private constructor(params: { id: string; name: string }) {
    this.id = params.id;
    this.name = params.name;
  }

  public updateName(name: string): void {
    this.name = name;
  }
}
// This file is "repository.ts"

import UserEntity from './entity.ts';

export default interface IUserRepository {
  save(data: UserEntity): Promise<void>;
  find(id: string): Promise<UserEntity>;
  delete(id: string): Promise<void>;
}
// This file is "use-case.ts"

import type IUserRepository from './repository.ts';

export default class UserUpdateUseCase {
  public constructor(private readonly userRepository: IUserRepository) {}

  public async run(params: { userId: string; name: string }): Promise<void> {
    const user = userRepository.find(params.userId);

    user.updateName(params.name);

    await this.userRepository.save(user);
  }
}

そして、このユースケースを Jest でテストしようとすると概ね以下のようになります。

// This is a test file.

import { describe, expect, jest, test } from '@jest/globals';
import { mock } from 'jest-mock-extended';
import UserEntity from './entity.ts';
import UserUpdateUseCase from './use-case.ts';
import type IUserRepository from './repository.ts';

describe('UserUpdateUseCase のテスト', () => {
  const userRepository = jest.mocked(
    mock<IUserRepository>({
      find: jest.fn(() => Promise.resolve(new UserEntity({ id: '123', name: 'my old name' }))),
      save: jest.fn(() => Promise.resolve()),
    })
  );

  const useCase = new UserUpdateUseCase(userRepository);
  const params = { userId: '123', name: 'my new name' };

  test('userRepository の save メソッドが 1 回実行され、名前が更新された User が渡されている。', async () => {
    await useCase.run(params);

    const calledCount = userRepository.save.mock.calls.length;
    expect(calledCount).toBe(1);

    const args = userRepository.save.mock.calls[0]![0];
    expect(args.name).toBe(params.name);
  });
});

悩み #

このとき、先のテストコードの中の以下の部分、つまり DI している依存関係のインスタンスをモックするコードをどのように記述するのが良いか悩みました。

const userRepository = jest.mocked(
  mock<IUserRepository>({
    find: jest.fn(() => Promise.resolve(new UserEntity({ id: '123', name: 'my old name' }))),
    save: jest.fn(() => Promise.resolve()),
  })
);

結論としては、最終的に落ち着いたのが上記のコードです。

経緯 #

この形に至るまでにどのような経緯があったのかをメモしておきます。

1. ts-auto-mock を検討 #

最初、インターフェースから自動でモックが作成できれば理想的だなぁと考えました。こんなイメージです。

import type IUserRepository from './repository.ts';

const userRepository = createMockFromInterface(IUserRepository);

他のプログラミング言語とは異なり、TypeScript の型情報は JavaScript にコンパイルされたあとは消えてしまいます。

ランタイムでは型情報は存在しないため、上記は不可能と捉えていました。

…が、これを実現してくれるライブラリがありました。

こんな感じで使えるようです。

import { createMock } from 'ts-auto-mock';

interface Person {
  id: string;
  getName(): string;
  details: {
    phone: number;
  };
}

const mock = createMock<Person>();
mock.id; // ""
mock.getName(); // ""
mock.details; // "{ phone: 0 }"

実現している仕組みとしては、TypeScript から JavaScript に変換するところで、インターフェースからもランタイムのコードを生成するように独自の変換を行なっているのだと思います。

求めていたのはまさにこれだ!となったものの、結局使用は断念しました。

当該ライブラリの説明に以下の注意書きがあり(こうせざるを得なかった理由も説明されています)、いまから積極的に採用するのは若干リスキーと判断したためです。

“This repository is now no longer maintained for new features development”

なお、このライブラリは以下の記事を読んでいたときに知りました。

2. タイプセーフではない書き方 #

結局、インターフェースをもとに自前でモックを定義するほかないと理解し、最初は次のようにコードを書きました。

import type IUserRepository from './repository.ts';

const userRepository = jest.mocked<IUserRepository>({
  find: jest.fn(() => Promise.resolve(new UserEntity({ id: '123', name: 'my old name' }))),
  save: jest.fn(() => Promise.resolve()),
} as unknown as IUserRepository);

UserRepository は find/save/delete の3つのメソッドを持っていますが、このテストにおいて delete は必要ありません。そのため、find と save だけをモックしたいです。

これを実現するために最後の as unknown as IUserRepository の記述が必要になります。

この記述がない場合は、delete メソッドが実装されていないというエラーが表示されてしまうためです。

一方でこの記述のためにすべてのエラーが見逃されてしまうため、例えば次のように、戻り値がおかしかったり、メソッド名をタイポしていても事前に検知できません。

const userRepository = jest.mocked<IUserRepository>({
  find: jest.fn(() => undefined), // 👈 戻り値が本来と異なる
  saveeeeeeeee: jest.fn(() => Promise.resolve()), // 👈 メソッド名が違う
} as unknown as IUserRepository);

3. タイプセーフな書き方 #

ということで次の書き方を試しました。これであれば不要な delete メソッドのモックを実装する必要もなく、かつ IUserRepository に存在しないメソッドを定義しようとするとエラーが検知されます。つまりタイポも防ぐことができます。

import type IUserRepository from './repository.ts';

const userRepository = jest.mocked<Partial<IUserRepository>>({
  find: jest.fn(() => Promise.resolve(new UserEntity({ id: '123', name: 'my old name' }))),
  save: jest.fn(() => Promise.resolve()),
}) as jest.Mocked<IUserRepository>;

4. jest-mock-extended を利用した書き方 #

先の書き方で希望は実現できました。さらに jest-mock-extended というライブラリを利用すればもう少しだけ簡潔な記述になりそうでしたので試してみました。

import { mock } from 'jest-mock-extended';
import type IUserRepository from './repository.ts';

const userRepository = jest.mocked(
  mock<IUserRepository>({
    find: jest.fn(() => Promise.resolve(new UserEntity({ id: '123', name: 'my old name' }))),
    save: jest.fn(() => Promise.resolve()),
  })
);

果たしてこの書き方がベストなのか自分の中で判然としていませんが、今のところこの形に落ち着いています。