壊れにくいUIテストの設計を考える(Playwright + Storybook + React + GitHub Actions)
私は普段、開発に携わっていない(= 実装を触らない)アプリのテストを MagicPod で実装することが多いのですが、壊れにくいテストを作る難しさを強く感じています。
特に悩ましいのは以下の2つを満たす粒度です。
- 仕様変更には追従できる(多少のUI変更で壊れない)
- でも不具合は検知できる(本当に守りたい挙動が崩れたら落ちてほしい)
このバランスを、テスト設計・ロケータ設計・運用ルールでどう支えるかを考えてみたくなり、
Vite + React + TypeScript の小さな検証リポジトリを作りました。
また、E2Eテストのケース数が増えると実行時間が長くなりがちです。
なので、テストの階層ごとの適切な役割分担についても考えてみました。
この記事では、導入手順そのものよりも、設計の意図を中心に整理します。
※実際に作ったリポジトリは以下です。
先に決めたこと
このリポジトリでは、最初に次の2つを決めました。
- テストの役割を分ける
- data-testidの運用をルール化する
意図はシンプルです。
- E2E を増やしすぎない(実行時間が伸びがち)
- コンポーネント単位でもブラウザ上の動きを確認できるようにする
- テストの書き方を人ごとにバラつかせない
その結果、E2E / Component Test / Storybook testを同居させつつ、
data-testidは共有定数経由でしか使えない構成にしました。
全体構成
ディレクトリはおおむね次のようにしています。
src/
components/
ExampleCard.tsx
ExampleCard.stories.tsx
ExampleCard.ct.spec.tsx
testids/
common/
app.ts
pages/
home.ts
index.ts
App.tsx
tests/
e2e/
home.spec.ts
playwright.config.ts
playwright-ct.config.ts
.storybook/
.github/workflows/ci.yml
README.md
この構成で意識したのは、責務に近い場所へ置くことです。
- E2Eテストはtests/e2e
- ComponentTestはコンポーネントの近く
- Storyもコンポーネントの近く
- data-testidはcommon(共通コンポーネントで使う想定)とpages(共通コンポーネント以外で使う想定のid)に分割
これにより、どこに何を書くかが明確になります。
data-testidは単一ファイルではなく責務ごとに分ける
最初は1ファイルにまとめることも考えましたが、画面数が増えると次の問題が出やすいです。
- 差分が1ファイルに集中する
- どのidがどの画面の責務か見えにくい
- 命名衝突を避けるコストが上がる
そこで、共通とページ単位に分ける構成にしました。
(例えば、以下のような感じです。)
export const appQa = {
shell: 'app-shell'
} as const;
export const homeQa = {
exampleCard: {
root: 'example-card',
title: 'example-card-title',
form: 'example-card-form',
button: 'example-card-button',
nameInput: 'example-card-name-input',
applyButton: 'example-card-apply-button',
message: 'example-card-message'
}
} as const;
export * from './common/app';
export * from './pages/home';
この形にすると、共通の外枠はappQaが担い、ホーム画面固有の要素はhomeQaが担うという感じで責務が見える名前で扱えます。
この設計の意図をまとめると、以下のような感じです。
- 画面単位で変更差分を閉じ込めやすい
- テストコードから見たときに、どの画面の要素かすぐ分かる
- 将来ページが増えても、
pages/<page>.tsを増やすだけでよい
コンポーネントとテストは同じ定数を見る
data-testidの管理で一番大事なのは、コンポーネントとテストが同じ定数を参照することです。
コンポーネント側はこうです。
import { useState } from "react";
import { homeQa } from "../testids";
type ExampleCardProps = {
title: string;
};
export function ExampleCard({ title }: ExampleCardProps) {
const [count, setCount] = useState(0);
const [draftName, setDraftName] = useState("");
const [displayName, setDisplayName] = useState("未設定");
const applyDisplayName = () => {
setDisplayName(draftName || "未設定");
};
return (
<section className="card" data-testid={homeQa.exampleCard.root}>
<p className="eyebrow">共有testidは1つの定義に集約しています。</p>
<h1 data-testid={homeQa.exampleCard.title}>{title}</h1>
<p>
目に見える要素の操作はroleベース、安定した構造の参照はgetByTestId
を使います。
</p>
<form
className="inline-form"
data-testid={homeQa.exampleCard.form}
onSubmit={(event) => {
event.preventDefault();
applyDisplayName();
}}
>
<label className="field">
<span>表示テキスト</span>
<input
type="text"
value={draftName}
data-testid={homeQa.exampleCard.nameInput}
onChange={(event) => setDraftName(event.target.value)}
/>
</label>
<button
type="submit"
className="button button-secondary"
data-testid={homeQa.exampleCard.applyButton}
>
反映する
</button>
</form>
<p className="message" data-testid={homeQa.exampleCard.message}>
現在の表示: {displayName}
</p>
<button
type="button"
className="button"
data-testid={homeQa.exampleCard.button}
onClick={() => setCount((value) => value + 1)}
>
クリック回数: {count}
</button>
</section>
);
}
E2E側も同じ定数をimportして使います。
import { test, expect } from '@playwright/test';
import { appQa, homeQa } from '../../src/testids';
test('トップページで locator 方針どおりに操作できる', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId(appQa.shell)).toBeVisible();
await expect(page.getByRole('heading', { name: /playwright \+ storybook\s*の基本構成/i })).toBeVisible();
await expect(page.getByTestId(homeQa.exampleCard.form)).toBeVisible();
await page.getByLabel('表示テキスト').fill('E2E 確認');
await page.getByRole('button', { name: '反映する' }).click();
await expect(page.getByTestId(homeQa.exampleCard.message)).toHaveText('現在の表示: E2E 確認');
await page.getByRole('button', { name: /クリック回数/i }).click();
await expect(page.getByTestId(homeQa.exampleCard.button)).toHaveText('クリック回数: 1');
});
test('入力フォームを空で送信すると未設定に戻る', async ({ page }) => {
await page.goto('/');
await page.getByLabel('表示テキスト').fill('フォーム確認');
await page.getByRole('button', { name: '反映する' }).click();
await expect(page.getByTestId(homeQa.exampleCard.message)).toHaveText('現在の表示: フォーム確認');
await page.getByLabel('表示テキスト').fill('');
await page.getByRole('button', { name: '反映する' }).click();
await expect(page.getByTestId(homeQa.exampleCard.message)).toHaveText('現在の表示: 未設定');
});
この設計の意図をまとめると、以下のようになります。
- 文字列変更があっても修正箇所が
src/testidsに集約される - UI実装とテスト実装のズレを減らせる
- getByTestIdを最優先にしても、属人的な命名になりにくい
ロケーター方針について
アクセシビリティ観点から、getByRoleを最優先にする方針もあるかと思います。
一方で今回は、壊れにくさの観点からtestidを使うことを推奨する感じになっています。
ただし、testidに寄せすぎるとテストが実装詳細っぽくなって読みづらくなることも考えられるかと思います。
そのため、次のように使い分けることを考えました。
- 操作の意図を表現したい箇所については、getByRole / getByLabelを使う(ボタン操作、フォーム入力など)
- 安定参照したい箇所については、getByTestIdを使う(表示結果、コンポーネントのルートなど)
- CSSやXPathについては最後の手段(原則禁止)
この方針はREADMEに明文化して、ブレないようにするのが良いかと思います。
テストレイヤーごとに役割を分ける
今回、テストは3層に分けました。
- E2E
- Storybook test
- Component Testing(CT)
重要なのは、全部をE2Eにしないことです。
※簡単に各テストごとの役割を表にまとめました。
| レイヤー | 目的 | 粒度 | 速度 | 失敗時の原因特定 | 主に置くケース |
|---|---|---|---|---|---|
| E2E | 代表導線の回帰テスト | 画面〜アプリ全体 | 遅め | 難しめ | 起動〜画面表示〜主要操作、致命バグの再発防止 |
| Storybook test | UIカタログ兼インタラクション検証 | 1 Story | 速め | しやすい | UI状態の分岐、見た目と操作のセット検証 |
| Component Test | コンポーネント単体のブラウザ挙動 | コンポーネント | 速め | しやすい | 状態遷移、イベント、フォーム挙動など |
※このリポジトリではvitestも入れており、純粋なロジック(関数・変換・バリデーションなど)はユニットテストに寄せます。
UIの導線や状態遷移をE2Eに寄せすぎないための受け皿として用意しています。
E2Eの役割
E2Eは、ページ全体の導線確認に使います。
- アプリ起動
- 画面表示
- フォーム操作
- 最終的な表示確認
ただし、細かい分岐を全部E2Eに寄せると重くなります。
そのため、このリポジトリでは「代表的な正常系」と「本当に画面全体で見たい挙動」だけに絞っています。
Storybook testの役割
Storybook testは、UIカタログと一緒にインタラクションを確認する役割です。
※以下にコードの例を記載しておきます。
import type { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { ExampleCard } from './ExampleCard';
import { homeQa } from '../testids';
const meta = {
title: 'Components/ExampleCard',
component: ExampleCard,
args: {
title: 'Storybookのインタラクション確認'
},
parameters: {
docs: {
description: {
component: `このStoryではdata-testidをページ単位の定数から参照しています。例: homeQa.exampleCard.nameInput = "${homeQa.exampleCard.nameInput}"`
}
}
}
} satisfies Meta<typeof ExampleCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Interactions: Story = {
args: {
title: '操作確認済み'
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const onUnhandled = fn();
await userEvent.clear(canvas.getByLabelText('表示テキスト'));
await userEvent.type(canvas.getByLabelText('表示テキスト'), 'Storybook 反映');
await userEvent.click(canvas.getByRole('button', { name: '反映する' }));
await expect(canvas.getByTestId(homeQa.exampleCard.message)).toHaveTextContent('現在の表示: Storybook 反映');
await userEvent.click(canvas.getByRole('button', { name: /クリック回数/i }));
await expect(canvas.getByTestId(homeQa.exampleCard.button)).toHaveTextContent('クリック回数: 1');
expect(onUnhandled).not.toHaveBeenCalled();
}
};
ここでは、play関数で入力やクリックを行い、expectでUIの変化を確認しています。
ComponentTestの役割
ComponentTestは、ブラウザで動くコンポーネント単体テストとして使っています。
import { expect, test } from '@playwright/experimental-ct-react';
import { ExampleCard } from './ExampleCard';
import { homeQa } from '../testids';
test('コンポーネントテストでカウントを増やす', async ({ mount }) => {
const component = await mount(<ExampleCard title="コンポーネントテスト" />);
await expect(component.getByRole('heading', { name: 'コンポーネントテスト' })).toBeVisible();
await component.getByLabel('表示テキスト').fill('CT 反映');
await component.getByRole('button', { name: '反映する' }).click();
await expect(component.getByTestId(homeQa.exampleCard.message)).toContainText('現在の表示: CT 反映');
await component.getByRole('button', { name: /クリック回数/i }).click();
await expect(component.getByTestId(homeQa.exampleCard.button)).toContainText('クリック回数: 1');
});
役割分担の意図
- E2Eテストは画面全体の導線確認
- Storybook testはStoryに紐づく操作確認
- ComponentTestはコンポーネント単位のブラウザ挙動確認
この分け方にしておくと、E2Eを増やしすぎずにテストの網羅性を上げやすくなります。
data-testid="literal"はESLintで禁止する
運用ルールに限った話ではないですが、口約束を守り続けられるほど人間は強くないと思うので、
仕組み化が重要だと考えます。
そこで、data-testidの文字列直書きをESLintで禁止しました。
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
const localTestIdPlugin = {
rules: {
// ここでdata-testidの文字列直書きを禁止しています。
'no-literal-testid': {
meta: {
type: 'problem',
docs: {
description: 'disallow string literals in JSX data-testid attributes'
},
messages: {
noLiteral:
'data-testid must reference a shared test id constant instead of a string literal.'
},
schema: []
},
create(context) {
const isDataTestIdAttribute = (node) =>
node.type === 'JSXAttribute' &&
node.name.type === 'JSXIdentifier' &&
node.name.name === 'data-testid';
const isLiteralValue = (node) => {
if (!node) {
return false;
}
if (node.type === 'Literal' && typeof node.value === 'string') {
return true;
}
if (node.type === 'JSXExpressionContainer') {
const expression = node.expression;
if (expression.type === 'Literal' && typeof expression.value === 'string') {
return true;
}
if (
expression.type === 'TemplateLiteral' &&
expression.expressions.length === 0 &&
expression.quasis.length === 1
) {
return true;
}
}
return false;
};
return {
JSXAttribute(node) {
if (!isDataTestIdAttribute(node)) {
return;
}
if (isLiteralValue(node.value)) {
context.report({
node,
messageId: 'noLiteral'
});
}
}
};
}
}
}
};
export default tseslint.config(
{
ignores: ['dist', 'storybook-static', 'playwright-report', 'test-results', 'playwright/.cache']
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname
}
},
plugins: {
'local-testid': localTestIdPlugin,
'react-hooks': reactHooks,
'react-refresh': reactRefresh
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'local-testid/no-literal-testid': 'error'
}
}
);
このルールで、以下はエラーになります。
<input data-testid="login-email" />
一方で、共有定数の参照は通ります。
<input data-testid={homeQa.exampleCard.nameInput} />
CIでも同じ粒度で回す
ローカルだけ整っていても、PRで崩れると意味がありません。
そのため、GitHub Actionsでも以下のように同じレイヤーを回す構成にしました。
- lint
- test:storybook
- test:ct
- test:e2e
さらに、Playwrightのブラウザ依存をインストールし、失敗時にはplaywright-reportと、
test-resultsをartifactとして回収します。
※参考までに、.github/workflows/ci.ymlとpackage.jsonの記載例を載せておきます。
name: CI
on:
push:
branches: ['**']
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install
- run: npx playwright install --with-deps chromium
- run: npm run lint
- run: npm run test:unit
- run: npm run test:storybook
- run: npm run test:ct
- run: npm run test:e2e
- name: Upload Playwright artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-artifacts
path: |
playwright-report
test-results
if-no-files-found: ignore
{
"name": "nextjs-playwright-storybook-lab",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "npm run test:unit",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:ct": "playwright test -c playwright-ct.config.ts",
"storybook": "mkdir -p .local-home && HOME=$(pwd)/.local-home STORYBOOK_DISABLE_TELEMETRY=1 storybook dev -p 6006",
"build-storybook": "mkdir -p .local-home && HOME=$(pwd)/.local-home STORYBOOK_DISABLE_TELEMETRY=1 storybook build",
"build-storybook:ci": "mkdir -p .local-home && HOME=$(pwd)/.local-home CI=1 STORYBOOK_DISABLE_TELEMETRY=1 storybook build",
"storybook:serve": "python3 -m http.server 6006 -d storybook-static",
"storybook:serve:test": "python3 -m http.server 6100 -d storybook-static",
"test:storybook:runner": "test-storybook --url http://127.0.0.1:6100",
"test:storybook": "npm run build-storybook:ci && start-server-and-test 'npm run storybook:serve:test' tcp:6100 'npm run test:storybook:runner'"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.20.0",
"@playwright/experimental-ct-react": "^1.52.0",
"@playwright/test": "^1.52.0",
"@storybook/addon-essentials": "8.6.17",
"@storybook/addon-interactions": "8.6.17",
"@storybook/react": "8.6.17",
"@storybook/react-vite": "8.6.17",
"@storybook/test": "8.6.17",
"@storybook/test-runner": "^0.23.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.0",
"@types/node": "^22.13.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.4.0",
"eslint": "^9.20.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.15.0",
"jsdom": "^26.0.0",
"playwright": "^1.52.0",
"start-server-and-test": "^2.0.10",
"storybook": "8.6.17",
"typescript": "^5.7.0",
"typescript-eslint": "^8.24.0",
"vite": "^6.2.0",
"vitest": "^3.0.0"
}
}
この構成にしておくと、
- ローカルでは動いたのにCIで落ちる
- どの層のテストが失敗したのか分からない
- 失敗時の調査材料が残らない
といった問題を減らせるのではないかと考えます。
まとめ
壊れにくいテストと、不具合を検知できるテストの両立は簡単ではありません。
ただ、テスト階層の役割分担と、testidを定数化して運用する仕組みをセットで設計すると、
現実的な落とし所を作りやすいのではないかと思いました。
私は開発経験もテスト自動化経験もあるので、プロダクトコードとテストコードを連動させて品質を上げる運用を試してみたいと思いました。
Discussion