Vitest Testing
提供 Vitest 单元测试与集成测试的模式与最佳实践,涵盖断言、异步测试与模拟方法。
提供 Playwright 与 Cypress 的端到端测试最佳实践,提升测试稳定性与效率。
openclaw skills install @wpank/e2e-testing-patterns命令、参数、文件名以原文为准
测试用户实际操作,而非代码实现方式。端到端测试验证系统整体运行正常——它们是你发布软件的信心来源。
npx clawhub@latest install e2e-testing-patterns提供构建端到端测试套件的模式,具备以下特性:
/\
/E2E\ ← 少量:仅关键路径(本技能覆盖范围)
/─────\
/Integr\ ← 较多:组件间交互、API 合约
/────────\
/Unit Tests\ ← 大量:快速、独立、覆盖边界情况
/────────────\| 适合 E2E 测试 ✓ | 不适合 E2E 测试 ✗ |
|---|---|
| 关键用户旅程(登录 → 仪表盘 → 操作 → 登出) | 单元级逻辑(应使用单元测试) |
| 多步骤流程(结账、引导向导) | API 合约(应使用集成测试) |
| 跨浏览器兼容性 | 边界情况(速度太慢,应使用单元测试) |
| 实际 API 集成 | 内部实现细节 |
| 认证流程 | 组件视觉状态(应使用 Storybook) |
经验法则: 如果某个功能失效会对业务造成严重打击,就用 E2E 测试;如果只是不便,就用更快的单元或集成测试来验证。
| 原则 | 原因 | 实现方式 |
|---|---|---|
| 测试行为,而非实现 | 能够抵御重构影响 | 断言用户可见结果,而非 DOM 结构 |
| 独立的测试 | 可并行执行,易于调试 | 每个测试自行创建数据,并在结束后清理 |
| 确定性的等待机制 | 避免测试不稳定 | 等待特定条件满足,而非固定超时时间 |
| 稳定的选择器 | 能够应对 UI 变更 | 使用 data-testid、角色、标签等,避免使用 CSS 类名 |
| 快速反馈 | 开发者可频繁运行 | 模拟外部服务,支持并行与分片执行 |
// 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("成功登录后跳转至仪表盘", 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) => {
// 设置:创建测试用户
const user = await createTestUser({
email: `test-${Date.now()}@example.com`,
password: "Test123!@#",
});
await use(user);
// 清理:删除测试用户
await deleteTestUser(user.id);
},
});
// 使用示例 —— testUser 在测试前创建,测试后删除
test("用户可以更新个人资料", async ({ page, testUser }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(testUser.email);
// ...
});永远不要使用固定超时。应等待特定条件达成。
// ❌ 不稳定:固定超时
await page.waitForTimeout(3000);
// ✅ 稳定:等待具体条件
await page.waitForLoadState("networkidle");
await page.waitForURL("/dashboard");
// ✅ 最佳实践:自动等待的断言
await expect(page.getByText("欢迎")).toBeVisible();
await expect(page.getByRole("button", { name: "提交" })).toBeEnabled();
// 等待 API 响应
const responsePromise = page.waitForResponse(
(r) => r.url().includes("/api/users") && r.status() === 200
);
await page.getByRole("button", { name: "加载" }).click();
await responsePromise;隔离测试与真实外部服务的影响。
# 端到端测试模式
## Playwright 测试示例test("API 失败时显示错误信息", async ({ page }) => {
// 模拟 API 响应
await page.route("**/api/users", (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: "服务器错误" }),
});
});
await page.goto("/users");
await expect(page.getByText("加载用户失败")).toBeVisible();
});
test("网络缓慢时能正常处理", async ({ page }) => {
await page.route("**/api/data", async (route) => {
await new Promise((r) => setTimeout(r, 3000)); // 模拟延迟
await route.continue();
});
await page.goto("/dashboard");
await expect(page.getByText("加载中...")).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}"]);
});
// 使用方式
cy.login("user@example.com", "password");
cy.dataCy("submit-button").click();
### 网络拦截// 模拟 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("结账表单")` | 稳定,测试专用 |
| 4 | **文本内容** | `getByText("欢迎回来")` | 面向用户 |
| ❌ | CSS 类名 | `.btn-primary` | 易受样式变更影响 |
| ❌ | DOM 结构 | `div > form > input:nth-child(2)` | 任何结构调整都会失效 |// ❌ 不推荐:脆弱的选择器
cy.get(".btn.btn-primary.submit-button").click();
cy.get("div > form > div:nth-child(2) > input").type("text");
// ✅ 推荐:稳定的选项器
page.getByRole("button", { name: "提交" }).click();
page.getByLabel("邮箱地址").fill("user@example.com");
page.getByTestId("email-input").fill("user@example.com");
---
## 视觉回归测试// Playwright 视觉对比
test("首页外观正确", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png", {
fullPage: true,
maxDiffPixels: 100,
});
});
test("按钮状态", async ({ page }) => {
const button = page.getByRole("button", { name: "提交" });
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("页面无可访问性违规", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.exclude("#third-party-widget") // 排除无法控制的内容
.analyze();
expect(results.violations).toEqual([]);
});
---
## 测试失败调试npx playwright test --headed
npx playwright test --debug
npx playwright show-report
// 添加测试步骤以提高失败报告可读性
test("结账流程", async ({ page }) => {
await test.step("将商品加入购物车", async () => {
await page.goto("/products");
await page.getByRole("button", { name: "加入购物车" }).click();
});
await test.step("完成结账", async () => {
await page.goto("/checkout");
// 如果失败,可明确知道是哪个步骤出错
});
});
// 暂停以便手动检查
await page.pause();
---
## 不稳定测试排查清单
当测试间歇性失败时,请检查:
| 问题 | 解决方案 |
|------|----------|
| 固定的 `waitForTimeout()` 调用 | 改为使用 `waitForSelector()` 或断言等待 |
| 页面加载时的竞争条件 | 等待 `networkidle` 或特定元素出现 |
| 测试数据污染 | 确保每个测试创建并清理自己的数据 |
| 动画时间不一致 | 等待动画完成或禁用动画 |
| 视口设置不一致 | 在配置中设置明确的视口尺寸 |
| 测试顺序随机导致的问题 | 测试必须相互独立 |
| 第三方服务不稳定 | 模拟外部 API 响应 |
---
## CI/CD 集成name: 端到端测试
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/
---
## 绝对不要这样做
1. **永远不要使用固定的 `waitForTimeout()` 或 `cy.wait(ms)`** — 这会导致测试不稳定并拖慢测试套件
2. **永远不要依赖 CSS 类名或 DOM 结构来选择元素** — 使用角色、标签或 `data-testid`
3. **永远不要在测试之间共享状态** — 每个测试必须完全独立
4. **永远不要测试实现细节** — 只测试用户可见和可操作的行为,而非内部结构
5. **永远不要跳过清理步骤** — 始终删除你创建的测试数据,即使测试失败也要清理
6. **永远不要用端到端测试覆盖所有场景** — 仅用于关键路径;边缘情况使用更快的测试类型
7. **永远不要忽略不稳定的测试** — 立即修复或删除;一个不稳定的测试比没有测试更糟糕
8. **永远不要在选择器中硬编码测试数据** — 使用动态等待处理内容变化的情况
---
## 快速参考
### Playwright 命令// 导航
await page.goto("/path");
await page.goBack();
await page.reload();
// 交互
await page.click("selector");
await page.fill("selector", "text");
await page.type("selector", "text"); // 逐字符输入
await page.selectOption("select", "value");
await page.check("checkbox");
// 断言
await expect(page).toHaveURL("/expected");
await expect(locator).toBeVisible();
await expect(locator).toHaveText("expected");
await expect(locator).toBeEnabled();
await expect(locator).toHaveCount(3);
### Cypress 命令// 导航
cy.visit("/path");
cy.go("back");
cy.reload();
// 交互
cy.get("selector").click();
cy.get("selector").type("text");
cy.get("selector").clear().type("text");
cy.get("select").select("value");
cy.get("checkbox").check();
// 断言
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);
已收录 6 个 Skill