QAエンジニアがWebアプリケーションフロントエンドのテストコードを書いてみる（テストスパイ編）

## はじめに

ReactなどのWebアプリケーションフレームワークを用いたフロントエンド開発経験ゼロのQAエンジニアが、Webアプリケーションのテストコードを書いてみるシリーズです。

背景として、エンジニアに対して、「テストコードを書いてください」とよくお願いしてしまいます。しかし、エンジニアから「テストコードを書くので書き方を教えてください」と返答されると、それに答えられるスキルがないので、自分なりに簡単なWebアプリケーションのコードとそれを対象としたテストコードを書いてみることにしました。

本書では、テストスパイを使ってテストコードを紹介します。

## テストスパイとは

本節は、[xUnit Test PatternsのTest Doubleパターン](https://goyoki.hatenablog.com/entry/20120301/1330608789)を引用して、テストスタブについて説明します。

### 間接入力

テストスパイを説明するために、まずは間接出力について説明します。

「間接出力」とは、テストコードからは見えない、テスト対象からの出力のことを指します。
以下の例では、テスト対象となる`funcUnderTest`の中で、`anyExternalFunc`の引数に`output`を渡しています。ここでの`anyExternalFunc`への引数のように、テストコードからは直接見えないが、テスト対象が外部に影響を与える出力が間接出力です。

```javascript
/**
 * テスト対象の関数
 */
function funcUnderTest() {
  // 何かの処理
  anyExternalFunc(output);  // 間接出力
  // 何かの処理
}

describe('funcUnderTest', () => {
  it('should do something', () => {
    expect(funcUnderTest()).toBe('expectedValue');
  });
});
```

なお、間接出力には、「テスト対象が依存する外部メソッドが実行されたか」や「テスト対象が依存する複数の外部メソッドが順番通りに実行されたか」のようなメソッドの呼び出しの有無も含みます。

### テストスパイ

テストスパイは、テスト対象の間接出力を記録し、その記録をテストコードから参照できるようにするテストダブルです。
テストでは、テスト対象の間接出力を記録するために、テスト対象が依存する外部メソッドを置き換えます。テストコードで、記録した間接出力を検証します。なお、[間接入力](https://zenn.dev/jyoppomu/articles/b7b0f63b2d5ae3#%E9%96%93%E6%8E%A5%E5%85%A5%E5%8A%9B)を制御することもあります。

## テストスパイを使ってテストコードを書いてみる

テスト対象の間接出力を記録するテストスパイを使って、テストコードを書いてみます。

### テスト対象

テスト対象は、以下のような外部のWeb APIを`fetch`で実行する関数(`getImage`)です。
この関数は、前述のとおり外部のWebAPIに依存しており、これをテスト対象から切り離すために、`fetch`をテストスパイに置き換えます。

```typescript
async function getImage(apiKey: string | null): Promise<ImageResponse> {
  const url = apiKey
    ? `${baseUrl}?api_key=${apiKey}`
    : `${baseUrl}?api_key=DEMO_KEY`;

  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(
      `Failed to fetch data: ${response.status} ${response.statusText}`
    );
  }
  const image: ImageResponse = await response.json();
  return image;
}
```

### テストコード

テストフレームワークにはVitestを使用します。`vi.spyOn`関数を使って、`fetch`をテストスパイに置き換えます。

```typescript
import {renderHook, waitFor} from "@testing-library/react";
import {describe, afterEach, it, vi} from "vitest";
import Api from "@/lib/api";

describe("Api.getImage", () => {
  // fetch を差し替えるテストスパイを定義する
  const arrangeFetchSpy = () =>
    vi.spyOn(global, "fetch").mockResolvedValue(
      new Response(
        JSON.stringify({
          copyright: "copyright",
          date: "2025-02-26",
          explanation: "explanation",
          hdurl:
            "https://apod.nasa.gov/apod/image/2502/ClusterRing_Euclid_2665.jpg",
          media_type: "image",
          service_version: "v1",
          title: "Einstein Ring Surrounds Nearby Galaxy Center",
          url: "https://apod.nasa.gov/apod/image/2502/ClusterRing_Euclid_960.jpg",
        }),
        {status: 200}
      )
    );

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe("APIキーを指定した場合", () => {
    it("指定したAPIキーで画像の情報が取得される", async ({expect}) => {
      const fetchSpy = arrangeFetchSpy();
      await Api.getImage("SPECIFIED_KEY");
      expect(fetchSpy).toHaveBeenCalledTimes(1);
      expect(fetchSpy).toHaveBeenCalledWith(
        "https://api.nasa.gov/planetary/apod?api_key=SPECIFIED_KEY"
      );
    });
  });
  describe("APIキーを指定しない場合", () => {
    it("デモ用のAPIキーで画像の情報が取得される", async ({expect}) => {
      const fetchSpy = arrangeFetchSpy();
      await Api.getImage(null);
      expect(fetchSpy).toHaveBeenCalledTimes(1);
      expect(fetchSpy).toHaveBeenCalledWith(
        "https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY"
      );
    });
  });
});
```

## おわりに

テストスパイを使って、テスト対象の間接出力を記録するテストコードを書いてみました。
外部のWeb APIを実行する関数をテストする際に、テストスパイを使うことで、外部依存を切り離してテストを行うことができました。
これにより、テストの決定性が向上し、テストの信頼性を高めることができます。

<!-- zenn article id: 85db46f4ce106d -->