iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

spyOnでの監視やmockテスト解説

こんにちは。iimonでエンジニアをしている林と申します。

mockについての解説記事を過去3回に渡って書いてきましたが、

spyを最近多用しているのでspyについても纏めてみました。

今回の記事は前回までの記事の知識を前提に記載させて頂きます。

よろしくお願いします。

https://tech.iimon.co.jp/entry/2023/12/11/121847

https://tech.iimon.co.jp/entry/2024/02/26/162500

https://tech.iimon.co.jp/entry/2024/06/18/160000

spyとは

spyとは以下の特徴があります。

  • 関数やメソッドの実際の振る舞いを監視する
    • スパイを使用すると関数が「いつ、何回、どのような引数で呼び出されたか」を監視する事が出来る
  • スパイは基本的にはテスト実行時の関数やメソッドの実際の振る舞いを置き換えない。
    • ただ、上記でも記載している通りモックとスパイは一部オーバラップしているので監視するだけでなく、必要に応じてmockの様にその振る舞いも置き換える事が可能。

前々回に紹介したjest.fn()としてmock化した場合とマッチャーは、ほぼ変わらないので

簡単に紹介するに留めますが、以下に、jest.spyOnを使った様々なパターンをいくつか記載します。

1. メソッドの呼び出しを監視する

const myMethodSpy = jest.spyOn(myObject, 'myMethod');
myObject.myMethod();
expect(myMethodSpy).toHaveBeenCalled();

2. メソッドの呼び出し回数を検証する

const myMethodSpy = jest.spyOn(myObject, 'myMethod');
myObject.myMethod();
expect(myMethodSpy).toHaveBeenCalledTimes(1);

3. メソッドの引数を検証する

const myMethodSpy = jest.spyOn(myObject, 'myMethod');
myObject.myMethod('arg1', 'arg2');
expect(myMethodSpy).toHaveBeenCalledWith('arg1', 'arg2');

4. メソッドの戻り値をモックする

const myMethodSpy = jest.spyOn(myObject, 'myMethod').mockReturnValue('mocked value');
expect(myObject.myMethod()).toBe('mocked value');

5. 非同期メソッドの戻り値をモックする

const myMethodSpy = jest.spyOn(myObject, 'myMethod').mockResolvedValue('mocked value');
await expect(myObject.myMethod()).resolves.toBe('mocked value');

6. プライベートメソッドをスパイする

const privateMethodSpy = jest.spyOn(myObject, 'privateMethod');
const myObjectAccessor = privateMethodSpy as any
myObjectAccessor.privateMethod();
expect(privateMethodSpy).toHaveBeenCalled();

7. メソッドの実装をモック化する

const myMethodSpy = jest.spyOn(myObject, 'myMethod').mockImplementation(() => 'mocked implementation');
expect(myObject.myMethod()).toBe('mocked implementation');

jest.fn()とjest.spyOn()の違い

jest.fn()とjest.spyOn()の違いとしてjest.fn()は完全に新しい関数を作成するので元の関数の動作は含まれないです。一方、jest.spyOn()は元の関数をラップしてスパイを作成するので元の関数の動きを保持しています。

マニュアルモックとjest.requireActualの併用とspyでのmockの違い

前々回記事で紹介したマニュアルモックとjest.requireActualを使用するmock化方法もmock化された関数以外は通常の動作をしてくれるのでspyOnでメソッドの一部をmock化するのとあまり変わらなかったです。

使い分けとしては、

  • マニュアルモックとjest.requireActualの併用する場合は外部ライブラリや依存関係の多かったり、モック化したメソッドのハンドリングを細かく定義したい場合はこっちを使った方が綺麗に分けれるのかなって思います。
  • jest.spyOnでmock化するケースは1,2個のメソッドのみのmock化ならspyでmock化しちゃった方がお手頃かなって思います。

ハンズオン

前回記事での設定

前回の記事で環境構築している人は飛ばして下さい。。。

jestのインストールと設定

mkdir jest-tutorial && cd $_

node.jsを初期化(package.jsonが作成される。)

npm init -y

typescriptをinstall(-Dは --save-devの短縮系)

npm i -D typescript

tsconfig.jsonを作成

npx tsc --init

ts-jestをinstall

  • ts-jestはjestでtypescriptのコードをテストことができる。
npm i -D jest @types/jest ts-jest

必要なライブラリは入ったけど、jestはそのままだとtsのテストは出来ないので追加の設定を行う。

まずはjestの設定ファイルを作成

npx ts-jest config:init

[package.json]

scriptのtestの項目をjestに変更する

  • これでnpm testコマンドでテストが実行できる様になる。
{
  "scripts": {
    "test": "jest"
  },
}
mkdir {test,src}
npm run dev

上記までが前回のブログで紹介したプロジェクトの初期設定。

例えば以下のようにしてselectで選んだものの写真を表示するプロダクトがあったとしてこれをspyしたいと思います。

[src/dogApi.ts]

export default class DogApi {
  setChoseDogImageEvent() {
    const btn = document.getElementById("dogChoseBtn");
    if (!btn) return;
    btn.addEventListener("click", async () => {
      const selectElem = document.getElementById("dogChose");
      const dogImg = document.getElementById("dogImg");
      if (
        !(selectElem instanceof HTMLSelectElement) ||
        !(dogImg instanceof HTMLImageElement)
      ) {
        return;
      }
      const dogVal = selectElem.value;
      const image = await this.fetchChoseDogImage(dogVal);
      dogImg.src = image;
      dogImg.style.width = "300px";
      dogImg.style.height = "300px";
    });
  }

  private async fetchChoseDogImage(dog: string) {
    try {
      const res = await fetch(`https://dog.ceo/api/breed/${dog}/images/random`);
      if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
      const data = await res.json();
      const imageUrl = data.message;
      return imageUrl;
    } catch (error) {
      throw new Error(`${error}`);
    }
  }
}

new DogApi().setChoseDogImageEvent();

[index.html]

<!DOCTYPE html>
<html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Vite App</title>
    </head>
    <body>
      <div>
        <img src="https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg" id="dogImg" alt="dog">
      </div>
      <br>
      <div>
        <select name="dogChose" id="dogChose">
          <option value="akita">akita</option>
          <option value="african">african</option>
          <option value="hound/english">englishHound</option>
        </select>
        <button id="dogChoseBtn">dogChose</button>
      </div>
      <script type="module" src="/src/dogApi.ts"></script>
    </body>
</html>

[test/html/dogApiHtml.ts]

export const dogApiHtml = `<!DOCTYPE html>
<html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Vite App</title>
    </head>
    <body>
      <div>
        <img src="https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg" id="dogImg" alt="dog">
      </div>
      <br>
      <div>
        <select name="dogChose" id="dogChose">
          <option value="akita">akita</option>
          <option value="african">african</option>
          <option value="hound/english">englishHound</option>
        </select>
        <button id="dogChoseBtn">dogChose</button>
      </div>
      <script type="module" src="/src/dogApi.ts"></script>
    </body>
</html>`

export default dogApiHtml

上記のようなプロダクトのコードにmockテストを以下のように書きました。

  • fetchDogImageのmockテスト
    • 呼び出される順番や引数などが問題ないかのテストです。
    • 今回は解説ブログなのであえてそうしてますが、toHaveBeenNthCalledWithを使う場合は呼び出しの順番と引数をみてくれるのでtoHaveBeenCalledWithは必要ないかなって思います。
  • setChoseDogImageEventのmockテスト
    • 実際にクリックした時の動きをテストで模倣したmockテストを書いてます。

spyでmock化したいメソッドがprivateの場合はアクセスできないので

accessorにanyとしてキャストして再代入してする形にします。

この場合はaccessorに再代入されるまではinstanceはDogApiという型という担保が取れていてspyでmock化されているところだけ書き換わっているという判断が出来るのでそうしていますが、

他にもっと堅牢な書き方があれば教えて欲しいです。

[test/dogApi.test.ts]

import DogApi from "../src/dogApi";
import dogApiHtml from "./html/dogApiHtml";

const imgAkitaTestUrl = "https://images/Akita.jpg";
const imgAfricanTestUrl = "https://images/African.jpg";
const imgEnglishHoundTestUrl = "https://images/EnglishHound.jpg";
document.body.innerHTML = dogApiHtml;

const watchImgSrcObserver = (elem: HTMLImageElement) => {
  return new Promise<void>((resolve) => {
    const observer = new MutationObserver(() => {
      observer.disconnect();
      resolve();
    });
    observer.observe(elem, { attributes: true, attributeFilter: ["src"] });
  });
};

const setupMock = () => {
  const dog: DogApi = new DogApi();
  // プライベートメソッドにアクセスするには一回anyとキャスト
  const dogApiAccessor = dog as any;
  jest.spyOn(dogApiAccessor, 'fetchChoseDogImage')
  .mockImplementation((dog) => {
    // mockResolvedValue系はmockImplementationの中では使えないのでPromise.resolveとなる
    if (dog === 'akita') return Promise.resolve(imgAkitaTestUrl);
    if (dog === 'african') return Promise.resolve(imgAfricanTestUrl);
    if (dog === 'hound/english') return Promise.resolve(imgEnglishHoundTestUrl);
  });
  return dogApiAccessor;
};

let instance = setupMock();

describe('fetchDogImageのspyテスト', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    instance = setupMock();
  });

  test('fetchDogImageのmockテスト', async () => {
    const dogImageFunc = instance.fetchChoseDogImage;
    const res1 = await dogImageFunc('akita');
    const res2 = await dogImageFunc('african');
    const res3 = await dogImageFunc('hound/english');
    // 返されたmock値の確認
    expect(res1).toEqual(imgAkitaTestUrl);
    expect(res2).toEqual(imgAfricanTestUrl);
    expect(res3).toEqual(imgEnglishHoundTestUrl);
    // 呼び出されている引数の確認
    expect(dogImageFunc).toHaveBeenCalledWith('akita');
    expect(dogImageFunc).toHaveBeenCalledWith('african');
    expect(dogImageFunc).toHaveBeenCalledWith('hound/english');
    // 呼び出されている順番の確認
    expect(dogImageFunc).toHaveBeenNthCalledWith(1, 'akita');
    expect(dogImageFunc).toHaveBeenNthCalledWith(2, 'african');
    expect(dogImageFunc).toHaveBeenNthCalledWith(3, 'hound/english');
    // 呼び出されている回数の確認
    expect(dogImageFunc).toHaveBeenCalledTimes(3);
  });

  test('setChoseDogImageEventのmockテスト', async () => {
    instance.setChoseDogImageEvent();
    const dogBtn = document.getElementById('dogChoseBtn');
    const selectElem = document.getElementById('dogChose');
    const dogImg = document.getElementById('dogImg');
    if (
      !(selectElem instanceof HTMLSelectElement) ||
      !(dogImg instanceof HTMLImageElement) ||
      !(dogBtn instanceof HTMLButtonElement) 
    ) {
      return;
    }
    expect(dogImg.src).toEqual('https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg')
    // 0番目のoptionを選択してボタンクリック
    selectElem.selectedIndex = 0
    let selectVal = selectElem.value
    dogBtn.click()
    expect(selectElem.value).toEqual('akita')
    await watchImgSrcObserver(dogImg)
    expect(dogImg.src).toEqual(imgAkitaTestUrl)
    // 1番目のoptionを選択してボタンクリック
    selectElem.selectedIndex = 1
    selectVal = selectElem.value
    dogBtn.click()
    expect(selectElem.value).toEqual('african')
    await watchImgSrcObserver(dogImg)
    expect(dogImg.src).toEqual(imgAfricanTestUrl)
    // 2番目のoptionを選択してボタンクリック
    selectElem.selectedIndex = 2
    selectVal = selectElem.value
    dogBtn.click()
    expect(selectElem.value).toEqual('hound/english')
    await watchImgSrcObserver(dogImg)
    expect(dogImg.src).toEqual(imgEnglishHoundTestUrl)  
  });
});

まとめ

最後まで読んでくださりありがとうございます。

今回でテストの記事はおそらく最後になるかと思います。

もし書くとしたらフレームワークを使った時のテストとかかも知れません。

この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします! Wantedly / Green

参考記事

https://jestjs.io/ja/docs/jest-object#jestspyonobject-methodname

https://qiita.com/craftect/items/28844664875b89b4e2fb

https://qiita.com/TMDM/items/bc6940fc2ed4a67fe4ff