BLOG

ブログ

Next.js x Jest x Testing Library で押さえておきたいモック関数と共通化について

はじめに

こちらは、Mavs Advent Calendar 2024の3日目の記事です🐺!

🌲🌲🌲

関数、モック化してますか?

フロントエンドでテストコードを書く際、一番エラーが出やすい項目は関数のモック化だと思っています。

今回は最低限覚えておきたいモックの心得とモック化のパターン、そして便利かもしれない共通設定をご紹介します。

今回はNext.js x jest x React Testing Library のテスト環境でのお話です。基本的な構築などには触れませんのでご注意ください。

構成は下記の通りです。

  • 覚えておきたいモック関数の決まり
  • モックかに使うjest関数たち
  • Next.jsのコンポーネントをテストする際の注意点
  • Next.jsのhooksを共通モック化しよう
  • おまけ

それでは参ります。

覚えておきたいモック関数の決まり

モック化に必要な関数を紹介をする前に、モック化した関数を取り扱うための主なメソッドと使用する際の約束を確認しておきます。

モック関数に対してよく使用されるのは以下の通りです。

  • toHaveBeenCalled (関数が呼びだされたかどうか)
  • toHaveBeenCalledTimes (関数が何回呼び出されたか)
  • toHaveBeenCalledWith (関数が呼び出された際どんな引数を受け取ったか)

実際のコードはこちら。

これらのメソッドはモック化された関数に対してのみ有効ということを肝に銘じ先に進みましょう。

// tests/sample.test.ts

test("toHaveBeenCalled: 関数が呼び出されたかどうか", () => {
  const mockFunction = jest.fn(); // モック関数を作成

  mockFunction(); // 関数を1回呼び出す

  // モック関数が呼び出されたことを確認
  expect(mockFunction).toHaveBeenCalled();
});

test("toHaveBeenCalledTimes: 関数が何回呼び出されたか", () => {
  const mockFunction = jest.fn(); // モック関数を作成

  mockFunction(); // 関数を2回呼び出す
  mockFunction();

  // モック関数が2回呼び出されたことを確認
  expect(mockFunction).toHaveBeenCalledTimes(2);
});

test("toHaveBeenCalledWith: 関数が呼び出された際の引数", () => {
  const mockFunction = jest.fn(); // モック関数を作成

  mockFunction("arg1", 42); // 特定の引数で関数を呼び出す

  // モック関数が指定の引数で呼び出されたことを確認
  expect(mockFunction).toHaveBeenCalledWith("arg1", 42);
});

モック化に使うjest関数たち

基本的にモック化するために必要な関数は以下の3つです。

どれを使っても似たようなことができますが、特徴を覚えておきましょう。

jest.fn()

こちらがおそらく一番目にするであろうモック関数の基本です。

モック関数を定義することができます。

“mockImplementation””mockResolveValue”のメソッドを指定することで、モック関数の動きや返り値を指定することができます。基本的な形は下記の通りです。

とはいえtest関数内でわざわざモック関数を定義し呼び出す機会はあまりないと個人的に思います。後述するjest.mock()と組み合わせて、特定の処理をモック化するために利用されることが多い印象です。

const mockFn1 = jest.fn();
const mockFn2 = jest.fn(()=>99); // 内容変更不可
const mockFn3 = jest.fn().mockImplementation(()=>99); // 後から内容を変更可能

// 引数を取得することも可能
mockFn1.mock.calls[0] -- 呼び出し自体
mockFn2.mock.calls[0][0] -- 受け取った引数

jest.spyOn

こちらは特定のメソッドに対し、本来の動きを保持したまま動作を追跡することができます。

一番最初に紹介したように、モックの呼び出しを検知するメソッドはモック化された関数にしか使用することができません。

ですが、上記のjest.fn()や後述のjest.mock()でモック化してしまうと既存の関数の動きを完全にモック化するため、関数本来の動作をテストフィイル内でもう一度記述する必要が出てきてしまいます。

そこで使用されるのがjest.spyOn()です。

jest.spyOn()を使うと、元の実装を保持するため、後でスパイを解除して元の動作に戻すこともできます。この特徴により、特定の状況でのみモックを適用したい場合や、既存の関数のテストを柔軟に行いたい場合に非常に便利です。

test("jest.spyOn() の基本的な使用例", () => {
  const obj = {
    method: (arg: string) => `Original: ${arg}`, // 元の実装
  };

  // obj.method をスパイして呼び出しを追跡
  const spy = jest.spyOn(obj, "method");

  // 元の実装を利用した呼び出し
  const result = obj.method("test");

  // 元の動作が維持されていることを確認
  expect(result).toBe("Original: test");

  // スパイによる追跡の確認
  expect(spy).toHaveBeenCalled();
  expect(spy).toHaveBeenCalledWith("test");

  // スパイを解除して元の実装に戻す
  spy.mockRestore();
});

jest.mock()

こちらはモジュールのモック化に使用します。

importしたモジュールの一部、または全部をモック化することができます。

// モック対象のモジュール
export const add = (a: number, b: number): number => a + b;
export const subtract = (a: number, b: number): number => a - b;
import * as math from "../../src/utils/math";

jest.mock("../../src/utils/math", () => ({
  ...jest.requireActual("../../src/utils/math"), // 他の関数はそのまま利用
  add: jest.fn(), // add 関数だけモック化
}));

test("add 関数のモック化", () => {
  const mockAdd = jest.spyOn(math, "add");
  mockAdd.mockImplementation(() => 100); // モックの動作を指定

  expect(math.add(1, 2)).toBe(100); // モック化された結果を確認
  expect(math.subtract(5, 3)).toBe(2); // subtract は元の実装のまま
});

また、下記のように書くことでモック化したモジュールの挙動をテストケースごと変化させることも可能です。API通信用の関数など、期待する返り値が変わる場合のテストに有効です。

import {function} from '../src/module';    
  
// moduleをモック化
jest.mock('../src/module'')  
  
describe('module test', () => {  
  // テストが始まる前に都度読み込まれる 
	beforeEach(()=>{  
	  // importした"function"をモック関数に定義し返り値を設定
		(module as jest.Mock).mockReturnValue('test1')  
	})  
  
  // beforeEachで定義したモック関数が適用される
	test('test1', () => {  
		const result = function()  
		expect(result).toBe('test1')  
	})
  
  // test2で定義したモック関数が適用される
	test('test2', () => {  
		(module as jest.Mock).mockReturnValue('test2')  
		const result = mockTest()  
		expect(result).toBe('test2')  
	}) 
})

Next.jsのコンポーネントをテストする際の注意点

Next.jsのコンポーネントをテストする際は、内部関数をモック化することはできないことに注意です。内部関数の挙動は、関数実行後の変化を検知するなどの対応が必要です。

もしくは外部関数としてexportし、テストファイルにimportするという方法もあります(あまりお勧めはできませんが)。

テストを優先とするか、実装を優先とするかで取りうる手段も変わりますが、基本的に内部関数のモック化は難しいと覚えておきましょう。

import React, { useState } from "react";

const MyComponent = () => {
  const [count, setCount] = useState(0);

  // 内部関数は基本的にモック化できない
  const handleClick = () => {
    console.log("Button clicked");
    setCount(count + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
};

export default MyComponent;

Next.jsのhooksを共通モック化しよう

Next.jsでテストを最初につまづくポイント、それはuseRouterなどの便利hooksたちです(個人の意見)。これらはjest環境内ではうまく動作しないため、モック化する必要があります。

共通化についてはjest.setup.tsファイルで、jest.mock()を使用しモジュールごとモックしてあげれば万事解決です。

ですが、テストケースによってはこのuseRouterなどが呼び出されたか確認したいケースがあると思います。また、状況によっては、hooksの中身を特定のテストでだけ変更したい場合もあるかもしれません。

もちろんテストファイルごとにモジュールをモック化しなしてもいいですが、hooksは使用率が高いため少し非効率な気がします。

そこで以下のようにjest.setupで定義します。

以下の設定ではuseRouterのモック関数を使用したいテストファイルにimportすることで、簡単に呼び出しテストなどで使用することができます。

// 呼び出したいテストコンポーネントでimportして使用するモック関数
export const mockRouterPush = jest.fn()

// 呼び出し先で上書きするため定数化
export const mockSearchParams = jest.fn().mockReturnValue({
  get: jest.fn().mockReturnValue(''),
})

// hooks全体をモック化 ここではuseRouterとuseSearchParamsのみを返す設定
jest.mock('next/navigation', () => {
  return {
    useRouter: () => ({
      push: mockRouterPush,
    }),
    useSearchParams: mockSearchParams,
  }
})

おまけ

最後に、テストをしていると必ず使用するscreen.debug()ですが、行数が多いと途中でみきれてしまいます。なので、行全体を表示するための魔法の呪文を共有してこの記事の締めくくりとします

DEBUG_PRINT_LIMIT=10000 npm test

以上、最近テストコード書きまくってる遠藤でした〜!

RELATED ARTICLE

  • この記事を書いた人
  • 最新の記事