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の実装をもとにしています。
Fequest は Feature Request の略で、 プロダクトに対して機能リクエストを送れるサービスです。
ほしいとつくるを共有するプラットフォーム
ユーザーがほしい機能をリクエストし、開発者がそれをつくるにつなげる、
みんなでプロダクトを育てる場所です。
Fequest 自体にも機能リクエストが送れます。プロダクト登録も誰でもできます。
このプロダクトでは、今回紹介する 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 テストに進む前の不確実性を抑えた状態で、テストコードを書くことができます。 ぜひ、参考にしてみてください。