e2e-testing by hieutrtr/ai1-skills
npx skills add https://github.com/hieutrtr/ai1-skills --skill e2e-testing在以下场景激活此技能:
不要在以下场景使用此技能:
react-testing-patterns)pytest-patterns)tdd-workflow)pytest-patterns)e2e/
├── playwright.config.ts # 全局Playwright配置
├── fixtures/
│ ├── auth.fixture.ts # 认证状态设置
│ └── test-data.fixture.ts # 测试数据创建/清理
├── pages/
│ ├── base.page.ts # 包含共享方法的基页面对象
│ ├── login.page.ts # 登录页面对象
│ ├── users.page.ts # 用户列表页面对象
│ └── user-detail.page.ts # 用户详情页面对象
├── tests/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── logout.spec.ts
│ ├── users/
│ │ ├── create-user.spec.ts
│ │ ├── edit-user.spec.ts
│ │ └── list-users.spec.ts
│ └── smoke/
│ └── critical-paths.spec.ts
└── utils/
├── api-helpers.ts # 用于测试设置的直接API调用
└── test-constants.ts # 共享常量
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
命名约定:
<feature>.spec.ts<page-name>.page.ts<concern>.fixture.ts每个页面都有一个页面对象类,封装选择器和操作。测试从不直接与选择器交互。
基础页面对象:
// e2e/pages/base.page.ts
import { type Page, type Locator } from "@playwright/test";
export abstract class BasePage {
constructor(protected readonly page: Page) {}
/** 导航到页面的URL。 */
abstract goto(): Promise<void>;
/** 等待页面完全加载。 */
async waitForLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
}
/** 获取toast/通知消息。 */
get toast(): Locator {
return this.page.getByRole("alert");
}
/** 获取页面标题。 */
get heading(): Locator {
return this.page.getByRole("heading", { level: 1 });
}
}
具体页面对象:
// e2e/pages/users.page.ts
import { type Page, type Locator } from "@playwright/test";
import { BasePage } from "./base.page";
export class UsersPage extends BasePage {
// ─── 定位器 ─────────────────────────────────────────
readonly createButton: Locator;
readonly searchInput: Locator;
readonly userTable: Locator;
constructor(page: Page) {
super(page);
this.createButton = page.getByTestId("create-user-btn");
this.searchInput = page.getByRole("searchbox", { name: /search users/i });
this.userTable = page.getByRole("table");
}
// ─── 操作 ──────────────────────────────────────────
async goto(): Promise<void> {
await this.page.goto("/users");
await this.waitForLoad();
}
async searchFor(query: string): Promise<void> {
await this.searchInput.fill(query);
// 等待搜索结果更新(防抖处理)
await this.page.waitForResponse("**/api/v1/users?*");
}
async clickCreateUser(): Promise<void> {
await this.createButton.click();
}
async getUserRow(email: string): Promise<Locator> {
return this.userTable.getByRole("row").filter({ hasText: email });
}
async getUserCount(): Promise<number> {
// 减去表头行
return (await this.userTable.getByRole("row").count()) - 1;
}
}
页面对象规则:
优先级顺序(从高到低):
| 优先级 | 选择器 | 示例 | 使用场景 |
|---|---|---|---|
| 1 | data-testid | getByTestId("submit-btn") | 交互元素、动态内容 |
| 2 | 角色 | getByRole("button", { name: /save/i }) | 按钮、链接、标题、输入框 |
| 3 | 标签 | getByLabel("Email") | 带有标签的表单输入框 |
| 4 | 占位符 | getByPlaceholder("Search...") | 搜索输入框 |
| 5 | 文本 | getByText("Welcome back") | 静态文本内容 |
绝对不要使用:
.class-name、#id)——脆弱,样式更改时会失效//div[@class="foo"])——不可读,极其脆弱div > span:nth-child(2))——布局更改时会失效添加data-testid属性:
// 在React组件中——为交互元素添加data-testid
<button data-testid="create-user-btn" onClick={handleCreate}>
Create User
</button>
// 约定:kebab-case,描述性
// 模式:<动作>-<实体>-<元素类型>
// 示例:create-user-btn, user-email-input, delete-confirm-dialog
绝对不要使用硬编码等待:
// 错误:硬编码等待——不稳定、缓慢
await page.waitForTimeout(3000);
// 错误:休眠
await new Promise((resolve) => setTimeout(resolve, 2000));
使用显式等待条件:
// 正确:等待特定元素出现
await page.getByRole("heading", { name: "Dashboard" }).waitFor();
// 正确:等待导航
await page.waitForURL("/dashboard");
// 正确:等待API响应
await page.waitForResponse(
(response) =>
response.url().includes("/api/v1/users") && response.status() === 200,
);
// 正确:等待网络稳定
await page.waitForLoadState("networkidle");
// 正确:等待元素状态
await page.getByTestId("submit-btn").waitFor({ state: "visible" });
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
自动等待: Playwright在点击、填充等操作前会自动等待元素变为可操作状态。仅在断言或复杂状态转换时才需要显式等待。
避免在每个测试前都登录。保存认证状态并复用。
一次性设置认证状态:
// e2e/fixtures/auth.fixture.ts
import { test as base } from "@playwright/test";
import path from "path";
const AUTH_STATE_PATH = path.resolve("e2e/.auth/user.json");
export const setup = base.extend({});
setup("authenticate", async ({ page }) => {
// 执行真实登录
await page.goto("/login");
await page.getByLabel("Email").fill("testuser@example.com");
await page.getByLabel("Password").fill("TestPassword123!");
await page.getByRole("button", { name: /sign in/i }).click();
// 等待认证完成
await page.waitForURL("/dashboard");
// 保存登录状态
await page.context().storageState({ path: AUTH_STATE_PATH });
});
在测试中复用:
// playwright.config.ts
export default defineConfig({
projects: [
// 设置项目首先运行并保存认证状态
{ name: "setup", testDir: "./e2e/fixtures", testMatch: "auth.fixture.ts" },
{
name: "chromium",
use: {
storageState: "e2e/.auth/user.json", // 复用认证状态
},
dependencies: ["setup"],
},
],
});
原则:
用于测试数据的API助手:
// e2e/utils/api-helpers.ts
import { type APIRequestContext } from "@playwright/test";
export class TestDataAPI {
constructor(private request: APIRequestContext) {}
async createUser(data: { email: string; displayName: string }) {
const response = await this.request.post("/api/v1/users", { data });
return response.json();
}
async deleteUser(userId: number) {
await this.request.delete(`/api/v1/users/${userId}`);
}
async createOrder(userId: number, items: Array<Record<string, unknown>>) {
const response = await this.request.post("/api/v1/orders", {
data: { user_id: userId, items },
});
return response.json();
}
}
在测试中的使用:
test("edit user name", async ({ page, request }) => {
const api = new TestDataAPI(request);
// 设置:通过API创建用户(快速)
const user = await api.createUser({
email: "edit-test@example.com",
displayName: "Before Edit",
});
try {
// 测试:通过UI编辑
const usersPage = new UsersPage(page);
await usersPage.goto();
// ... 通过UI执行编辑操作 ...
} finally {
// 清理:移除测试数据
await api.deleteUser(user.id);
}
});
1. 对失败使用跟踪查看器:
// playwright.config.ts
use: {
trace: "on-first-retry", // 仅在重试时捕获跟踪
}
查看跟踪:npx playwright show-trace trace.zip
2. 在headed模式下运行以进行调试:
npx playwright test --headed --debug tests/users/create-user.spec.ts
3. 不稳定测试的常见原因:
| 原因 | 修复方法 |
|---|---|
| 硬编码等待 | 使用显式等待条件 |
| 共享测试数据 | 每个测试创建自己的数据 |
| 动画干扰 | 在配置中设置 animations: "disabled" |
| 竞态条件 | 在断言前等待API响应 |
| 视口依赖行为 | 在配置中设置显式视口 |
| 测试间会话泄漏 | 正确使用 storageState,清除cookie |
4. 重试策略:
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0, // 仅在CI中重试
});
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start application
run: |
docker compose up -d
npx wait-on http://localhost:3000 --timeout 60000
- name: Run E2E tests
run: npx playwright test
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
- name: Upload traces on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-traces
path: test-results/
使用 scripts/run-e2e-with-report.sh 在本地运行带有HTML报告输出的Playwright。
查看 references/page-object-template.ts 获取带注释的页面对象类。查看 references/e2e-test-template.ts 获取带注释的端到端测试。查看 references/playwright-config-example.ts 获取生产环境Playwright配置。
每周安装量
1.5K
仓库
GitHub星标数
8
首次出现
2026年2月4日
安全审计
安装于
opencode1.2K
codex1.2K
gemini-cli1.2K
github-copilot1.2K
kimi-cli1.2K
amp1.2K
Activate this skill when:
Do NOT use this skill for:
react-testing-patterns)pytest-patterns)tdd-workflow)pytest-patterns with httpx)e2e/
├── playwright.config.ts # Global Playwright configuration
├── fixtures/
│ ├── auth.fixture.ts # Authentication state setup
│ └── test-data.fixture.ts # Test data creation/cleanup
├── pages/
│ ├── base.page.ts # Base page object with shared methods
│ ├── login.page.ts # Login page object
│ ├── users.page.ts # Users list page object
│ └── user-detail.page.ts # User detail page object
├── tests/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── logout.spec.ts
│ ├── users/
│ │ ├── create-user.spec.ts
│ │ ├── edit-user.spec.ts
│ │ └── list-users.spec.ts
│ └── smoke/
│ └── critical-paths.spec.ts
└── utils/
├── api-helpers.ts # Direct API calls for test setup
└── test-constants.ts # Shared constants
Naming conventions:
<feature>.spec.ts<page-name>.page.ts<concern>.fixture.tsEvery page gets a page object class that encapsulates selectors and actions. Tests never interact with selectors directly.
Base page object:
// e2e/pages/base.page.ts
import { type Page, type Locator } from "@playwright/test";
export abstract class BasePage {
constructor(protected readonly page: Page) {}
/** Navigate to the page's URL. */
abstract goto(): Promise<void>;
/** Wait for the page to be fully loaded. */
async waitForLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
}
/** Get a toast/notification message. */
get toast(): Locator {
return this.page.getByRole("alert");
}
/** Get the page heading. */
get heading(): Locator {
return this.page.getByRole("heading", { level: 1 });
}
}
Concrete page object:
// e2e/pages/users.page.ts
import { type Page, type Locator } from "@playwright/test";
import { BasePage } from "./base.page";
export class UsersPage extends BasePage {
// ─── Locators ─────────────────────────────────────────
readonly createButton: Locator;
readonly searchInput: Locator;
readonly userTable: Locator;
constructor(page: Page) {
super(page);
this.createButton = page.getByTestId("create-user-btn");
this.searchInput = page.getByRole("searchbox", { name: /search users/i });
this.userTable = page.getByRole("table");
}
// ─── Actions ──────────────────────────────────────────
async goto(): Promise<void> {
await this.page.goto("/users");
await this.waitForLoad();
}
async searchFor(query: string): Promise<void> {
await this.searchInput.fill(query);
// Wait for search results to update (debounced)
await this.page.waitForResponse("**/api/v1/users?*");
}
async clickCreateUser(): Promise<void> {
await this.createButton.click();
}
async getUserRow(email: string): Promise<Locator> {
return this.userTable.getByRole("row").filter({ hasText: email });
}
async getUserCount(): Promise<number> {
// Subtract 1 for header row
return (await this.userTable.getByRole("row").count()) - 1;
}
}
Rules for page objects:
Priority order (highest to lowest):
| Priority | Selector | Example | When to Use |
|---|---|---|---|
| 1 | data-testid | getByTestId("submit-btn") | Interactive elements, dynamic content |
| 2 | Role | getByRole("button", { name: /save/i }) | Buttons, links, headings, inputs |
| 3 | Label | getByLabel("Email") | Form inputs with labels |
| 4 | Placeholder | getByPlaceholder("Search...") |
NEVER use:
.class-name, #id) -- brittle, break on styling changes//div[@class="foo"]) -- unreadable, extremely brittlediv > span:nth-child(2)) -- break on layout changesAdding data-testid attributes:
// In React components -- add data-testid to interactive elements
<button data-testid="create-user-btn" onClick={handleCreate}>
Create User
</button>
// Convention: kebab-case, descriptive
// Pattern: <action>-<entity>-<element-type>
// Examples: create-user-btn, user-email-input, delete-confirm-dialog
NEVER use hardcoded waits:
// BAD: Hardcoded wait -- flaky, slow
await page.waitForTimeout(3000);
// BAD: Sleep
await new Promise((resolve) => setTimeout(resolve, 2000));
Use explicit wait conditions:
// GOOD: Wait for a specific element to appear
await page.getByRole("heading", { name: "Dashboard" }).waitFor();
// GOOD: Wait for navigation
await page.waitForURL("/dashboard");
// GOOD: Wait for API response
await page.waitForResponse(
(response) =>
response.url().includes("/api/v1/users") && response.status() === 200,
);
// GOOD: Wait for network to settle
await page.waitForLoadState("networkidle");
// GOOD: Wait for element state
await page.getByTestId("submit-btn").waitFor({ state: "visible" });
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
Auto-waiting: Playwright auto-waits for elements to be actionable before clicking, filling, etc. Explicit waits are needed only for assertions or complex state transitions.
Avoid logging in before every test. Save auth state and reuse it.
Setup auth state once:
// e2e/fixtures/auth.fixture.ts
import { test as base } from "@playwright/test";
import path from "path";
const AUTH_STATE_PATH = path.resolve("e2e/.auth/user.json");
export const setup = base.extend({});
setup("authenticate", async ({ page }) => {
// Perform real login
await page.goto("/login");
await page.getByLabel("Email").fill("testuser@example.com");
await page.getByLabel("Password").fill("TestPassword123!");
await page.getByRole("button", { name: /sign in/i }).click();
// Wait for auth to complete
await page.waitForURL("/dashboard");
// Save signed-in state
await page.context().storageState({ path: AUTH_STATE_PATH });
});
Reuse in tests:
// playwright.config.ts
export default defineConfig({
projects: [
// Setup project runs first and saves auth state
{ name: "setup", testDir: "./e2e/fixtures", testMatch: "auth.fixture.ts" },
{
name: "chromium",
use: {
storageState: "e2e/.auth/user.json", // Reuse auth state
},
dependencies: ["setup"],
},
],
});
Principles:
API helpers for test data:
// e2e/utils/api-helpers.ts
import { type APIRequestContext } from "@playwright/test";
export class TestDataAPI {
constructor(private request: APIRequestContext) {}
async createUser(data: { email: string; displayName: string }) {
const response = await this.request.post("/api/v1/users", { data });
return response.json();
}
async deleteUser(userId: number) {
await this.request.delete(`/api/v1/users/${userId}`);
}
async createOrder(userId: number, items: Array<Record<string, unknown>>) {
const response = await this.request.post("/api/v1/orders", {
data: { user_id: userId, items },
});
return response.json();
}
}
Usage in tests:
test("edit user name", async ({ page, request }) => {
const api = new TestDataAPI(request);
// Setup: create user via API (fast)
const user = await api.createUser({
email: "edit-test@example.com",
displayName: "Before Edit",
});
try {
// Test: edit via UI
const usersPage = new UsersPage(page);
await usersPage.goto();
// ... perform edit via UI ...
} finally {
// Cleanup: remove test data
await api.deleteUser(user.id);
}
});
1. Use trace viewer for failures:
// playwright.config.ts
use: {
trace: "on-first-retry", // Capture trace only on retry
}
View trace: npx playwright show-trace trace.zip
2. Run in headed mode for debugging:
npx playwright test --headed --debug tests/users/create-user.spec.ts
3. Common causes of flaky tests:
| Cause | Fix |
|---|---|
| Hardcoded waits | Use explicit wait conditions |
| Shared test data | Each test creates its own data |
| Animation interference | Set animations: "disabled" in config |
| Race conditions | Wait for API responses before assertions |
| Viewport-dependent behavior | Set explicit viewport in config |
| Session leaks between tests | Use storageState correctly, clear cookies |
4. Retry strategy:
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0, // Retry in CI only
});
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Start application
run: |
docker compose up -d
npx wait-on http://localhost:3000 --timeout 60000
- name: Run E2E tests
run: npx playwright test
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
- name: Upload traces on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: test-traces
path: test-results/
Use scripts/run-e2e-with-report.sh to run Playwright with HTML report output locally.
See references/page-object-template.ts for annotated page object class. See references/e2e-test-template.ts for annotated E2E test. See references/playwright-config-example.ts for production Playwright config.
Weekly Installs
1.5K
Repository
GitHub Stars
8
First Seen
Feb 4, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode1.2K
codex1.2K
gemini-cli1.2K
github-copilot1.2K
kimi-cli1.2K
amp1.2K
React 组合模式指南:Vercel 组件架构最佳实践,提升代码可维护性
102,200 周安装
| Search inputs |
| 5 | Text | getByText("Welcome back") | Static text content |