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

## はじめに

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

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

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

## テストスタブとは

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

### 間接入力

テストスタブを説明するために、まずは間接入力について説明します。

「間接入力」とは、テストコードからは見えない、テスト対象への入力のことを指します。
以下の例では、テスト対象となる`funcUnderTest`の中で、`anyExternalFunc`の戻り値を使用しています。ここでの`anyExternalFunc`の戻り値のように、テストコードからは直接見えないが、テスト対象に影響を与える入力が間接入力です。

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

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

なお、間接入力には、例えばテスト対象が依存するオブジェクトからの例外発生も含みます。

### テストスタブ

テストスタブは、テスト対象への間接入力を操作するテストダブルです。
テストスタブは、テスト対象が依存するオブジェクトを置き換えることで、テスト対象に影響を与える間接入力を制御します。

## テストスタブを使ってテストコードを書いてみる

テスト対象の間接入力を制御するテストスタブを使って、テストコードを書いてみます。

### テスト対象

テスト対象は、以下のようなReactのカスタムフック(`userFetchImage`)です。
このカスタムフックが依存している`Api.getImage`関数は、外部のWebAPIにリクエストをして、画像データを取得する関数です。この外部依存となる`Api.getImage`をテスト対象から切り離すために、テストスタブに置き換えます。

```typescript
import {useState, useEffect} from "react";
import {ImageResponse} from "@/lib/types";
import Api from "@/lib/api";

const useFetchImage = (apiKey: string | null) => {
  const [image, setImage] = useState<ImageResponse>();
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const fetchImage = async () => {
      setLoading(true);
      try {
        const imageResponse = await Api.getImage(apiKey);
        setImage(imageResponse);
      } catch (err) {
        setError(err as Error);
      } finally {
        setLoading(false);
      }
    };

    fetchImage();
  }, [apiKey]);

  return {image, loading, error};
};
```

### テストコード

テストフレームワークにはVitestを使用します。`vi.mock`関数を使って、`@/lib/api`モジュールの`getImage`関数をテストスタブに置き換えます。

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

describe("Hooks.useFetchImage", () => {
  describe("データ取得が成功した場合", () => {
    it("画像の情報が返る", async ({expect}) => {
      // テストスタブの定義
      vi.mock("@/lib/api", () => {
        return {
          default: {
            // getImageの戻り値を固定の値にする
            getImage: vi.fn().mockResolvedValue({
              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",
            }),
          },
        };
      });

      const {result} = renderHook(() =>
        Hooks.useFetchImage("SPECIFIED_KEY")
      );

      // 非同期処理の完了を待つ
      await waitFor(() => {
        expect(result.current.image).not.toBeNull();
      });

      expect(result.current.image).toEqual({
        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",
      });
      expect(result.current.loading).toBe(false);
      expect(result.current.error).toBeNull();
    });
  });
});
```

## おわりに

テストスタブを使って、テスト対象の間接入力を制御するテストコードを書いてみました。
Web APIに依存するReactのカスタムフックをテストする際に、テストスタブを使うことで、外部依存を切り離してテストを行うことができました。
これにより、テストの決定性が向上し、テストの信頼性を高めることができます。


<!-- zenn article id: b7b0f63b2d5ae3 -->