Sジブンノート

Playwright の POM を Storybook 上で確認してから E2E テストを書く

はじめに

Playwright で E2E テストを書く際、playwright codegen や、近年では Playwright MCP を利用して、テストコードの雛形を作成することが多いと思います。

ただし、生成したテストコードが正しく動作するかどうかは、実際に E2E テストを実行するまで確認できません。

E2E テストは、単体テストなどと比べて、実行環境の準備が重く、実行時間が長くなりやすい傾向があります。また、実行環境の影響を受けやすく、テストが不安定になる場合もあります。

そのため、テストコードを書いてから動作確認を行うまでの往復に、想像以上の時間がかかることがあります。

Storybook を使った事前検証という考え方

この課題に対して、私は Playwright のテストコードを、最初から実アプリケーションに向けて実行するのではなく、Storybook をテスト対象として利用する方法を取っています。

Storybook では、ページ単位のコンポーネントを描画対象とします。 そのページ単位のコンポーネントの近くにPage Object Model(POM)を配置し、まずは Storybook 上で POM の動作確認を行います。

この段階の目的は、E2E テストを書くことではありません。 POM が意図した要素を取得できるか、想定した操作が成立するかを、軽量な環境で確認することにあります。

本記事で扱う実例について

本記事で紹介しているテスト構成は、個人で開発している Web サービス、Fequestの実装をもとにしています。

https://fequest.vercel.app

Fequest は Feature Request の略で、 プロダクトに対して機能リクエストを送れるサービスです。

ほしいとつくるを共有するプラットフォーム
ユーザーがほしい機能をリクエストし、開発者がそれをつくるにつなげる、
みんなでプロダクトを育てる場所です。

Fequest 自体にも機能リクエストが送れます。プロダクト登録も誰でもできます。

https://fequest.vercel.app/8

このプロダクトでは、今回紹介する Storybook と Playwright を組み合わせた POM の検証方法や、Cucumber と Testcontainers を用いた E2E テスト構成を実際に採用しています。

ソースコードは公開しており、テスト周りの実装も含めて確認できます。

https://github.com/silverbirder/fequest

フォルダ構成の例

この構成を前提としたフォルダ構成は、以下のようになります。

src/
├─ components/
│  ├─ Product.tsx
│  └─ Product.stories.tsx
├─ e2e/
│  ├─ product.page.ts
│  └─ product.page.spec.ts
├─ features/
│  ├─ product.feature.md
│  └─ product.steps.ts
├─ playwright.config.ts
├─ cucumber.config.js
└─ package.json

ページ単位コンポーネントの例

以下は、管理画面のプロダクトページを想定した、ページ単位コンポーネントの例です。

// src/components/Product.tsx
type Props = {
  product: {
    name: string;
    description?: string;
    features: { id: number; title: string }[];
  };
  onDelete?: () => void;
};

export function Product({ product, onDelete }: Props) {
  return (
    <div>
      <h1>プロダクトの管理</h1>

      <section>
        <h2>{product.name}</h2>
        {product.description && <p>{product.description}</p>}
      </section>

      <ul>
        {product.features.length === 0 ? (
          <li>リクエストはまだありません</li>
        ) : (
          product.features.map((feature) => (
            <li key={feature.id}>{feature.title}</li>
          ))
        )}
      </ul>

      <button onClick={onDelete}>
        プロダクトを削除
      </button>
    </div>
  );
}

Storybook の用意

上記のコンポーネントに対して、表示と操作確認に必要な最小限の Storybook を用意します。

// src/components/Product.stories.tsx
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
import { Product } from "./Product";

const meta = {
  title: "Feature/Product",
  component: Product,
  args: {
    product: {
      id: 1,
      name: "サンプルプロダクト",
      description: "ユーザーからの要望を集めるプロダクトです。",
      features: [
        { id: 1, title: "プロフィール画像アップロード" },
        { id: 2, title: "管理画面のフィルタ機能" },
      ],
    },
    onDelete: async () => {
      console.log("delete product");
    },
  },
} satisfies Meta<typeof Product>;

export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

Page Object Model の実装

ページ単位コンポーネントに対応する POM を実装します。 以下は、表示確認と基本操作のみを持つ簡易的な例です。

// src/e2e/product.page.ts
import { expect, type Page } from "@playwright/test";

export class ProductPage {
  constructor(private page: Page) {}

  async goto(url: string) {
    await this.page.goto(url, { waitUntil: "networkidle" });
  }

  async expectVisible() {
    await expect(
      this.page.getByRole("heading", { name: "プロダクトの管理" }),
    ).toBeVisible();
  }

  async deleteProduct() {
    await this.page.getByRole("button", { name: "プロダクトを削除" }).click();
    await this.page.waitForLoadState("networkidle");
  }
}

この POM は、Storybook と実アプリケーションのどちらのテストからも再利用できます。

Storybook を対象とした Playwright テスト

まずは Storybook を対象に、POM の動作確認を行います。

// src/e2e/product.page.spec.ts
import { test } from "@playwright/test";
import { ProductPage } from "./product.page";

test("product page (storybook)", async ({ page }) => {
  const productPage = new ProductPage(page);

  await productPage.goto(
    "/iframe.html?id=feature-product--default",
  );

  await productPage.expectVisible();
});

Playwright と Storybook の設定

Storybook を起動した状態で、Playwright のテストを実行するための設定例です。

{
  "scripts": {
    "storybook:e2e": "STORYBOOK_PORT=6000 playwright test"
  },
  "devDependencies": {
    "@playwright/test": "~1.57.0",
    "playwright": "~1.57.0",
    "@storybook/nextjs-vite": "^10.1.10"
  }
}
// playwright.config.ts
import { defineConfig } from "@playwright/test";

const port = Number(process.env.STORYBOOK_PORT ?? 6000);

export default defineConfig({
  testDir: "./src/e2e",
  use: {
    baseURL: `http://localhost:${port}`,
    viewport: { width: 1280, height: 720 },
  },
  webServer: {
    command: `storybook dev -p ${port}`,
    port,
    reuseExistingServer: true,
    timeout: 120_000,
  },
});

E2E テストへの展開

Storybook 上で POM の動作確認ができたら、次に実アプリケーションを対象としたE2E テストを記述します。

私は、受け入れ駆動と使い捨てE2E環境が好みなので、Cucumber と Testcontainers を組み合わせています。

シナリオ: プロダクト管理画面を表示できる
  前提 管理画面が起動している
  もし プロダクト管理画面を開く
  ならば プロダクト管理画面が表示される
// src/features/product.steps.ts
import { Given, When, Then } from "@cucumber/cucumber";
import { ProductPage } from "../e2e/product.page";

let productPage: ProductPage;

Given("管理画面が起動している", async function () {
  // Testcontainers で adminUrl, page が用意済み
  productPage = new ProductPage(this.page);
});

When("プロダクト管理画面を開く", async function () {
  await productPage.goto(
    `${this.adminUrl}/products/${this.productId}`,
  );
});

Then("プロダクト管理画面が表示される", async function () {
  await productPage.expectVisible();
});

おわりに

この構成では、POM の検証と E2E テストの記述を分離できます。

Storybook を中間地点として利用することで、E2E テストに進む前の不確実性を抑えた状態で、テストコードを書くことができます。 ぜひ、参考にしてみてください。