e2e-tests-studio について
e2e-tests-studioは、フロントエンド変更のPlaywright E2Eテストを作成・検証するスキルです。画面表示だけでなく、ユーザーが確認できる製品の動作、BDD形式の構成、現実的なフィクスチャを重視します。
主な機能
- 01製品の動作を中心にテスト
- 023段階のBDD構造を徹底
- 03機能を観測可能な結果に対応付け
- 04現実的なテストフィクスチャを案内
- 05永続化とエラー回復を検証
- 06ビルドとテストのコマンドを提示
活用シーン
- 01エージェント設定が応答に与える影響のテスト
- 02ツール実行とワークフローのデータ連携の検証
- 03複数ターンの会話での文脈維持の確認
- 04画面状態テストから動作テストへの移行
MCP 対応
ブラウザテストにはPlaywright MCPサーバーが必要です。ブラウザナビゲーションツールがない場合の追加方法も記載されています。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mastra-ai/ mastra --skill e2e-tests-studioインストール後、Claude Code で/e2e-tests-studioを入力して読み込まれたか確認します。
詳細
フロントエンド変更のE2E動作検証
基本原則:UIの状態ではなく、プロダクトの動作をテストする
重要:テストでは、UI要素が表示されるかどうかだけでなく、プロダクトの機能が正しく動作することを検証する必要があります。
テストしないこと(UIの状態):
- ❌「クリックするとドロップダウンが開く」
- ❌「ボタンのクリック後にモーダルが表示される」
- ❌「リクエスト中に読み込みスピナーが表示される」
- ❌「フォームのフィールドが表示されている」
- ❌「サイドバーが折りたたまれる」
テストすること(プロダクトの動作):
- ✅「LLM プロバイダーを選択すると、そのプロバイダーを使用するようエージェントが設定される」
- ✅「新しいエージェントを作成すると保存され、エージェント一覧に表示される」
- ✅「パラメーターを指定してツールを実行すると、期待どおりの出力が返される」
- ✅「チャットメッセージが正しくストリーミングされ、会話のコンテキストが維持される」
- ✅「ワークフローを実行すると、正しい順序でツールが呼び出される」
BDD構造(必須)
すべてのE2E仕様は、MSWテストと同じBDD構造に従う必要があります。 packages/playground では、e2e-bdd/test-needs-when-describe によってこの構造が強制されます。
構造は、以下のちょうど3つのレベルで構成されます。
- 外側の
test.describe=テスト対象の単位(ファイルごとに1つのページまたは機能)。 - 内側の
test.describe('when …')=前提条件をちょうど1つ指定します。タイトルは必ずwhenで始めます。 - 各
test=観測可能な結果をちょうど1つ指定します。
import { test, expect } from '@playwright/test';
import { resetStorage } from '../__utils__/reset-storage';
test.describe('Tools list page', () => {
// the unit
test.afterEach(async () => {
await resetStorage();
});
test.describe('when a registered tool is clicked', () => {
// ONE precondition (starts with "when")
test('navigates to that tool detail page', async ({ page }) => {
// ONE outcome
await page.goto('/tools');
await page.locator('text=Get current weather for a location').click();
await expect(page).toHaveURL(/\/tools\/weatherInfo$/);
});
test('shows the tool name as the page heading', async ({ page }) => {
// ONE outcome
await page.goto('/tools');
await page.locator('text=Get current weather for a location').click();
await expect(page.locator('h2')).toHaveText('weatherInfo');
});
});
});
ルール:
- ファイルごとに、テスト対象の単位を名前にした外側の
test.describeを1つだけ置きます。 - すべての末端の
testは、前提条件を示すtest.describe('when …')グループ内に置きます。トップレベルに単独のtest()を置いてはいけません。 - 複数のアサーションを含む
test()は、アサーションが個別の結果を表す場合にのみ分割します。1つの結果を証明する密接に関連したアサーションはまとめておきます。アサーションを削除してはいけません。 beforeEach/afterEachは、それらを必要とする最も狭いdescribeスコープに配置します。
前提条件
Playwright MCP サーバーが必要です。browser_navigate ツールを使用できない場合は、ユーザーに追加するよう案内してください。
claude mcp add playwright -- npx @playwright/mcp@latest
ステップ1:機能の意図を理解する
テストを書く前に、必ず次の質問に答えてください。
- この機能は、ユーザーのどのような問題を解決しますか?
- 機能が正しく動作した場合、どのような結果が期待されますか?
- システム内をどのようなデータが流れますか?(ユーザー入力 → API → 状態 → UI)
- ページを再読み込みした後も、何が保持されるべきですか?
- この操作によって、どのような後続の影響が生じるべきですか?
これらの回答を、テストファイル内のコメントとして記載してください。
ステップ2:ビルドして起動する
pnpm build:cli
cd packages/playground/e2e/kitchen-sink && pnpm dev
http://localhost:4111 でサーバーが起動していることを確認します。
ステップ3:機能を動作テストに対応付ける
機能とテストの対応付けガイド
| 機能カテゴリ | テスト内容 | アサーションの例 |
|---|---|---|
| エージェント設定 | 設定の変更がエージェントの動作に反映されること | メッセージを送信 → 選択したモデルが応答に使用されていることを確認 |
| LLM プロバイダーの選択 | 選択したプロバイダーがリクエストで使用されること | API呼び出しをインターセプト → リクエストペイロード内のプロバイダーを確認 |
| ツールの実行 | ツールが正しいパラメーターで実行され、結果を返すこと | ツールを実行 → 出力が期待される変換結果と一致することを確認 |
| ワークフローの実行 | ステップが順番に実行され、データがステップ間を流れること | ワークフローを実行 → 各ステップの出力が次のステップに渡されることを確認 |
| チャット/ストリーミング | メッセージが保持され、ターンをまたいでコンテキストが維持されること | 複数ターンの会話 → コンテキストが認識されていることを確認 |
| MCP サーバーのツール | サーバーのツールを呼び出せて、データが返されること | MCP ツールを呼び出し → 応答の構造と内容を確認 |
| メモリー/永続化 | ページを再読み込みしてもデータが保持されること | 項目を作成 → 再読み込み → 項目が存在することを確認 |
| エラー処理 | エラーが正しくユーザーに通知されること | エラー条件を発生させる → エラーメッセージと復旧を確認 |
ステップ4:動作に焦点を当てたテストを書く
テスト構造のテンプレート
import { test, expect, Page } from '@playwright/test';
import { resetStorage } from '../__utils__/reset-storage';
import { selectFixture } from '../__utils__/select-fixture';
import { nanoid } from 'nanoid';
/**
* FEATURE: [Name of feature]
* USER STORY: As a user, I want to [action] so that [outcome]
* BEHAVIOR UNDER TEST: [Specific behavior being validated]
*/
test.describe('[Feature Name] - Behavior Tests', () => {
let page: Page;
test.beforeEach(async ({ browser }) => {
const context = await browser.newContext();
page = await context.newPage();
});
test.afterEach(async () => {
await resetStorage(page);
});
test.describe('when [the single precondition for these outcomes]', () => {
test('[verb describing the single observable outcome]', async () => {
// ARRANGE: Set up preconditions
// - Navigate to the feature
// - Configure any required state
// ACT: Perform the user action that triggers the behavior
// ASSERT: Verify the OUTCOME, not the UI state
// - Check data persistence
// - Verify downstream effects
// - Confirm API calls made correctly
});
});
});
動作テストのパターン
パターン 1:設定が動作に影響する
test.describe('when a different LLM provider is selected', () => {
test('uses that provider for agent responses', async () => {
// ARRANGE
await page.goto('/agents/my-agent/chat');
// Intercept API to verify provider
let capturedProvider: string | null = null;
await page.route('**/api/chat', route => {
const body = JSON.parse(route.request().postData() || '{}');
capturedProvider = body.provider;
route.continue();
});
// ACT: Select a different provider
await page.getByTestId('provider-selector').click();
await page.getByRole('option', { name: 'OpenAI' }).click();
// Send a message to trigger the agent
await page.getByTestId('chat-input').fill('Hello');
await page.getByTestId('send-button').click();
// ASSERT: Verify the selected provider was used
await expect.poll(() => capturedProvider).toBe('openai');
});
});
パターン 2:データの永続化
test.describe('when a new agent is created', () => {
test('persists after page reload', async () => {
// ARRANGE
await page.goto('/agents');
const agentName = `Test Agent ${nanoid()}`;
// ACT: Create new agent
await page.getByTestId('create-agent-button').click();
await page.getByTestId('agent-name-input').fill(agentName);
await page.getByTestId('save-agent-button').click();
// Wait for creation to complete
await expect(page.getByText(agentName)).toBeVisible();
// ASSERT: Verify persistence
await page.reload();
await expect(page.getByText(agentName)).toBeVisible({ timeout: 10000 });
});
});
パターン 3:ツールの実行で正しい出力が得られる
test.describe('when the weather tool is executed with a city', () => {
test('returns formatted weather data for that city', async () => {
// ARRANGE
await selectFixture(page, 'weather-success');
await page.goto('/tools/weather-tool');
// ACT: Execute tool with parameters
await page.getByTestId('param-city').fill('San Francisco');
await page.getByTestId('execute-tool-button').click();
// ASSERT: Verify OUTPUT content, not just that output appears
const output = page.getByTestId('tool-output');
await expect(output).toContainText('temperature');
await expect(output).toContainText('San Francisco');
// Verify structured data if applicable
const outputText = await output.textContent();
const outputData = JSON.parse(outputText || '{}');
expect(outputData).toHaveProperty('temperature');
expect(outputData).toHaveProperty('conditions');
});
});
パターン 4:ワークフローのステップの連結
test.describe('when a multi-step workflow is run', () => {
test('passes data between steps correctly', async () => {
// ARRANGE
await selectFixture(page, 'workflow-multi-step');
const sessionId = nanoid();
await page.goto(`/workflows/data-pipeline?session=${sessionId}`);
// ACT: Trigger workflow execution
await page.getByTestId('workflow-input').fill('test input data');
await page.getByTestId('run-workflow-button').click();
// ASSERT: Verify each step received correct input from previous step
// Wait for completion
await expect(page.getByTestId('workflow-status')).toHaveText('completed', { timeout: 30000 });
// Check step outputs show data transformation chain
const step1Output = await page.getByTestId('step-1-output').textContent();
const step2Output = await page.getByTestId('step-2-output').textContent();
// Verify step 2 received step 1's output as input
expect(step2Output).toContain(step1Output);
});
});
パターン 5:コンテキストを使用したストリーミングチャット
test.describe('when a multi-turn conversation is held', () => {
test('maintains conversation context across messages', async () => {
// ARRANGE
await selectFixture(page, 'contextual-chat');
const chatId = nanoid();
await page.goto(`/agents/assistant/chat/${chatId}`);
// ACT: Multi-turn conversation
await page.getByTestId('chat-input').fill('My name is Alice');
await page.getByTestId('send-button').click();
await expect(page.getByTestId('assistant-message').last()).toBeVisible({ timeout: 20000 });
await page.getByTestId('chat-input').fill('What is my name?');
await page.getByTestId('send-button').click();
// ASSERT: Verify context was maintained
const response = page.getByTestId('assistant-message').last();
await expect(response).toContainText('Alice', { timeout: 20000 });
});
});
パターン 6:エラーからの回復
test.describe('when the API fails during tool execution', () => {
test('shows an actionable error and allows a successful retry', async () => {
// ARRANGE: Set up failure fixture
await selectFixture(page, 'api-failure');
await page.goto('/tools/flaky-tool');
// ACT: Trigger the error
await page.getByTestId('execute-tool-button').click();
// ASSERT: Error is shown with recovery option
await expect(page.getByTestId('error-message')).toContainText('failed');
await expect(page.getByTestId('retry-button')).toBeVisible();
// Switch to success fixture and retry
await selectFixture(page, 'api-success');
await page.getByTestId('retry-button').click();
// Verify recovery worked
await expect(page.getByTestId('tool-output')).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId('error-message')).not.toBeVisible();
});
});
ステップ 5:既存のテストを更新する
テストファイルがすでに存在する場合:
- 既存のテストを読み、現在のテスト範囲を把握します
- テストが UI 中心か、動作中心かを確認します
- UI 中心のテストをリファクタリングし、代わりに動作を検証します:
リファクタリングの例
変更前(UI 中心):
test('dropdown opens when clicked', async () => {
await page.getByTestId('model-dropdown').click();
await expect(page.getByRole('listbox')).toBeVisible();
});
変更後(動作中心+BDD のネスト):
test.describe('when a model is selected from the dropdown', () => {
test('updates and persists the agent configuration', async () => {
// Open dropdown and select model
await page.getByTestId('model-dropdown').click();
await page.getByRole('option', { name: 'GPT-4' }).click();
// Verify the selection persists and affects behavior
await page.reload();
await expect(page.getByTestId('model-dropdown')).toHaveText('GPT-4');
// Optionally: verify the model is used in actual requests
// (via request interception or checking response metadata)
});
});
ステップ 6:動作テスト用のキッチンシンクフィクスチャ
フィクスチャには、単なるモックデータではなく、現実的なシナリオを表すものを用意します:
フィクスチャの命名規則
<feature>-<scenario>.fixture.ts
Examples:
- agent-with-tools.fixture.ts
- chat-multi-turn-context.fixture.ts
- workflow-parallel-execution.fixture.ts
- tool-validation-error.fixture.ts
- mcp-server-timeout.fixture.ts
フィクスチャの内容要件
各フィクスチャでは、次の内容を定義する必要があります:
- シナリオの説明(テスト可能にする動作)
- 期待される結果(パスするべきアサーション)
- 対象となるエッジケース(エラー状態、空の状態など)
// fixtures/agent-provider-switch.fixture.ts
export const agentProviderSwitch = {
name: 'agent-provider-switch',
description: 'Tests that switching LLM providers changes agent behavior',
// Mock responses for different providers
responses: {
openai: { content: 'Response from OpenAI', model: 'gpt-4' },
anthropic: { content: 'Response from Anthropic', model: 'claude-3' },
},
expectedBehavior: {
// When provider is switched, subsequent messages use new provider
providerSwitchAffectsNextMessage: true,
// Provider selection persists across page reload
providerPersistsOnReload: true,
},
};
ステップ 7:実行して検証する
cd packages/playground && pnpm test:e2e
テスト品質チェックリスト
テストが完了したと判断する前に、次の点を確認してください:
- 各テストに、ユーザーストーリーを明確に示すコメントがある
- 外側に置く
test.describeは 1 つで、テスト対象の単位に名前を付けている - すべての
testがtest.describe('when …')の前提条件ブロック内にネストされている(トップレベルにフラットなtest()を置かない) - 各
testが、観測可能な結果をちょうど 1 つだけアサートしている - 中間的な UI 状態ではなく、結果を検証している
- 機能が壊れた場合にテストが失敗する(UI が変更された場合にのみ失敗するのではない)
- 該当する場合、
page.reload()を使って永続性を検証している - エラーのシナリオを網羅している
- 非同期処理に適切なタイムアウトを使用している
- フィクスチャが現実的な使用シナリオを表している
クイックリファレンス
| ステップ | コマンド/操作 |
|---|---|
| ビルド | pnpm build:cli |
| 起動 | cd packages/playground/e2e/kitchen-sink && pnpm dev |
| アプリ URL | http://localhost:4111 |
| ルート | @packages/playground/src/App.tsx |
| テスト実行 | cd packages/playground && pnpm test:e2e |
| テストディレクトリ | packages/playground/e2e/tests/ |
| フィクスチャ | packages/playground/e2e/kitchen-sink/fixtures/ |
避けるべきアンチパターン
| ❌ しないこと | ✅ 代わりにすること |
|---|---|
| モーダルが開くことをテストする | モーダルの操作が完了し、その結果が永続化されることをテストする |
| ボタンがクリック可能であることをテストする | ボタンのクリックで期待される結果が得られることをテストする |
| 読み込みスピナーが表示されることをテストする | 読み込まれたデータが正しいことをテストする |
| フォームの検証メッセージが表示されることをテストする | 無効なフォームは送信できず、かつ有効なフォームは送信できることをテストする |
| ドロップダウンに選択肢があることをテストする | 選択肢を選ぶことでシステムの動作が変わることをテストする |
| サイドバーのナビゲーションが機能することをテストする | 遷移先のページに正しいデータ/機能があることをテストする |
| 要素が表示されていることをアサートする | 要素に期待されるデータ/状態が含まれていることをアサートする |
前提条件の describe がないトップレベルのフラットな test() | すべての test を test.describe('when …') ブロック内にネストする |
1 つの test() で複数の無関係な結果をアサートする | 観測可能な結果ごとに 1 つの test() を作成する |