jestで関数をモックするときはモジュールを意識しよう
今日の学び
TypeScript 等でjestを扱うとき(多分vitestとかでも同じな気がする、ES moduleの話なので)に以下の状況を考える
あるテスト対象の関数 somethingA が同じモジュール内にある別の関数 somethingB を呼び出している
somethingA のテストで、somethingBの入力を検証したい
code:something.ts
export const somethingA = (input: number) => {
somethingB(input + 1);
};
export const somethingB = (input: number) => {
console.log(input);
};
素直に書けばこんな感じになるはず↓(clearAllMocksとかは省略)
code:__tests__/something.test.ts
import { somethingA, somethingB } from '../something';
jest.mock('../something', () => {
const actual = jest.requireActual('../something');
return {
...actual,
somethingB: jest.fn(),
}
})
test('Validation for somethingA', () => {
(somethingB as jest.Mock).mockReturnValue(undefined);
somethingA(1);
const args = (somethingB as jest.Mock).mock.calls0;
const input = args0 as number;
expect(input).toBe(2);
});
しかしこれは実際には失敗する。
const input = args[0] as number; のところで、args が undefined になっている
→ そもそも mock.calls が空 = somethingB は呼ばれていない事になっている
その答えはこれ:
https://stackoverflow.com/questions/72051085/jest-my-isolated-module-function-depends-on-a-function-found-on-the-same-module
レキシカル・スコープのルールと、それがクロージャの概念とどのように結びついているかを覚えておく必要がある。簡単に言うと、テストがうまくいかないのは、モジュールabc.jsの中で関数a()が、同じモジュールで宣言された関数b()を本体内で呼び出しているからです。関数が定義された環境ではなく、関数が呼び出された環境で定義することをレキシカル・スコープといいます。定義されたバインディングを利用する関数本体と、その既存のバインディング/環境のコンテキストで関数を呼び出すというこの組み合わせは、クロージャと呼ばれるものです。つまり、関数b()をモックし、そのモックを使ってa()の戻り値を変更しようとしても、a()は常に元の関数b()を使うことになる。
翻訳:Deepl
要するに、somethingA の中で呼ばれている somethingB は元々定義されているスコープのものしか指さないので、いくら外からモックを差し込もうとしても somethingA の実装が変わらない限りは差し替わらないということ(多分)。
なのでこれを解決するには以下の方法がある。
somethingB を somethingA とは明確に別モジュールとする
somethingB の中で利用している関数などがあれば、その入力を検証することで代替する
今回の場合は console.log
後者の方法は somethingA のテストをしたいのに somethingB の事情を知らないといけないので少し筋が悪い。
基本的には前者で解決するのが良さそう。
例えば something.ts → something/index.ts something/something.ts something/internal.ts というふうに分割して、
code:something/index.ts
export * from './something.ts';
code:something/something.ts
export const somethingA = (input: number) => {
somethingB(input + 1);
};
code:something/internal.ts
export const somethingB = (input: number) => {
console.log(input);
};
とすれば元々あったimportも壊さなくて済む。
ちなみに今日はこれで1~2時間くらい溶かした。Kurogoma4D.icon つらい