コンポーネントテストは、何を確かめるのか

Storybook と Playwright を題材に、コンポーネントテストで何を確かめたいのかを整理します。

Published
2023年5月10日
Read in English

filled なら bg-gray-400 が付く」というテストは、クラス名を変えただけで落ちます。見た目は何も壊れていないのに、です。

フロントエンドのテストには長らくJestとTesting Libraryがあり、そこへStorybookのインタラクションテストとPlaywrightのコンポーネントテストが加わりました。UIを実際のブラウザで確かめる道が増えたぶん、ツールを選ぶより先に、何を確かめたいのかを決める必要があります。

Storybook の play 関数

Storybook では、ストーリーの play 関数にユーザー操作とアサーションを記述できます。現在は Vitest と Testing Library を統合した storybook/test を使い、ストーリーをそのままブラウザ上のテストケースとして実行できます。

import { expect, userEvent, within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react";
import { Checkbox } from "./Checkbox";

const meta = {
  component: Checkbox,
} satisfies Meta<typeof Checkbox>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
  args: {
    id: "greeting",
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const checkbox = canvas.getByRole("checkbox");

    await userEvent.click(checkbox);
    await expect(checkbox).toBeChecked();
  },
};

実行結果は Storybook の Interactions パネルに表示されます。

Storybook のインタラクションテスト実行画面

satisfies 演算子を使うと、メタデータの型を検査しながら、ストーリー固有の型推論も保てます。必須の props が足りない場合にも TypeScript が教えてくれるため、ストーリー自体を安全に書きやすくなります。

操作をステップに分ける

一連の操作は step で意味のある単位に分けられます。内部リンクと外部リンクの振る舞いを同じストーリーで確かめる場合は、次のように書けます。

export const Links: Story = {
  render: () => (
    <div className="flex gap-4">
      <Link href="/articles">内部リンク</Link>
      <Link href="https://example.com/">外部リンク</Link>
    </div>
  ),
  play: async ({ canvasElement, step }) => {
    const canvas = within(canvasElement);

    await step("内部リンクとして表示される", async () => {
      const link = canvas.getByRole("link", { name: "内部リンク" });
      await expect(link).toHaveAttribute("href", "/articles");
    });

    await step("外部リンクを新しいタブで開く", async () => {
      const link = canvas.getByRole("link", { name: "外部リンク" });
      await expect(link).toHaveAttribute("href", "https://example.com/");
      await expect(link).toHaveAttribute("target", "_blank");
      await expect(link).toHaveAttribute("rel", "noopener noreferrer");
    });
  },
};

ステップに分けたインタラクションテスト

テストをユーザーの行動に近い言葉で分けると、失敗した箇所を Storybook 上で追いやすくなります。実装上のクラス名よりも、アクセシブルネームやロールを使って要素を探す方が、利用者から見た振る舞いを検証できます。

Playwright のコンポーネントテスト

Playwright も、React や Vue などのコンポーネントを実ブラウザにマウントしてテストする機能を提供しています。現時点では実験的な機能ですが、通常の Playwright と同じ Locator API やアサーションを利用できます。

import { expect, test } from "@playwright/experimental-ct-react";
import { Form } from "./Form";

test("ユーザー名を入力できる", async ({ mount }) => {
  const component = await mount(<Form />);
  const username = component.getByLabel("ユーザー名");

  await username.fill("john");

  await expect(username).toHaveValue("john");
});

コンポーネントは実際のブラウザで動くため、レイアウトやブラウザ API を含む振る舞いを確かめられます。ただし、テストコードは Node.js、コンポーネントはブラウザという境界があるため、複雑なオブジェクトや同期コールバックを props に渡せないケースがあります。導入前に、その制約がプロジェクトに合うかを見ておく必要があります。

何をテストしたいのか

Storybook と Playwright のどちらを選ぶにしても、先に決めたいのはツールではなく確かめたい対象です。私は大きく次の3つに分けています。

  • props や状態によって、どのような見た目になるか
  • 必要な操作や機能が成立しているか
  • 対象ブラウザで期待どおり動くか

見た目のバリエーション

Label コンポーネントに filledoutlined があるなら、それぞれのストーリーを用意すると、差分をブラウザですぐ確認できます。

冒頭のようにクラス名を検査するテストは、リファクタリングに弱く、実際に確かめたい見た目の変化も保証できません。ストーリーで状態を並べ、重要な差分には Visual Regression Testing(VRT)を組み合わせる方が目的に合います。

ユーザー操作と機能

フォームなら、必要な入力欄があり、入力や送信後に期待する状態へ移ることを確かめます。Storybook の play 関数は、状態のカタログと操作テストを同じストーリーに置ける点が便利です。Playwright は、より広いブラウザ操作やネットワーク制御まで含めてテストしたい場合に力を発揮します。

純粋な関数や Hooks まで、すべてをブラウザテストに寄せる必要はありません。速く安定して確かめられるものは Vitest などのユニットテストに残し、実ブラウザでしか得られない確信にだけ、その実行コストを払うのがよさそうです。

ブラウザごとの差

Playwright では Chromium、Firefox、WebKit のプロジェクトを分けて、同じテストを実行できます。それでも、自動テストですべての視覚的な違和感を見つけられるわけではありません。

操作の成否はアサーションで、意図しない見た目の変化は VRT で確かめ、最終的な品質は人の目でも確認します。それぞれの得意な領域を組み合わせます。

さいごに

テストツールが増えるほど、導入すること自体が目的になりがちです。守りたい体験と、壊れたときの影響を先に決めてから、その不安を最も小さなコストで減らせる方法を選びます。StorybookもPlaywrightも、そのための選択肢です。

参考資料