关于 e2e-tests-studio
e2e-tests-studio 是用于编写和验证前端改动 Playwright 端到端测试的技能。它强调用户可观察到的产品行为、规范的 BDD 测试结构和真实场景夹具,而非只检查界面状态。
主要功能
- 01围绕产品行为设计测试
- 02规范三级 BDD 结构
- 03将功能映射为可观察结果
- 04指导编写真机场景夹具
- 05覆盖持久化与错误恢复
- 06提供构建和测试命令
适用场景
- 01测试 Agent 配置是否影响响应
- 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 提供方后,Agent 会使用该提供方"
- ✅ "创建新 Agent 后会持久化保存,并显示在 Agent 列表中"
- ✅ "使用参数运行工具会返回预期输出"
- ✅ "聊天消息能正确流式输出,并保持对话上下文"
- ✅ "工作流执行会按正确顺序触发工具"
BDD 结构(必须遵循)
每个 E2E 规范都必须遵循与 MSW 测试相同的 BDD 结构。 在 packages/playground 中,e2e-bdd/test-needs-when-describe 会强制执行该结构。
该结构只有三层:
- 外层
test.describe:被测单元(每个文件对应一个页面或功能)。 - 内层
test.describe('when …'):恰好一个前置条件。标题必须以when开头。 - 每个
test:恰好一个可观察的结果。
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,用于命名被测单元。 - 每个叶子
test都必须位于某个test.describe('when …')前置条件分组内。不允许顶层扁平的test()。 - 仅当多个断言代表不同结果时才拆分多断言的
test();紧密耦合、用于证明同一结果的断言应保持在一起。绝不能丢弃任何断言。 - 将
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 步:将功能映射为行为测试
功能到测试的映射指南
| 功能类别 | 测试内容 | 示例断言 |
|---|---|---|
| Agent 配置 | 配置变更会影响 Agent 行为 | 发送消息 → 验证响应使用了所选模型 |
| 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 步:用于行为测试的全能型 Fixture
Fixture 应体现真实场景,而不仅仅是模拟数据:
Fixture 命名约定
<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
Fixture 内容要求
每个 Fixture 必须定义:
- 场景描述(它用于测试哪些行为)
- 预期结果(哪些断言应当通过)
- 覆盖的边界情况(错误状态、空状态等)
// 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命名被测单元 - 每个
test都嵌套在test.describe('when …')前置条件块中(不存在扁平的顶层test()) - 每个
test只断言一个可观察的结果 - 测试验证的是结果,而非中间 UI 状态
- 如果功能出现故障,测试会失败(而不只是 UI 发生变化)
- 在适用的情况下,通过
page.reload()验证持久化 - 覆盖了错误场景
- 测试为异步操作使用了合适的超时时间
- Fixture 体现真实的使用场景
快速参考
| 步骤 | 命令/操作 |
|---|---|
| 构建 | 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/ |
| Fixture | packages/playground/e2e/kitchen-sink/fixtures/ |
应避免的反模式
| ❌ 不要 | ✅ 应当改为 |
|---|---|
| 测试模态框是否打开 | 测试模态框操作能否完成并被持久化 |
| 测试按钮是否可点击 | 测试点击按钮是否产生预期结果 |
| 测试加载动画是否出现 | 测试加载后的数据是否正确 |
| 测试表单校验消息是否显示 | 测试无效表单无法提交,且有效表单能够成功提交 |
| 测试下拉框是否有选项 | 测试选择某个选项是否改变系统行为 |
| 测试侧边栏导航是否可用 | 测试导航后的页面是否具有正确的数据和功能 |
| 断言元素可见 | 断言元素包含预期的数据或状态 |
顶层扁平 test(),没有前置条件 describe | 将每个 test 嵌套在 test.describe('when …') 块中 |
一个 test() 断言多个互不相关的结果 | 每个可观察结果对应一个 test() |
评论
支持 Markdown