关于 e2e-tester
面向使用 Playwright 的端到端测试编写技能,帮助团队探索真实页面流程并据此编写测试。它涵盖页面对象复用、选择器、等待、断言、模拟和测试运行等实践。
主要功能
- 01先评估端到端测试价值
- 02使用 MCP 探索页面流程
- 03页面对象与辅助代码复用
- 04选择器和等待策略
- 05模拟、断言与调试指导
适用场景
- 01为登录或注册流程编写端到端测试
- 02依据页面快照验证真实选择器
- 03整理页面对象并复用测试辅助代码
- 04排查测试中的等待或断言问题
MCP 相关
若可用,技能要求在编写测试前使用 Playwright MCP 工具导航页面、获取快照、交互并截图,以确认流程和选择器。
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ pageai-pro/ ralph-loop --skill e2e-tester安装后在 Claude Code 中输入/e2e-tester确认已加载。
详细介绍
开始之前:
- 检查是否已有测试覆盖你要测试的功能
- 检查单元测试是否能更好地覆盖该功能
- 分析端到端测试是否比单元测试更有价值
如果以上任一项的答案为“是”,则继续创建端到端测试。
MCP 工作流(如果可用则必须执行)
⚠️ 如果你有 Playwright MCP 工具,创建任何测试之前都必须先使用它们:
- 导航到目标页面
- 拍摄快照,查看页面结构和元素
- 与表单/元素交互,验证用户流程的准确步骤
- 截取屏幕截图,记录预期状态
- 验证页面转换,覆盖完整流程(加载、成功、错误)
- 记录快照中的实际选择器(使用真实的引用和标签)
- 只有在完成探索之后,才能使用已验证的选择器编写测试代码
如果 MCP 不可用: 根据文档和代码分析创建测试。
这样做的重要性:
- ✅ 测试精准——步骤准确无误,无需猜测
- ✅ 选择器准确——基于真实 DOM 结构,而非想象
- ✅ 验证真实流程——确认用户旅程确实可行
- ✅ 避免过度设计——针对现有功能编写最精简的测试
- ✅ 防止测试不稳定——实际探索能带来稳定性
- ❌ 切勿臆测 UI“应该”如何工作
等待策略(至关重要)
// ❌ NEVER use fixed waits
await page.waitForTimeout(2000);
// ✅ Wait for specific conditions
await expect(element).toBeVisible();
await page.waitForResponse(resp => resp.url().includes('/api/data'));
await page.waitForURL('**/dashboard');
await expect(page.getByText('Success')).toBeVisible({ timeout: 10000 });
// ✅ For SPAs, prefer explicit waits over networkidle
await page.goto('/app', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('main')).toBeVisible();
文件结构
tests/
├── base-page.ts # Parent class for ALL pages
├── helpers.ts # Shared utilities
└── {page-name}/
├── {page-name}-page.ts # Page Object Model
├── {page-name}.spec.ts # ALL tests here (NO separate files!)
└── {page-name}.md # Test documentation
文件命名:
- ✅
sign-up.spec.ts(所有注册测试) - ✅
sign-up-page.ts(页面对象) - ✅
sign-up.md(文档) - ❌
sign-up-critical-path.spec.ts(错误——不要创建单独文件) - ❌
sign-up-validation.spec.ts(错误)
选择器优先级(必须遵守)
// 1. BEST - getByRole for interactive elements
this.submitButton = page.getByRole("button", { name: "Submit" });
this.navLink = page.getByRole("link", { name: "Dashboard" });
// 2. BEST - getByLabel for form controls
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
// 3. SPARINGLY - getByText for static content only
this.errorMessage = page.getByText("Invalid credentials");
this.pageTitle = page.getByText("Welcome");
// 4. LAST RESORT - getByTestId when above fail
this.customWidget = page.getByTestId("date-picker");
// ❌ AVOID fragile selectors
this.button = page.locator(".btn-primary"); // NO
this.input = page.locator("#email"); // NO
范围判断(存在歧义时请询问)
| 用户表述 | 操作 |
|---|---|
| “一个测试”“一个新测试”“新增测试”“添加测试” | 在现有 spec 中创建一个 test() |
| “全面的测试”“所有测试”“测试套件”“生成测试” | 创建完整测试套件 |
示例:
- “为用户注册创建一个测试” → 仅创建一个测试
- “为登录页面生成端到端测试” → 创建完整测试套件
- “添加一个测试以验证表单校验” → 在现有 spec 中添加一个测试
页面对象模式
import { Page, Locator, expect } from "@playwright/test";
// BasePage - ALL pages extend this
export class BasePage {
constructor(protected page: Page) {}
async goto(path: string): Promise<void> {
await this.page.goto(path);
await this.page.waitForLoadState("networkidle");
}
// Common methods go here (see Refactoring Guidelines)
async waitForNotification(): Promise<void> {
await this.page.waitForSelector('[role="status"]');
}
async verifyNotificationMessage(message: string): Promise<void> {
const notification = this.page.locator('[role="status"]');
await expect(notification).toContainText(message);
}
}
// Page-specific implementation
export interface LoginData {
email: string;
password: string;
}
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.submitButton = page.getByRole("button", { name: "Sign in" });
}
async goto(): Promise<void> {
await super.goto("/login");
}
async login(data: LoginData): Promise<void> {
await this.emailInput.fill(data.email);
await this.passwordInput.fill(data.password);
await this.submitButton.click();
}
async verifyCriticalOutcome(): Promise<void> {
await expect(this.page).toHaveURL("/dashboard");
}
}
重用页面对象(至关重要)
创建新的页面对象之前,务必先检查是否已有可用对象!
// ✅ GOOD: Reuse existing page objects
import { SignInPage } from "../sign-in/sign-in-page";
import { HomePage } from "../home/home-page";
test("User can sign up and login", async ({ page }) => {
const signUpPage = new SignUpPage(page);
const signInPage = new SignInPage(page); // REUSE
const homePage = new HomePage(page); // REUSE
await signUpPage.signUp(userData);
await homePage.verifyPageLoaded(); // REUSE method
await homePage.signOut(); // REUSE method
await signInPage.login(credentials); // REUSE method
});
// ❌ BAD: Recreating existing functionality
export class SignUpPage extends BasePage {
async logout() { /* ... */ } // ❌ HomePage already has this
async login() { /* ... */ } // ❌ SignInPage already has this
}
指南:
- 先检查
tests/中是否已有页面对象 - 导入并重用现有页面
- 仅在页面对象不存在时才创建
- 如果测试需要多个页面,请确保所有页面对象均已存在(需要时创建)
重构指南
以下情况移至 BasePage:
- ✅ 多个页面都会用到的导航辅助方法(
waitForPageLoad()、getCurrentUrl()) - ✅ 通用 UI 交互(通知、模态框、主题切换)
- ✅ 多个页面中重复出现的验证模式(
isVisible()、waitForVisible()) - ✅ 适用于所有页面的错误处理
- ✅ 用于调试的屏幕截图工具
以下情况移至 helpers.ts:
- ✅ 测试数据生成(
generateUniqueEmail()、generateTestUser()) - ✅ 设置/清理辅助工具(
createTestUser()、cleanupTestData()) - ✅ 自定义断言(
expectNotificationToContain()) - ✅ 用于测试设置的 API 辅助工具(
seedDatabase()、resetState()) - ✅ 时间辅助工具(
waitForCondition()、retryAction())
之前(错误):
// Repeated in multiple page objects
export class SignUpPage extends BasePage {
async waitForNotification(): Promise<void> {
await this.page.waitForSelector('[role="status"]');
}
}
export class SignInPage extends BasePage {
async waitForNotification(): Promise<void> {
await this.page.waitForSelector('[role="status"]'); // DUPLICATED!
}
}
之后(正确):
// BasePage - shared across all pages
export class BasePage {
async waitForNotification(): Promise<void> {
await this.page.waitForSelector('[role="status"]');
}
}
// helpers.ts - data generation
export function generateUniqueEmail(): string {
return `test.${Date.now()}@example.com`;
}
export function generateTestUser() {
return {
name: "Test User",
email: generateUniqueEmail(),
password: "TestPassword123!",
};
}
带标签的测试模式
import { test, expect } from "@playwright/test";
import { LoginPage } from "./login-page";
test.describe("Login", () => {
test("User can login successfully",
{ tag: ["@critical", "@e2e", "@login", "@LOGIN-E2E-001"] },
async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login({ email: "[email protected]", password: "pass123" });
await expect(page).toHaveURL("/dashboard");
}
);
});
标签类别:
- 优先级:
@critical、@high、@medium、@low - 类型:
@e2e - 功能:
@signup、@signin、@dashboard - 测试 ID:
@SIGNUP-E2E-001、@LOGIN-E2E-002
测试文档格式({page-name}.md)
### E2E Tests: {Feature Name}
**Suite ID:** `{SUITE-ID}`
**Feature:** {Feature description}
---
## Test Case: `{TEST-ID}` - {Test case title}
**Priority:** `{critical|high|medium|low}`
**Tags:**
- type → @e2e
- feature → @{feature-name}
**Description/Objective:** {Brief description}
**Preconditions:**
- {Prerequisites for test to run}
- {Required data or state}
### Flow Steps:
1. {Step 1}
2. {Step 2}
3. {Step 3}
### Expected Result:
- {Expected outcome 1}
- {Expected outcome 2}
### Key verification points:
- {Assertion 1}
- {Assertion 2}
### Notes:
- {Additional considerations}
文档规则:
- ❌ 不要包含通用的测试运行说明
- ❌ 不要解释文件结构
- ❌ 不要提供代码示例或教程
- ❌ 不要添加故障排除章节
- ✅ 仅聚焦于特定测试用例
- ✅ 尽可能控制在 60 行以内
身份验证状态复用
// auth.setup.ts - Run once, reuse across tests
import { test as setup } from "@playwright/test";
setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Password").fill("password");
await page.getByRole("button", { name: "Sign in" }).click();
await page.waitForURL("/dashboard");
await page.context().storageState({ path: ".auth/user.json" });
});
// playwright.config.ts
projects: [
{ name: "auth", testMatch: /auth\.setup\.ts/ },
{ name: "logged-in", dependencies: ["auth"], use: { storageState: ".auth/user.json" } },
{ name: "logged-out", use: { storageState: { cookies: [], origins: [] } } }
]
API 模拟
// Mock API responses for isolated tests
await page.route("**/api/users", route =>
route.fulfill({ json: { users: [{ id: 1, name: "Test" }] } })
);
// Mock error states
await page.route("**/api/submit", route =>
route.fulfill({ status: 500, json: { error: "Server error" } })
);
// Abort requests (e.g., block analytics)
await page.route("**/analytics/**", route => route.abort());
测试固件
// fixtures.ts - Custom fixtures for setup/teardown
import { test as base } from "@playwright/test";
import { AdminPage } from "./admin/admin-page";
export const test = base.extend<{ adminPage: AdminPage }>({
adminPage: async ({ page }, use) => {
const admin = new AdminPage(page);
await admin.goto();
await use(admin);
// Teardown runs after test
},
});
// Usage in tests
test("admin can manage users", async ({ adminPage }) => {
await adminPage.deleteUser("[email protected]");
});
并行执行
// Tests run in parallel by default. Use serial when tests share state:
test.describe.configure({ mode: "serial" });
// Isolate test data to prevent conflicts
test("create user", async ({ page }) => {
const uniqueEmail = `user-${Date.now()}@test.com`; // ✅ Unique per run
});
断言
// Soft assertions - collect multiple failures
await expect.soft(page.getByText("Title")).toBeVisible();
await expect.soft(page.getByText("Subtitle")).toBeVisible();
// Test continues, reports all failures at end
// Polling assertions - retry until condition met
await expect(async () => {
const count = await page.getByRole("listitem").count();
expect(count).toBeGreaterThan(5);
}).toPass({ timeout: 10000 });
// Visual regression
await expect(page).toHaveScreenshot("dashboard.png");
await expect(page.getByRole("dialog")).toHaveScreenshot();
常见陷阱
// ❌ Element detached after navigation
const button = page.getByRole("button", { name: "Submit" });
await button.click();
await button.click(); // May fail if page navigated
// ✅ Re-query after navigation
await page.getByRole("button", { name: "Submit" }).click();
await page.getByRole("button", { name: "Confirm" }).click();
// ❌ Race condition with animations
await element.click();
// ✅ Wait for animation to complete
await element.click();
await expect(modal).toBeVisible();
// Iframes
const frame = page.frameLocator("#iframe-id");
await frame.getByRole("button").click();
// Shadow DOM - Playwright pierces by default, but for closed shadow:
await page.locator("custom-element").locator("internal:shadow=button").click();
移动设备与视口测试
// In test file
test.use({ viewport: { width: 375, height: 667 } });
// Device emulation
import { devices } from "@playwright/test";
test.use({ ...devices["iPhone 13"] });
// Or in playwright.config.ts projects
projects: [
{ name: "desktop", use: { viewport: { width: 1280, height: 720 } } },
{ name: "mobile", use: { ...devices["iPhone 13"] } },
]
调试与跟踪
# Enable traces on failure (recommended for CI)
npx playwright test --trace on-first-retry
# View trace file
npx playwright show-trace trace.zip
# Debug mode - step through test
npx playwright test --debug
# Headed mode to see browser
npx playwright test --headed
// In playwright.config.ts
use: {
trace: "on-first-retry", // Capture trace on retry
screenshot: "only-on-failure", // Screenshot on failure
video: "retain-on-failure", // Video on failure
}
CI/CD 配置
// playwright.config.ts for CI
export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [["html"], ["github"], ["junit", { outputFile: "results.xml" }]]
: [["html"]],
use: {
trace: "on-first-retry",
screenshot: "only-on-failure",
},
});
# GitHub Actions example
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
运行测试(优先运行部分测试)
始终优先运行特定测试,而非整个测试套件,以便更快获得反馈:
# Run ALL tests
npx playwright test
# ✅ PREFERRED: Run specific file
npx playwright test tests/login/login.spec.ts
# ✅ PREFERRED: Run specific folder
npx playwright test tests/login/
# ✅ PREFERRED: Run by test name (grep)
npx playwright test --grep "login"
npx playwright test --grep "user can sign up"
# ✅ PREFERRED: Run by tag
npx playwright test --grep "@critical"
npx playwright test --grep "@LOGIN-E2E-001"
# ✅ Run single test by line number
npx playwright test tests/login/login.spec.ts:42
# ✅ Run tests matching multiple patterns
npx playwright test --grep "login|signup"
# ✅ Exclude tests by pattern
npx playwright test --grep-invert "slow"
其他命令
npx playwright test --debug # Debug mode (step through)
npx playwright test --headed # See browser
npx playwright test --trace on # Enable tracing
npx playwright test --project=mobile # Run specific project
npx playwright codegen # Record tests
部分字符串匹配(对稳健性至关重要)
始终使用部分匹配/模式匹配,而非精确字符串匹配,以减少维护工作:
// ❌ FRAGILE: Exact string matches break easily
await expect(page.getByText("Welcome to Our Application!")).toBeVisible();
await expect(page.getByRole("button", { name: "Submit Form" })).toBeVisible();
await expect(notification).toHaveText("Your account has been created successfully.");
// ✅ ROBUST: Partial matches survive copy changes
await expect(page.getByText(/welcome/i)).toBeVisible();
await expect(page.getByRole("button", { name: /submit/i })).toBeVisible();
await expect(notification).toContainText("account");
await expect(notification).toContainText(/created/i);
// ✅ ROBUST: Use toContainText over toHaveText
await expect(page.locator(".message")).toContainText("success");
// ✅ ROBUST: Pattern matching for dynamic content
await expect(page.getByText(/order #\d+/i)).toBeVisible();
await expect(page.getByText(/\d+ items? in cart/i)).toBeVisible();
部分匹配的优势:
- ✅ 能适应轻微的文案/文本变更
- ✅ 适用于不同语言区域设置(不区分大小写)
- ✅ 对空格变更不那么敏感
- ✅ 长期维护更轻松
- ❌ 精确匹配会因标点、大小写或措辞调整而失效
评论
支持 Markdown