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

## はじめに

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

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

本書では、テストフレームワークである[Vitest](https://vitest.dev/)のBrowser Modeを使ったテストコードを紹介します。
また、本書で扱うVitestのバージョンは`v3.1.4`です。

## Vitest Browser Mode とは

Vitestの[Browser Mode](https://vitest.dev/guide/browser/)は、ブラウザ上でテストを実行できるモードです。
[``jsdom``](https://github.com/jsdom/jsdom)や[``happy-dom``](https://github.com/capricorn86/happy-dom)などのライブラリは、あくまでNode.js環境下でシミュレートされたブラウザ環境でテストを実行するため、実際のブラウザ環境との差分が発生する可能性があります。つまり、これらのライブラリを使って得られたテスト結果は、偽陽性もしくは偽陰性になる可能性があること注意しないければなりません。
一方、VitestのBrowser Modeでは、実際のブラウザ環境でテスト実行を可能にするため、フロントエンドのテストの信頼性を向上できます。
なお、`v3.1.4`では、まだexperimentalな機能であり、将来的に変更される可能性があります。

## テストコードを書いてみる

### テスト対象

テスト対象は、以下のようなテキストボックス要素とボタン要素をもつ`InputKey`コンポーネントです。
動作しようとして、テキストボックスに入力された文字列をボタンをクリックすることで、`USER-TOKEN`という名前のCookieとして保存します。

```typescript:components/ui/input-key.tsx
import {useState, FC} from "react";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {setTokenToCookie} from "@/lib/cookie";

export const InputKey: FC = (): JSX.Element => {
  const [inputKeyValue, setInputKeyValue] = useState<string>("");

  const handleInputKeyChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setInputKeyValue(event.target.value);
  };

  const handleSaveKeyClick = () => {
    setTokenToCookie(inputKeyValue);
  };

  return (
    <div>
      <Input
        type="text"
        value={inputKeyValue}
        onChange={handleInputKeyChange}
        placeholder="Enter Key"
      />
      <Button
        onClick={handleSaveKeyClick}
      >
        Save
      </Button>
    </div>
  );
};
```

ここでは`document.cookie`を使ってCookieを保存します。実ブラウザ環境下でglobalなAPIである`document.cookie`を呼び出してテスト実行することになります。
（なんかもっとよいブラウザ環境でないと困る操作が他にあれば、教えてください...）

```typescript:lib/cookie.ts
export const setTokenToCookie = (
  value: string,
  options: CookieOptions = {}
): void => {
  const defaultOptions: CookieOptions = {
    path: "/",
    secure: true,
    sameSite: "Strict",
    maxAge: 3600, // in unit of seconds
    expires: new Date(Date.now() + 3600 * 1000), // 1時間後に有効期限を設定
  };

  // デフォルトオプションと引数で指定されたオプションをマージ
  const combinedOptions = {...defaultOptions, ...options};

  // Cookieのオプションを文字列形式に変換
  const cookieString =
    `USER-TOKEN=${encodeURIComponent(value)};` +
    Object.entries(combinedOptions)
      .map(([key, val]) => {
        if (val === true) return key; // 値がtrueの場合、属性名だけを追加（例: 'Secure'）
        if (key === "expires" && val instanceof Date) {
          return `${key}=${val.toUTCString()}`; // expiresはUTC形式に変換
        }
        if (val !== undefined) return `${key}=${val}`; // 値が存在する場合のみ追加
        return "";
      })
      .filter(Boolean) // 空の要素を削除
      .join("; ");

  // Cookieを設定
  document.cookie = cookieString;
};
```

### Vitest の設定

VitestのBrowser Modeを使うためには、`vitest.config.ts`に以下のように設定を記述します。

```typescript:vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    name: "browser",
    include: [
      "tests/browser/**/*.test.ts",
      "tests/browser/**/*.test.tsx",
    ],
    browser: {
      enabled: true,
      provider: 'playwright',
      instances: [
        {
          browser: 'chromium',
        },
      ],
    },
  },
})
```

元々プロジェクトでNode.js環境下でのテストを行なっている場合は、[workspace](https://vitest.dev/guide/workspace.html)を定義することで、Browser Modeのテストを共存させられます。

```typescript:vitest.workspace.ts
import {defineWorkspace} from "vitest/config";

export default defineWorkspace([
  {
    extends: "vite.config.ts",
    test: {
      name: "unit",
      include: ["tests/unit/**/*.test.ts", "tests/unit/**/*.test.tsx"],
      globals: true,
      environment: "jsdom",
    },
  },
  {
    extends: "vite.config.ts",
    test: {
      name: "browser",
      include: [
        "tests/browser/**/*.test.ts",
        "tests/browser/**/*.test.tsx",
      ],
      browser: {
        enabled: true,
        provider: 'playwright',
        instances: [
          {
            browser: 'chromium',
          },
        ],
      },
    },
  },
]);
```

### テストコード

テスト実行時には[vitest-browser-react](https://github.com/vitest-dev/vitest-browser-react)の`render`関数を使って、Reactコンポーネントをレンダリングします。
これにより、コンポーネントに対するユーザー操作やアサーションを行うことができます。

```typescript
import {expect, describe, test} from "vitest";
import {render} from "vitest-browser-react";
import {userEvent} from "@vitest/browser/context";
import {InputKey} from "@/components/ui/input-key";

describe("InputKey Component", () => {
  test("キー入力欄が表示されること", () => {
    const component = render(<InputKey />);
    const inputField = component.getByPlaceholder("Enter Key");
    expect(inputField).toBeVisible();
    expect(inputField).toBeEnabled();
  });

  test("保存ボタンが表示されること", () => {
    const component = render(<InputKey />);
    const saveButton = component.getByRole("button", {name: "Save"});
    expect(saveButton).toBeVisible();
    expect(saveButton).toBeEnabled();
  });

  test("保存ボタンをクリックするとトークンが設定されること", async () => {
    const component = render(<InputKey />);
    const inputField = component.getByPlaceholder("Enter Key");
    const saveButton = component.getByRole("button", {name: "Save"});

    // 入力フィールドに値を入力し、保存ボタンをクリック
    await userEvent.fill(inputField, "test-api-key");
    await userEvent.click(saveButton);

    // トークンがCookieに保存されたか確認
    expect(document.cookie).toContain("USER-TOKEN=test-api-key");
  });
});
```

### テストコードを書くときに考えていたこと

- コンポーネントテストとして書く
- テスト実行が「実際にユーザーが操作する」の再現となるようにする
- テストの工程はざっくり以下の2段階
  - コンポーネントの責任範囲（このコンポーネントはどういう振る舞いをしないといけないのか）をユーザー視点から捉えた（テスト分析）。
  - 「どういう振る舞いをしないといけないのか」を「実際にユーザーが操作する」仕方でテストケースを表現した（しやすいと感じた）（テスト作成）。

## おわりに

- VitestのBrowser Modeを使って、「実際にユーザーが操作する」視点コンポーネントテストを書いてみました。
- 「実際のユーザーの操作」を再現しやすい（書きやすい）APIが用意されているように思った。
- 実際のブラウザ環境でテストを実行するため、偽陽性や偽陰性のリスクを低減できる点も大きなメリットです。


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