用 Claude Code 做 E2E 测试: Playwright/Cypress 与 CI 实战指南
用 Claude Code 设计 Playwright E2E 测试:CI、常见坑、可运行示例和转化检查。
先决定 E2E 测试要保护什么
让 Claude Code 生成一个登录测试并通过,并不代表发布已经安全。E2E 测试会在真实浏览器里检查用户操作路径,调试成本比单元测试高,运行时间也更长。如果把所有按钮和样式都放进 E2E,CI 很快会变慢,失败也会被团队忽略。
更好的做法是先选出损失最大的路径:登录、结账、咨询表单、管理后台危险操作,以及文章里的商品或培训 CTA。Masa 在 ClaudeCodeLab 的运营中遇到过一个典型问题:页面能正常显示,但文章末尾通往培训页的链接被改坏了。这样的错误不会被普通截图发现,却会直接影响收入路径。
本文基于 2026 年 6 月确认过的 Playwright 安装文档、定位器文档、CI 文档 和 Trace Viewer,整理如何向 Claude Code 下达任务、如何写可运行的 Playwright 示例、如何放进 CI,以及 headed/headless 的常见坑。相关内容可以继续读 API 测试自动化 和 测试策略指南。
flowchart LR
A["向 Claude Code 下达任务"] --> B["选择三个关键流程"]
B --> C["实现 Playwright 测试"]
C --> D["用 trace 和报告排查"]
D --> E["作为 CI 门禁"]
C --> F["检查 CTA 和收入路径"]
给 Claude Code 的提示词模板
第一步要写清楚范围、成功条件、失败条件、可修改文件和验证命令。否则 Claude Code 很容易生成依赖 CSS 类名的脆弱测试。Playwright 官方推荐优先使用 getByRole、getByLabel、getByText、getByTestId 等定位器。定位器就是“如何找到页面元素”的规则,它能配合 Playwright 的自动等待和可重试断言。
目标: 为登录、结账、newsletter 注册写 E2E 测试
工具: Playwright Test,最后补充 Cypress 适用场景
规则:
- 优先使用 role、label、text、testid,不依赖 CSS 类名
- 不只写成功路径,也要覆盖输入错误和空购物车
- CI 中使用 workers: 1,trace: on-first-retry
- 检查收入 CTA 的 href 没有丢失
可修改范围:
- tests/e2e/**/*.spec.ts
- playwright.config.ts
验证:
- npx playwright test
- 本地排查时使用 npx playwright test --headed
同时也要说明哪些不做。真实扣款、真实邮件发送、广告点击、CRM 写入不应该每次 E2E 都直接触发。它们通常应该使用测试模式、mock,或拆到 API/契约测试里。Webhook 和分析事件可以参考 webhook 实现 与 分析实现。
安装与基本命令
新项目可以用下面的命令开始。Playwright 官方说明,每个版本都对应特定浏览器二进制文件,所以升级 Playwright 后也要重新安装浏览器。Linux CI 上还要处理系统依赖,通常使用 --with-deps。
npm init playwright@latest
npx playwright install
npx playwright test
npx playwright test --headed
npx playwright test --ui
npx playwright show-report
--headed 会打开可见浏览器,适合本地排查。--ui 会打开 Playwright 的交互式界面。CI 通常使用 headless,也就是不显示浏览器窗口。如果本地通过而 CI 失败,优先检查动画、视口、字体、时区、CPU 速度和外部 API 等待。
可直接运行的 Playwright 示例
下面的 spec 使用 page.setContent 创建一个小型页面,因此不需要启动真实应用。安装 Playwright 后,把它复制到 tests/e2e/claude-code-e2e.spec.ts 就能运行。真实项目里,把 renderDemoApp(page) 换成 page.goto('/login'),再调整标签和 data-testid。
// tests/e2e/claude-code-e2e.spec.ts
import { test, expect, type Page } from '@playwright/test';
async function renderDemoApp(page: Page) {
await page.setContent(`
<!doctype html>
<main>
<form id="login-form" aria-label="Login form">
<label>Email <input id="email" aria-label="Email" /></label>
<label>Password <input id="password" aria-label="Password" type="password" /></label>
<button type="submit">Log in</button>
<p id="login-error" role="alert" hidden></p>
</form>
<section id="dashboard" hidden>
<h1>Dashboard</h1>
<a data-testid="training-cta" href="/training/">Book Claude Code training</a>
<button id="add-plan">Add Pro plan to cart</button>
<span data-testid="cart-count">0</span>
<button id="buy">Complete purchase</button>
<p data-testid="order-status" role="status"></p>
<label>Newsletter email <input id="newsletter-email" aria-label="Newsletter email" /></label>
<button id="newsletter-button" type="button">Join newsletter</button>
<p id="newsletter-error" role="alert" hidden></p>
<p data-testid="newsletter-status" role="status"></p>
</section>
</main>
<script>
const state = { cart: 0 };
document.querySelector('#login-form').addEventListener('submit', (event) => {
event.preventDefault();
const email = document.querySelector('#email').value;
const password = document.querySelector('#password').value;
const error = document.querySelector('#login-error');
if (email === 'masa@example.com' && password === 'password123') {
document.querySelector('#login-form').hidden = true;
document.querySelector('#dashboard').hidden = false;
error.hidden = true;
return;
}
error.textContent = 'Authentication failed';
error.hidden = false;
});
document.querySelector('#add-plan').addEventListener('click', () => {
state.cart += 1;
document.querySelector('[data-testid="cart-count"]').textContent = String(state.cart);
});
document.querySelector('#buy').addEventListener('click', () => {
const status = document.querySelector('[data-testid="order-status"]');
status.textContent = state.cart === 0 ? 'Cart is empty' : 'Order ORD-1001 completed';
});
document.querySelector('#newsletter-button').addEventListener('click', () => {
const email = document.querySelector('#newsletter-email').value;
const error = document.querySelector('#newsletter-error');
const status = document.querySelector('[data-testid="newsletter-status"]');
if (!email.includes('@')) {
error.textContent = 'Enter a valid email';
error.hidden = false;
status.textContent = '';
return;
}
error.hidden = true;
status.textContent = 'Thanks, we will send the checklist.';
});
</script>
`);
}
async function loginAsDemoUser(page: Page) {
await page.getByLabel('Email').fill('masa@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log in' }).click();
}
test.describe('Claude Code E2E starter', () => {
test('use case 1: login shows dashboard and keeps the training CTA', async ({ page }) => {
await renderDemoApp(page);
await loginAsDemoUser(page);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByTestId('training-cta')).toHaveAttribute('href', '/training/');
});
test('use case 2: checkout flow creates an order number', async ({ page }) => {
await renderDemoApp(page);
await loginAsDemoUser(page);
await page.getByRole('button', { name: 'Add Pro plan to cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
await page.getByRole('button', { name: 'Complete purchase' }).click();
await expect(page.getByTestId('order-status')).toContainText('ORD-1001');
});
test('use case 3: newsletter validation blocks invalid leads', async ({ page }) => {
await renderDemoApp(page);
await loginAsDemoUser(page);
await page.getByRole('button', { name: 'Join newsletter' }).click();
await expect(page.getByRole('alert')).toContainText('Enter a valid email');
await page.getByLabel('Newsletter email').fill('reader@example.com');
await page.getByRole('button', { name: 'Join newsletter' }).click();
await expect(page.getByTestId('newsletter-status')).toContainText('Thanks');
});
});
这个文件覆盖了三个具体用例:登录、结账、线索表单。它还检查了培训 CTA 的 href,这正是很多薄弱 E2E 测试容易漏掉的地方。
实务用 Playwright 配置
配置文件要让失败可追踪。Playwright 的 CI 文档建议在 CI 中使用 workers: 1 来提高稳定性和可复现性。等测试稳定后,再考虑并行或分片。
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
trace: 'on-first-retry' 会在第一次失败后保留操作记录。把 trace 和 HTML 报告交给 Claude Code,比只贴一行超时错误更容易定位问题。
放进 GitHub Actions
CI 上常见失败不是测试代码,而是浏览器或 Linux 依赖缺失。先用简单配置,不要一开始就复杂缓存。
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: npx playwright test
- uses: actions/upload-artifact@v5
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
如果测试预览环境,把 BASE_URL 指向预览地址。如果要在 CI 里启动本地应用,再补充 Playwright 的 webServer 配置。
值得写 E2E 的用例
| 用例 | 为什么适合 E2E | 关键期待值 |
|---|---|---|
| 登录到控制台 | 认证、跳转、权限会连在一起 | 标题、用户状态、退出、错误提示 |
| 结账或咨询表单 | 直接影响收入和线索 | 订单号、确认页、防重复提交 |
| 文章到免费资料或培训页 | 保护非广告收入路径 | CTA 的 href、事件、移动端可见 |
| 管理后台危险操作 | 防止误删和越权 | 确认弹窗、审计日志、角色检查 |
| 多语言页面 | 翻译时最容易断链接 | locale URL、自然文案、外部文档 |
把这张表交给 Claude Code,可以避免它只“点击按钮”却没有验证真正重要的结果。
常见失败案例
不要使用 waitForTimeout(3000)。固定等待在本机可能通过,在 CI 中经常失败。应该等待具体状态,例如元素可见、URL 改变、状态文本出现。
不要依赖 .btn-primary:nth-child(2) 这类 CSS 选择器。设计改版会立即破坏测试。优先使用 role、label、text 和稳定的 data-testid。
不要让测试共享状态。前一个测试创建的购物车、Cookie 或数据库记录,不应该成为下一个测试的前提。并行和重试会放大这个问题。
不要忽视 headed 与 headless 的差异。字体、GPU、滚动、剪贴板、文件上传、视口都可能在 CI 和本地不同。CI 保持 headless,排查时再用 --headed 或 --ui。
不要每次都调用真实支付、邮件、广告或 CRM。使用测试模式、mock 或单独的 API 合约测试更稳定。
Playwright 与 Cypress 的取舍
| 维度 | Playwright | Cypress |
|---|---|---|
| 浏览器 | Chromium、Firefox、WebKit | Chrome 系、Firefox、Edge |
| 并行 | Playwright Test 内建支持 | 通常结合 Dashboard 设计 |
| 多标签和上下文 | 支持较强 | 限制较多 |
| 调试 | Trace Viewer、UI mode、HTML report | 交互式 GUI 友好 |
| 适合场景 | CI、多浏览器、认证状态隔离 | 已有 Cypress 流程的前端团队 |
Cypress 仍然是好工具。如果团队已有 Cypress 资产,不必为了追新而迁移。可以先给一个关键流程补 Playwright,比较 flake 率、trace 质量和 CI 时间。最终判断要以 Playwright 和 Cypress 官方文档为准。
变现 CTA 与实际验证结果
E2E 也在保护收入。咨询链接、商品卡、免费资料表单、培训 CTA 坏掉,可能比一个视觉问题更贵。个人练习可以从免费 Claude Code 速查表开始,需要复用提示词时看产品与模板。团队要把 CI 门禁、CLAUDE.md、评审规则和 E2E 责任边界落地,可以从Claude Code 培训与咨询开始。
本文的示例已按 TypeScript 代码片段抽出并做了语法验证。最有用的习惯是把业务期待写进测试名,例如“保留培训 CTA”。真实项目建议先写三条:登录、结账或线索表单、文章 CTA。等它们在 CI 中稳定后,再扩展到后台和多语言页面。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。