E2E Testing Patterns
2026-03-28
新闻来源:网淘吧
围观:16
电脑广告
手机广告
端到端测试模式
测试用户行为,而非代码实现。端到端测试验证系统整体运行——它们是您交付产品的信心保障。
安装说明
OpenClaw / Moltbot / Clawbot
npx clawhub@latest install e2e-testing-patterns
技能功能说明
提供构建端到端测试套件的模式,可实现:
- 在用户发现问题前捕获回归缺陷
- 满足CI/CD流程的速度要求
- 保持测试稳定性(避免偶发性失败)
- 覆盖核心用户流程,避免过度测试
适用场景
- 为Web应用程序实施端到端测试自动化
- 调试间歇性失败的不稳定性测试
- 搭建包含浏览器测试的CI/CD测试流水线
- 测试核心用户工作流(身份验证、支付结算、注册流程)
- 如何选择端到端测试与单元/集成测试的适用场景
测试金字塔——了解你的层级
/\
/E2E\ ← FEW: Critical paths only (this skill)
/─────\
/Integr\ ← MORE: Component interactions, API contracts
/────────\
/Unit Tests\ ← MANY: Fast, isolated, cover edge cases
/────────────\
端到端测试的用途
| 适合进行端到端测试的内容 ✓ | 不适合进行端到端测试的内容 ✗ |
|---|---|
| 关键用户旅程(登录 → 仪表板 → 操作 → 登出) | 单元级别的逻辑(应使用单元测试) |
| 多步骤流程(结账、新用户引导向导) | API 契约(应使用集成测试) |
| 跨浏览器兼容性 | 边界情况(速度太慢,应使用单元测试) |
| 真实的 API 集成 | 内部实现细节 |
| 身份验证流程 | 组件视觉状态(应使用 Storybook) |
经验法则:如果某项功能出错会严重损害你的业务,就进行端到端测试。如果只是带来不便,就用单元/集成测试更快地测试它。
核心原则
| 原则 | 原因 | 方法 |
|---|---|---|
| 测试行为,而非实现 | 在重构中保持稳定 | 基于用户可见的结果进行断言,而非DOM结构 |
| 独立测试 | 可并行化,易于调试 | 每个测试创建自己的数据,并在完成后清理 |
| 确定性等待 | 无随机性失败 | 等待条件满足,而非固定超时 |
| 稳定的选择器 | 在UI变更中保持稳定 | 使用data-testid、角色、标签——永远不要使用CSS类 |
| 快速反馈 | 由开发者运行 | 模拟外部服务、并行化、分片 |
Playwright模式
配置
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 30000,
expect: { timeout: 5000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["html"], ["junit", { outputFile: "results.xml" }]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile", use: { ...devices["iPhone 13"] } },
],
});
模式:页面对象模型
封装页面逻辑。测试读起来像用户故事。
// pages/LoginPage.ts
import { Page, Locator } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.loginButton = page.getByRole("button", { name: "Login" });
this.errorMessage = page.getByRole("alert");
}
async goto() {
await this.page.goto("/login");
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
}
// tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage";
test("successful login redirects to dashboard", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("user@example.com", "password123");
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
模式:测试数据夹具
自动创建和清理测试数据。
// fixtures/test-data.ts
import { test as base } from "@playwright/test";
export const test = base.extend<{ testUser: TestUser }>({
testUser: async ({}, use) => {
// Setup: Create user
const user = await createTestUser({
email: `test-${Date.now()}@example.com`,
password: "Test123!@#",
});
await use(user);
// Teardown: Clean up
await deleteTestUser(user.id);
},
});
// Usage — testUser is created before, deleted after
test("user can update profile", async ({ page, testUser }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(testUser.email);
// ...
});
模式:智能等待
切勿使用固定的超时时间。等待特定条件。
// ❌ FLAKY: Fixed timeout
await page.waitForTimeout(3000);
// ✅ STABLE: Wait for conditions
await page.waitForLoadState("networkidle");
await page.waitForURL("/dashboard");
// ✅ BEST: Auto-waiting assertions
await expect(page.getByText("Welcome")).toBeVisible();
await expect(page.getByRole("button", { name: "Submit" })).toBeEnabled();
// Wait for API response
const responsePromise = page.waitForResponse(
(r) => r.url().includes("/api/users") && r.status() === 200
);
await page.getByRole("button", { name: "Load" }).click();
await responsePromise;
模式:网络模拟
将测试与真实的外部服务隔离。
test("shows error when API fails", async ({ page }) => {
// Mock the API response
await page.route("**/api/users", (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: "Server Error" }),
});
});
await page.goto("/users");
await expect(page.getByText("Failed to load users")).toBeVisible();
});
test("handles slow network gracefully", async ({ page }) => {
await page.route("**/api/data", async (route) => {
await new Promise((r) => setTimeout(r, 3000)); // Simulate delay
await route.continue();
});
await page.goto("/dashboard");
await expect(page.getByText("Loading...")).toBeVisible();
});
Cypress 模式
自定义命令
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
dataCy(value: string): Chainable<JQuery<HTMLElement>>;
}
}
}
Cypress.Commands.add("login", (email, password) => {
cy.visit("/login");
cy.get('[data-testid="email"]').type(email);
cy.get('[data-testid="password"]').type(password);
cy.get('[data-testid="login-button"]').click();
cy.url().should("include", "/dashboard");
});
Cypress.Commands.add("dataCy", (value) => {
return cy.get(`[data-cy="${value}"]`);
});
// Usage
cy.login("user@example.com", "password");
cy.dataCy("submit-button").click();
网络拦截
// Mock API
cy.intercept("GET", "/api/users", {
statusCode: 200,
body: [{ id: 1, name: "John" }],
}).as("getUsers");
cy.visit("/users");
cy.wait("@getUsers");
cy.get('[data-testid="user-list"]').children().should("have.length", 1);
选择器策略
| 优先级 | 选择器类型 | 示例 | 原因 |
|---|---|---|---|
| 1 | 角色 + 名称 | getByRole("button", { name: "提交" }) | 可访问,面向用户 |
| 2 | 标签 | getByLabel("电子邮件地址") | 可访问,语义化 |
| 3 | data-testid | getByTestId("checkout-form") | 稳定,明确用于测试 |
| 4 | 文本内容 | getByText("欢迎回来") | 面向用户 |
| ❌ | CSS 类 | .btn-primary | 样式变更时失效 |
| ❌ | DOM 结构 | div > form > input:nth-child(2) | 任何结构重组时失效 |
// ❌ BAD: Brittle selectors
cy.get(".btn.btn-primary.submit-button").click();
cy.get("div > form > div:nth-child(2) > input").type("text");
// ✅ GOOD: Stable selectors
page.getByRole("button", { name: "Submit" }).click();
page.getByLabel("Email address").fill("user@example.com");
page.getByTestId("email-input").fill("user@example.com");
视觉回归测试
// Playwright visual comparisons
test("homepage looks correct", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png", {
fullPage: true,
maxDiffPixels: 100,
});
});
test("button states", async ({ page }) => {
const button = page.getByRole("button", { name: "Submit" });
await expect(button).toHaveScreenshot("button-default.png");
await button.hover();
await expect(button).toHaveScreenshot("button-hover.png");
});
无障碍测试
// npm install @axe-core/playwright
import AxeBuilder from "@axe-core/playwright";
test("page has no accessibility violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.exclude("#third-party-widget") // Exclude things you can't control
.analyze();
expect(results.violations).toEqual([]);
});
调试失败的测试
# Run in headed mode (see the browser)
npx playwright test --headed
# Debug mode (step through)
npx playwright test --debug
# Show trace viewer for failed tests
npx playwright show-report
// Add test steps for better failure reports
test("checkout flow", async ({ page }) => {
await test.step("Add item to cart", async () => {
await page.goto("/products");
await page.getByRole("button", { name: "Add to Cart" }).click();
});
await test.step("Complete checkout", async () => {
await page.goto("/checkout");
// ... if this fails, you know which step
});
});
// Pause for manual inspection
await page.pause();
不稳定测试检查清单
当测试间歇性失败时,请检查:
| 问题 | 修复 |
|---|---|
已修复waitForTimeout()调用 | 替换为waitForSelector()或 expect 断言 |
| 页面加载时的竞态条件 | 等待网络空闲或特定元素 |
| 测试数据污染 | 确保测试创建/清理自己的数据 |
| 动画时序 | 等待动画完成或禁用动画 |
| 视口不一致 | 在配置中设置明确的视口 |
| 随机测试顺序问题 | 测试必须相互独立 |
| 第三方服务不稳定性 | 模拟外部API |
CI/CD集成
# GitHub Actions example
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npm run start & npx wait-on http://localhost:3000
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
绝对不要
- 绝对不要使用固定的
waitForTimeout()或cy.wait(ms)——它们会导致测试不稳定并拖慢测试套件 - 绝对不要依赖CSS类或DOM结构作为选择器——使用角色、标签或data-testid
- 切勿在测试之间共享状态——每个测试必须完全独立
- 切勿测试实现细节——测试用户所见和所操作的内容,而非内部结构
- 切勿跳过清理步骤——始终删除创建的测试数据,即使测试失败
- 切勿对所有内容进行端到端测试——仅针对关键路径使用;对边缘情况采用更快的测试方法
- 切勿忽视不稳定的测试——立即修复或删除;不稳定的测试比没有测试更糟糕
- 切勿在定位器中硬编码测试数据——对动态内容使用动态等待
快速参考
Playwright 命令
// Navigation
await page.goto("/path");
await page.goBack();
await page.reload();
// Interactions
await page.click("selector");
await page.fill("selector", "text");
await page.type("selector", "text"); // Types character by character
await page.selectOption("select", "value");
await page.check("checkbox");
// Assertions
await expect(page).toHaveURL("/expected");
await expect(locator).toBeVisible();
await expect(locator).toHaveText("expected");
await expect(locator).toBeEnabled();
await expect(locator).toHaveCount(3);
Cypress 命令
// Navigation
cy.visit("/path");
cy.go("back");
cy.reload();
// Interactions
cy.get("selector").click();
cy.get("selector").type("text");
cy.get("selector").clear().type("text");
cy.get("select").select("value");
cy.get("checkbox").check();
// Assertions
cy.url().should("include", "/expected");
cy.get("selector").should("be.visible");
cy.get("selector").should("have.text", "expected");
cy.get("selector").should("have.length", 3);
文章底部电脑广告
手机广告位-内容正文底部
上一篇:Learning
下一篇:Qq Zone Photo


微信扫一扫,打赏作者吧~