🙆

【jest】mockについて

2024/02/01に公開

jestでmockを使ったテストを書く際に苦戦したので理解を深めるためにまとめました。

モックの種類

まずすべてのモック関数には .mock プロパティがあり、モック関数呼び出し時のデータと、関数の返り値が記録されています。

// The function was called exactly once
// 呼び出された回数
expect(someMockFunction.mock.calls).toHaveLength(1);

// The first arg of the first call to the function was 'first arg'
// x回目の呼び出しの時のx番目の引数
expect(someMockFunction.mock.calls[0][0]).toBe('first arg');

...

jest.fn()

最も単純なモック関数を作成できます。

// 関数のモック
const mockFn = jest.fn();

mockFn();
mockFn('arg1', 'arg2');

// テスト: 関数が2回呼ばれたことを確認
expect(mockFn).toHaveBeenCalledTimes(2);

mockの戻り値

mockReturnValueOncemockReturnValueを使ってモックに対して単一の戻り値を指定できます。

const myMock = jest.fn();

myMock.mockReturnValueOnce(10).mockReturnValueOnce('x').mockReturnValue(true);

console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true



mockImplementationは引数に関数を受け取る。複雑な条件に基づいて動的に戻り値を変更したい場合に使用します。

const mockFn = jest.fn();
mockFn.mockImplementation((x) => `動的な戻り値: ${x}`);



mockResolvedValueは非同期でPromiseを返す関数のモックに対して使用します。

// 外部APIを呼び出す関数(モック対象)
function fetchData() {
  return fetch('https://api.example.com/data')
    .then(response => response.json());
}

// モック関数の作成
jest.mock('fetch');
fetch.mockResolvedValue({
  json: () => Promise.resolve("模擬データ")
});

// テストの実施
test('fetchData returns "模擬データ"', async () => {
  const data = await fetchData();
  expect(data).toBe("模擬データ");
});

jest.spyOn()

既存のオブジェクトのメソッドを監視します。
メソッドの呼び出しを追跡し、必要に応じてモックの実装を提供できます。

// オブジェクトのメソッドをスパイ
const video = {
  play: () => 'playing'
};

const spy = jest.spyOn(video, 'play');

// テスト: video.playが呼ばれたかテスト
expect(video.play).toHaveBeenCalled();


オブジェクトのメソッドをモックする場合。

// 監視するオブジェクトとメソッド
const mathOperations = {
  add: (a, b) => a + b,
  calculate: (x) => x * 2
};

// calculateメソッドを監視し、カスタムの実装を提供
const spy = jest.spyOn(mathOperations, 'calculate').mockImplementation((n) => n * 10);

// テスト: calculateメソッドがカスタムの実装に従って動作するか確認
expect(mathOperations.calculate(5)).toBe(50);

// スパイとモックのクリーンアップ
spy.mockRestore();

既存のメソッドを上書きせずに監視し、mockRestoreでテストが他のテストに影響を与えないようにすることができます。
また名前付きエクスポートやデフォルトエクスポートを直接モック化することはできません。
メソッドや関数に対してのみ機能します。

jest.mock()

モジュール全体をモックすることができます。

import axios from 'axios';

class Users {
  static all() {
    return axios.get('/users.json').then(resp => resp.data);
  }
}

export default Users;
import axios from 'axios';
import Users from './users';

jest.mock('axios');

test('should fetch users', () => {
  const users = [{name: 'Bob'}];
  const resp = {data: users};
  axios.get.mockResolvedValue(resp);

  // or you could use the following depending on your use case:
  // axios.get.mockImplementation(() => Promise.resolve(resp))

  return Users.all().then(data => expect(data).toEqual(users));
});



https://jestjs.io/ja/docs/mock-functions#部分的なモック
上記のドキュメントのようにjest.mock()を使って部分的にモックすることもできます。

実装で詰まった点

(以下jestを使ったモック化という趣旨から少しずれています、レキシカルスコープに関してです)
以下のuseSampleのような関数内で定義された b関数をモック化しようとしていました。
しかし、jest.mock()を使った部分的なモック作成しようとしてもオリジナルのbメソッドが呼ばれてしまう状況でした。

// 実装
export function useSample() {
  const b = () => {
    return true
  }

  const a = () => {
    if (b() === true) {
      return true
    }

    false
  }

  return { a, b }
}
// テスト
import { useSample } from './useSample'

jest.mock('./useSample', () => {
  const originalModule = jest.requireActual('./useSample');
  const mockB = jest.fn().mockReturnValue(false);

  return {
    ...originalModule,
    useSample: () => ({
      ...originalModule.useSample(),
      b: mockB,
    }),
  }
})

describe('useSample tests', () => {
  it('tests a function', () => {
    const { a, b } = useSample();
    // 失敗する
    expect(a()).toBe(false)
    // 成功する(b関数単体では正しくモックできる)
    expect(b()).toBe(false)
  })
})

これはJavaScriptのレキシカルスコープとクロージャという概念を理解していない為でした。
以下の記事がわかりやすかったです。
https://wemo.tech/904
引用するとクロージャとは
関数はそれが定義されたスコープ内でのみアクセス可能な変数とともに「閉じ込められ」ます。これは「クロージャ」と呼ばれる概念です。
関数内部で定義されたローカルな関数がその親関数の実行によって外部スコープへと持ち出される時、その瞬間の環境を記憶したクロージャという特殊なオブジェクトへと変化する。

今回の件に当てはめると
親関数のuseSampleを実行した時点(const { a, b } = useSample())で内部の関数(a,b)が固定化されます。(レキシカルスコープが決定する) その為a関数内のb関数は外部から直接アクセスや置き換えができません。このため、a関数内で b関数 が呼び出されると、モックではなく実際の b関数 が実行されます。


以下のように関数を分けたとしても正しくモックされませんでした。a関数の中で実行されるb関数はオリジナルのb関数が呼ばれます。これも同じようにa関数をモックする際に参照するb関数はレキシカルスコープとして固定化されていて外部から変更できないという認識です。

./useSample.ts
export const b = ():boolean => {
  return true
};

export function useSample() {
  const a = () => {
    if (b() === true) {
      return true;
    }
    return false;
  };

  return { a };
}
import * as useSampleModule from './useSample'

jest.mock('./useSample', () => {
  const originalModule = jest.requireActual('./useSample')

  return {
    ...originalModule,
    b: jest.fn().mockReturnValue(false),
  };
});

describe('./useSample', () => {
  it("テスト1", () => {
    const { useSample } = useSampleModule
    // 失敗する
    expect(useSample().a()).toBe(false)
  })

  it("テスト2", () => {
    const { b } = useSampleModule
    // 成功する(b関数単体では正しくモックできる)
    expect(b()).toBe(false)
  })
})


この問題を解決するには以下のようにモック化したい関数(b関数)を引数で渡します。これでa関数の中でモック化されたb関数を使用することができます。

./useSample.ts
export const b = (): boolean => {
  return true;
};

// 外部から b関数を受け取る
export function useSample(externalB = b) {
  const a = () => {
    if (externalB() === true) {
      return true;
    }
    return false;
  };

  return { a, b: externalB };
}
import * as useSampleModule from './useSample'

jest.mock('./useSample', () => {
  const originalModule = jest.requireActual('./useSample')

  return {
    ...originalModule,
    b: jest.fn().mockReturnValue(false),
  };
});

describe('./useSample', () => {
  it("テスト1", () => {
    const mockB = jest.fn().mockReturnValue(false);
    const { a } = useSampleModule.useSample(mockB);
    // 成功する
    expect(a()).toBe(false);
  });

  it("テスト2", () => {
    const { b } = useSampleModule;
    // 成功する
    expect(b()).toBe(false);
  });
});

参考
https://jestjs.io/ja/docs/mock-functions
https://qiita.com/YSasago/items/6109c5d3fbdbffa31c9f

Discussion