npx skills add https://github.com/agents-inc/skills --skill web-testing-playwright-e2e快速指南: 使用 Playwright 进行端到端测试,通过真实应用程序验证完整的用户工作流程。专注于关键用户旅程,使用基于无障碍访问的定位器(getByRole),并利用自动等待断言。
<critical_requirements>
所有代码必须遵循 CLAUDE.md 中的项目约定(kebab-case、命名导出、导入顺序、
import type、命名常量)
(你必须使用 getByRole() 作为主要的定位器策略 - 它反映了用户与页面的交互方式)
(你必须测试完整的端到端用户工作流程 - 登录流程、结账流程、表单提交)
(你必须使用自动等待的 Web 优先断言 - toBeVisible()、toHaveText(),而非手动休眠)
(你必须隔离测试 - 每个测试在其自己的浏览器上下文中独立运行)
(你必须为测试数据使用命名常量 - 测试文件中不要出现魔术字符串或数字)
</critical_requirements>
自动检测: Playwright, E2E testing, end-to-end testing, browser automation, page.goto, test.describe, expect(page), getByRole, getByTestId, toBeVisible
使用时机:
不应使用的情况:
Quick Guide: Use Playwright for end-to-end tests that verify complete user workflows through the real application. Focus on critical user journeys, use accessibility-based locators (getByRole), and leverage auto-waiting assertions.
<critical_requirements>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use getByRole() as your primary locator strategy - it mirrors how users interact with the page)
(You MUST test complete user workflows end-to-end - login flows, checkout processes, form submissions)
(You MUST use web-first assertions that auto-wait - toBeVisible(), toHaveText(), not manual sleeps)
(You MUST isolate tests - each test runs independently with its own browser context)
(You MUST use named constants for test data - no magic strings or numbers in test files)
</critical_requirements>
Auto-detection: Playwright, E2E testing, end-to-end testing, browser automation, page.goto, test.describe, expect(page), getByRole, getByTestId, toBeVisible
When to use:
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
涵盖的关键模式:
详细资源:
Playwright E2E 测试从用户角度验证你的应用程序是否正常工作。它们与真实浏览器交互,在实际页面间导航,并验证用户可见的行为。
核心原则:
E2E 测试提供最大价值的情况:
E2E 测试可能不是最佳选择的情况:
使用 test.describe 分组相关测试,test 用于单个测试用例,钩子用于设置/清理。
// tests/e2e/auth/login-flow.spec.ts
import { test, expect } from "@playwright/test";
const LOGIN_URL = "/login";
const DASHBOARD_URL = "/dashboard";
const VALID_EMAIL = "user@example.com";
const VALID_PASSWORD = "securePassword123";
test.describe("登录流程", () => {
test.beforeEach(async ({ page }) => {
await page.goto(LOGIN_URL);
});
test("成功登录重定向到仪表板", async ({ page }) => {
await page.getByLabel(/email/i).fill(VALID_EMAIL);
await page.getByLabel(/password/i).fill(VALID_PASSWORD);
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page).toHaveURL(DASHBOARD_URL);
await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible();
});
test("空邮箱显示验证错误", async ({ page }) => {
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page.getByText(/email is required/i)).toBeVisible();
});
});
优点: 逻辑上分组相关测试,beforeEach 消除重复同时保持隔离,命名常量防止魔术字符串,描述性测试名称记录预期行为
// 反面示例 - 无组织,魔术字符串
test("登录测试", async ({ page }) => {
await page.goto("/login");
await page.locator("#email").fill("user@example.com");
await page.locator("#password").fill("password123");
await page.locator("button").click();
await page.waitForTimeout(2000); // 手动休眠!
expect(page.url()).toContain("dashboard");
});
缺点: 无测试分组导致导航困难,魔术字符串分散各处,值改变时会破坏测试,CSS 选择器脆弱,重构时易损坏,手动超时而非自动等待导致测试不稳定
将页面结构和交互封装在可重用的类中,以提高可维护性。
// tests/e2e/pages/login-page.ts
import type { Page, Locator } from "@playwright/test";
const LOGIN_URL = "/login";
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel(/email/i);
this.passwordInput = page.getByLabel(/password/i);
this.signInButton = page.getByRole("button", { name: /sign in/i });
this.errorMessage = page.getByRole("alert");
}
async goto() {
await this.page.goto(LOGIN_URL);
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
}
// tests/e2e/auth/login-flow.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/login-page";
const VALID_EMAIL = "user@example.com";
const VALID_PASSWORD = "securePassword123";
const DASHBOARD_URL = "/dashboard";
test.describe("登录流程", () => {
test("成功登录", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(VALID_EMAIL, VALID_PASSWORD);
await expect(page).toHaveURL(DASHBOARD_URL);
});
test("无效凭证显示错误", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("wrong@example.com", "wrongpassword");
await expect(loginPage.errorMessage).toBeVisible();
});
});
优点: 将元素定位器集中在一处,使 UI 更改易于更新,方法封装了复杂的交互,提高了可读性,页面对象可在多个测试中重用,减少了重复
使用时机: 在同一页面上跨越多个交互的测试,跨测试文件的可重用流程。
不应使用的情况: 简单的、一次性测试,其中内联定位器更清晰。
优先使用基于无障碍访问的定位器,这些定位器反映了用户与应用程序的交互方式。
// 首选:基于无障碍访问的定位器
await page.getByRole("button", { name: /submit/i }); // 最佳 - 反映用户交互
await page.getByLabel(/email address/i); // 通过标签定位表单字段
await page.getByText(/welcome back/i); // 可见文本
await page.getByPlaceholder("Search..."); // 占位符文本
await page.getByAltText("Company logo"); // 图像替代文本
await page.getByTitle("Close dialog"); // 标题属性
// 可接受:复杂情况下的测试 ID
await page.getByTestId("user-avatar"); // 当不存在语义角色时
// 避免:依赖于实现的定位器
await page.locator("#submit-btn"); // 脆弱的 ID 选择器
await page.locator(".btn-primary"); // CSS 类可能改变
await page.locator("div > button:nth-child(2)"); // 依赖于 DOM 结构
优点: getByRole 匹配无障碍树,确保键盘导航有效,在 UI 重构后仍能工作,因为角色和标签是稳定的,作为副作用验证了无障碍访问
// 在列表中过滤
await page
.getByRole("listitem")
.filter({ hasText: "Product A" })
.getByRole("button", { name: /add to cart/i })
.click();
// 限定到特定区域
const sidebar = page.getByRole("complementary");
await sidebar.getByRole("link", { name: /settings/i }).click();
// 通过嵌套元素过滤
await page
.getByRole("row")
.filter({ has: page.getByText("Active") })
.getByRole("button", { name: /edit/i })
.click();
// 过滤不包含某元素的项(v1.50+)
await page
.getByRole("listitem")
.filter({ hasNot: page.getByText("Out of stock") })
.first()
.click();
// 仅过滤可见元素(v1.51+)
await page.locator("button").filter({ visible: true }).click();
优点: 链式调用无需脆弱的选择器即可定位到特定元素,filter() 优雅地处理动态列表,限定到区域可防止选择错误的元素
// 使用 .and() 组合条件 - 元素必须同时匹配两者
const subscribedButton = page
.getByRole("button")
.and(page.getByTitle("Subscribe"));
await subscribedButton.click();
// 使用 .or() 匹配任一替代项 - 适用于条件性 UI
const newEmail = page.getByRole("button", { name: "New" });
const dialog = page.getByText("Confirm security settings");
await expect(newEmail.or(dialog).first()).toBeVisible();
优点: .and() 无需脆弱的选择器即可创建精确匹配,.or() 优雅地处理条件性 UI 状态,两者均可与现有定位器组合使用
使用自动等待并重试直到条件满足的断言。
import { test, expect } from "@playwright/test";
const TIMEOUT_LONG_MS = 10000;
test("带自动等待的 Web 优先断言", async ({ page }) => {
// 自动等待元素可见
await expect(page.getByText("Welcome")).toBeVisible();
// 自动等待文本内容匹配
await expect(page.getByRole("heading")).toHaveText("Dashboard");
// 自动等待 URL 匹配
await expect(page).toHaveURL(/\/dashboard/);
// 带自定义超时的自动等待
await expect(page.getByRole("status")).toHaveText("Complete", {
timeout: TIMEOUT_LONG_MS,
});
// 否定断言也自动等待
await expect(page.getByRole("progressbar")).not.toBeVisible();
});
优点: 自动等待消除了竞态条件导致的不稳定测试,无需手动休眠或任意超时,自动重试直到条件满足或超时
// 反面示例 - 手动等待
test("手动等待不稳定", async ({ page }) => {
await page.click("button");
await page.waitForTimeout(2000); // 任意休眠!
const text = await page.textContent(".result");
expect(text).toBe("Success"); // 非等待断言
});
缺点: 固定超时要么太短(不稳定),要么太长(缓慢),不适应实际页面加载时间,手动断言失败时不重试
test("软断言在失败后继续执行", async ({ page }) => {
await page.goto("/profile");
// 这些断言失败不会停止测试
await expect.soft(page.getByTestId("avatar")).toBeVisible();
await expect.soft(page.getByText("Premium Member")).toBeVisible();
await expect
.soft(page.getByRole("link", { name: /settings/i }))
.toBeEnabled();
// 测试继续执行,所有失败在最后报告
});
优点: 在一次测试运行中收集所有失败,适用于验证多个独立条件,调试时减少测试重新运行次数
模拟外部 API 并控制网络条件,以实现可靠、隔离的测试。
import { test, expect } from "@playwright/test";
const API_USERS_ENDPOINT = "**/api/users";
const MOCK_USER_ID = "user-123";
const MOCK_USER_NAME = "John Doe";
const MOCK_USER_EMAIL = "john@example.com";
test("显示来自模拟 API 的用户资料", async ({ page }) => {
// 拦截 API 调用并返回模拟数据
await page.route(API_USERS_ENDPOINT, (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: MOCK_USER_ID,
name: MOCK_USER_NAME,
email: MOCK_USER_EMAIL,
}),
});
});
await page.goto("/profile");
await expect(page.getByText(MOCK_USER_NAME)).toBeVisible();
await expect(page.getByText(MOCK_USER_EMAIL)).toBeVisible();
});
优点: 消除了外部 API 可用性导致的不稳定性,能够测试错误状态和边缘情况,控制精确数据以实现可预测的断言
const API_USERS_ENDPOINT = "**/api/users";
const HTTP_SERVER_ERROR = 500;
const HTTP_NETWORK_OFFLINE = "failed";
test("优雅处理 API 错误", async ({ page }) => {
await page.route(API_USERS_ENDPOINT, (route) => {
route.fulfill({
status: HTTP_SERVER_ERROR,
body: JSON.stringify({ error: "Internal server error" }),
});
});
await page.goto("/profile");
await expect(page.getByText(/something went wrong/i)).toBeVisible();
await expect(page.getByRole("button", { name: /retry/i })).toBeVisible();
});
test("处理网络故障", async ({ page }) => {
await page.route(API_USERS_ENDPOINT, (route) =>
route.abort(HTTP_NETWORK_OFFLINE),
);
await page.goto("/profile");
await expect(page.getByText(/network error/i)).toBeVisible();
});
优点: 在不破坏外部服务的情况下测试错误处理,模拟难以复现的条件,确保用户看到适当的反馈
test("修改 API 响应以进行测试", async ({ page }) => {
await page.route("**/api/products", async (route) => {
// 发出真实请求
const response = await route.fetch();
const json = await response.json();
// 修改响应
json.products = json.products.map((p: { price: number }) => ({
...p,
price: p.price * 0.9, // 应用 10% 折扣
}));
// 返回修改后的响应
await route.fulfill({ response, json });
});
await page.goto("/products");
// 使用修改后的数据进行测试
});
优点: 结合了真实 API 行为与受控修改,适用于测试转换或特定场景,保持了真实的响应结构
捕获并比较截图以检测意外的视觉变化。
import { test, expect } from "@playwright/test";
test("主页视觉回归", async ({ page }) => {
await page.goto("/");
// 全页截图比较
await expect(page).toHaveScreenshot("homepage.png");
});
test("组件视觉回归", async ({ page }) => {
await page.goto("/components/button");
// 特定元素的截图
const button = page.getByRole("button", { name: /primary/i });
await expect(button).toHaveScreenshot("primary-button.png");
});
优点: 自动捕获意外的视觉变化,基线图像作为视觉文档,特定元素的截图减少了动态内容带来的干扰
const SCREENSHOT_ANIMATION_DELAY_MS = 500;
test("处理动态内容的截图", async ({ page }) => {
await page.goto("/dashboard");
// 屏蔽动态元素
await expect(page).toHaveScreenshot("dashboard.png", {
mask: [page.getByTestId("current-time"), page.getByTestId("random-ad")],
});
// 或等待动画完成
await expect(page).toHaveScreenshot("dashboard-stable.png", {
animations: "disabled",
});
// 可接受差异的自定义阈值
await expect(page).toHaveScreenshot("dashboard-fuzzy.png", {
maxDiffPixels: 100,
});
});
优点: 屏蔽防止时间戳或广告导致的误报,禁用动画确保确定的截图,阈值允许微小的可接受变化
使用夹具进行可重用的设置,使用钩子进行测试生命周期管理。
import { test, expect } from "@playwright/test";
// page - 隔离的浏览器页面
// context - 浏览器上下文(cookie、存储)
// browser - 浏览器实例
// request - API 测试上下文
test("使用内置夹具", async ({ page, context }) => {
// 每个测试获得一个全新的页面
await page.goto("/app");
// 访问上下文以操作 cookie
await context.addCookies([
{ name: "session", value: "abc123", domain: "localhost", path: "/" },
]);
});
// tests/e2e/fixtures.ts
import { test as base } from "@playwright/test";
import { LoginPage } from "./pages/login-page";
import { DashboardPage } from "./pages/dashboard-page";
const AUTH_SESSION_TOKEN = "test-session-token";
const AUTH_COOKIE_NAME = "session";
const AUTH_COOKIE_DOMAIN = "localhost";
// 使用自定义夹具扩展基础测试
export const test = base.extend<{
loginPage: LoginPage;
dashboardPage: DashboardPage;
authenticatedPage: void;
}>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page);
await use(dashboardPage);
},
// 为每个测试自动运行的自动夹具
authenticatedPage: [
async ({ page, context }, use) => {
// 设置:添加认证 cookie
await context.addCookies([
{
name: AUTH_COOKIE_NAME,
value: AUTH_SESSION_TOKEN,
domain: AUTH_COOKIE_DOMAIN,
path: "/",
},
]);
await use();
// 清理:清除 cookie
await context.clearCookies();
},
{ auto: true }, // 自动为所有测试运行
],
});
export { expect } from "@playwright/test";
// tests/e2e/dashboard.spec.ts
import { test, expect } from "./fixtures";
test("仪表板显示用户数据", async ({ page, dashboardPage }) => {
await dashboardPage.goto();
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
});
优点: 夹具将设置和清理封装在一起,自动夹具消除了重复的身份验证设置,作为夹具的页面对象提高了可重用性
import { test, expect } from "@playwright/test";
test.describe("用户设置", () => {
// 在此 describe 块中的所有测试之前运行一次
test.beforeAll(async ({ browser }) => {
// 一次性设置(例如,种子数据库)
});
// 在每个测试之前运行
test.beforeEach(async ({ page }) => {
await page.goto("/settings");
});
// 在每个测试之后运行
test.afterEach(async ({ page }) => {
// 清理(例如,重置用户偏好)
});
// 在所有测试之后运行一次
test.afterAll(async ({ browser }) => {
// 最终清理
});
test("可以更新资料", async ({ page }) => {
// 测试实现
});
});
优点: beforeAll/afterAll 处理昂贵的一次性设置,beforeEach 确保一致的起始状态,afterEach 防止测试污染
在 playwright.config.ts 中根据项目需求配置 Playwright。
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
const BASE_URL = "http://localhost:3000";
const CI_WORKERS = 2;
const DEFAULT_TIMEOUT_MS = 30000;
const ACTION_TIMEOUT_MS = 10000;
const NAVIGATION_TIMEOUT_MS = 30000;
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? CI_WORKERS : undefined,
reporter: [["html", { open: "never" }], ["list"]],
use: {
baseURL: BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
// 全局超时
timeout: DEFAULT_TIMEOUT_MS,
expect: {
timeout: ACTION_TIMEOUT_MS,
},
// 浏览器项目
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 5"] },
},
],
// 本地开发服务器
webServer: {
command: "npm run dev",
url: BASE_URL,
reuseExistingServer: !process.env.CI,
},
});
优点: fullyParallel 最大化测试速度,CI 上的重试处理暂时性失败,仅在失败时记录 trace/截图/视频节省资源,项目启用跨浏览器测试
// playwright.config.ts
import { defineConfig } from "@playwright/test";
const STAGING_URL = "https://staging.example.com";
const PRODUCTION_URL = "https://example.com";
const LOCAL_URL = "http://localhost:3000";
const CI_WORKERS = 4;
export default defineConfig({
use: {
baseURL: process.env.BASE_URL || LOCAL_URL,
},
projects: [
{
name: "staging",
use: {
baseURL: STAGING_URL,
},
testMatch: /.*\.e2e\.ts/,
},
{
name: "production",
use: {
baseURL: PRODUCTION_URL,
},
testMatch: /.*\.smoke\.ts/, // 生产环境仅运行冒烟测试
},
],
});
优点: 为不同环境设置独立项目,生产环境限制为冒烟测试以确保安全,环境变量覆盖默认值
与你的测试组织配合:
tests/e2e/ 目录中auth/、checkout/、search/).spec.ts 扩展名以区别于单元测试CI/CD 集成:
Playwright 通过配置和 CLI 标志与 CI 流水线集成。使用分片将测试拆分到多台机器上:
# 在 CI 上运行测试
npx playwright test --shard=1/4
# 更新快照
npx playwright test --update-snapshots
调试工具:
npx playwright test --uinpx playwright test --debugnpx playwright show-trace trace.zip<red_flags>
高优先级问题:
page.waitForTimeout() - 导致不稳定或缓慢的测试,请改用自动等待断言.btn-primary 或 #submit-btn 这样的 CSS 选择器 - 脆弱且重构时会损坏,请使用 getByRole中优先级问题:
常见错误:
陷阱和边缘情况:
toBeVisible() 等待元素,toBeInTheDocument() 不等待 - 始终优先使用可见性检查beforeAll 每个工作线程运行一次,而非全局一次 - 使用全局设置进行真正的一次性设置</red_flags>
<critical_reminders>
所有代码必须遵循 CLAUDE.md 中的项目约定
(你必须使用 getByRole() 作为主要的定位器策略 - 它反映了用户与页面的交互方式)
(你必须测试完整的端到端用户工作流程 - 登录流程、结账流程、表单提交)
(你必须使用自动等待的 Web 优先断言 - toBeVisible()、toHaveText(),而非手动休眠)
(你必须隔离测试 - 每个测试在其自己的浏览器上下文中独立运行)
(你必须为测试数据使用命名常量 - 测试文件中不要出现魔术字符串或数字)
不遵守这些规则将导致不稳定测试、误报和维护噩梦。
</critical_reminders>
每周安装数
1
代码仓库
首次出现
1 天前
安全审计
安装于
windsurf1
amp1
cline1
openclaw1
trae-cn1
opencode1
When NOT to use:
Key patterns covered:
Detailed Resources:
Playwright E2E tests verify that your application works correctly from the user's perspective. They interact with the real browser, navigate through actual pages, and validate user-visible behavior.
Core Principles:
When E2E tests provide the most value:
When E2E tests may not be the best choice:
Use test.describe to group related tests, test for individual test cases, and hooks for setup/teardown.
// tests/e2e/auth/login-flow.spec.ts
import { test, expect } from "@playwright/test";
const LOGIN_URL = "/login";
const DASHBOARD_URL = "/dashboard";
const VALID_EMAIL = "user@example.com";
const VALID_PASSWORD = "securePassword123";
test.describe("Login Flow", () => {
test.beforeEach(async ({ page }) => {
await page.goto(LOGIN_URL);
});
test("successful login redirects to dashboard", async ({ page }) => {
await page.getByLabel(/email/i).fill(VALID_EMAIL);
await page.getByLabel(/password/i).fill(VALID_PASSWORD);
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page).toHaveURL(DASHBOARD_URL);
await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible();
});
test("shows validation error for empty email", async ({ page }) => {
await page.getByRole("button", { name: /sign in/i }).click();
await expect(page.getByText(/email is required/i)).toBeVisible();
});
});
Why good: Groups related tests logically, beforeEach eliminates repetition while maintaining isolation, named constants prevent magic strings, descriptive test names document expected behavior
// Bad Example - No organization, magic strings
test("login test", async ({ page }) => {
await page.goto("/login");
await page.locator("#email").fill("user@example.com");
await page.locator("#password").fill("password123");
await page.locator("button").click();
await page.waitForTimeout(2000); // Manual sleep!
expect(page.url()).toContain("dashboard");
});
Why bad: No test grouping makes navigation difficult, magic strings scattered throughout break when values change, CSS selectors are fragile and break on refactoring, manual timeout instead of auto-waiting causes flaky tests
Encapsulate page structure and interactions in reusable classes to improve maintainability.
// tests/e2e/pages/login-page.ts
import type { Page, Locator } from "@playwright/test";
const LOGIN_URL = "/login";
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel(/email/i);
this.passwordInput = page.getByLabel(/password/i);
this.signInButton = page.getByRole("button", { name: /sign in/i });
this.errorMessage = page.getByRole("alert");
}
async goto() {
await this.page.goto(LOGIN_URL);
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
}
// tests/e2e/auth/login-flow.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/login-page";
const VALID_EMAIL = "user@example.com";
const VALID_PASSWORD = "securePassword123";
const DASHBOARD_URL = "/dashboard";
test.describe("Login Flow", () => {
test("successful login", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(VALID_EMAIL, VALID_PASSWORD);
await expect(page).toHaveURL(DASHBOARD_URL);
});
test("shows error for invalid credentials", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("wrong@example.com", "wrongpassword");
await expect(loginPage.errorMessage).toBeVisible();
});
});
Why good: Centralizes element locators in one place making UI changes easy to update, methods encapsulate complex interactions improving readability, page objects are reusable across multiple tests reducing duplication
When to use: Tests spanning multiple interactions on the same page, reusable flows across test files.
When not to use: Simple one-off tests where inline locators are clearer.
Prioritize accessibility-based locators that mirror how users interact with your application.
// Preferred: Accessibility-based locators
await page.getByRole("button", { name: /submit/i }); // BEST - mirrors user interaction
await page.getByLabel(/email address/i); // Form fields by label
await page.getByText(/welcome back/i); // Visible text
await page.getByPlaceholder("Search..."); // Placeholder text
await page.getByAltText("Company logo"); // Image alt text
await page.getByTitle("Close dialog"); // Title attribute
// Acceptable: Test IDs for complex cases
await page.getByTestId("user-avatar"); // When no semantic role exists
// Avoid: Implementation-dependent selectors
await page.locator("#submit-btn"); // Fragile ID selector
await page.locator(".btn-primary"); // CSS class can change
await page.locator("div > button:nth-child(2)"); // DOM structure dependent
Why good: getByRole matches accessibility tree ensuring keyboard navigability works, survives UI refactoring since roles and labels are stable, validates accessibility as a side effect
// Filter within a list
await page
.getByRole("listitem")
.filter({ hasText: "Product A" })
.getByRole("button", { name: /add to cart/i })
.click();
// Scope to a specific region
const sidebar = page.getByRole("complementary");
await sidebar.getByRole("link", { name: /settings/i }).click();
// Filter by nested element
await page
.getByRole("row")
.filter({ has: page.getByText("Active") })
.getByRole("button", { name: /edit/i })
.click();
// Filter by NOT having element (v1.50+)
await page
.getByRole("listitem")
.filter({ hasNot: page.getByText("Out of stock") })
.first()
.click();
// Filter only visible elements (v1.51+)
await page.locator("button").filter({ visible: true }).click();
Why good: Chains narrow down to specific elements without fragile selectors, filter() handles dynamic lists gracefully, scoping to regions prevents selecting wrong elements
// Combine conditions with .and() - element must match both
const subscribedButton = page
.getByRole("button")
.and(page.getByTitle("Subscribe"));
await subscribedButton.click();
// Match either alternative with .or() - useful for conditional UI
const newEmail = page.getByRole("button", { name: "New" });
const dialog = page.getByText("Confirm security settings");
await expect(newEmail.or(dialog).first()).toBeVisible();
Why good: .and() creates precise matches without fragile selectors, .or() handles conditional UI states elegantly, both compose with existing locators
Use assertions that automatically wait and retry until the condition is met.
import { test, expect } from "@playwright/test";
const TIMEOUT_LONG_MS = 10000;
test("web-first assertions with auto-waiting", async ({ page }) => {
// Auto-waits for element to be visible
await expect(page.getByText("Welcome")).toBeVisible();
// Auto-waits for text content to match
await expect(page.getByRole("heading")).toHaveText("Dashboard");
// Auto-waits for URL to match
await expect(page).toHaveURL(/\/dashboard/);
// Auto-waits with custom timeout
await expect(page.getByRole("status")).toHaveText("Complete", {
timeout: TIMEOUT_LONG_MS,
});
// Negated assertions also auto-wait
await expect(page.getByRole("progressbar")).not.toBeVisible();
});
Why good: Auto-waiting eliminates flaky tests from race conditions, no manual sleeps or arbitrary timeouts needed, retries automatically until condition met or timeout exceeded
// Bad Example - Manual waiting
test("manual waiting is flaky", async ({ page }) => {
await page.click("button");
await page.waitForTimeout(2000); // Arbitrary sleep!
const text = await page.textContent(".result");
expect(text).toBe("Success"); // Non-waiting assertion
});
Why bad: Fixed timeouts are either too short (flaky) or too long (slow), doesn't adapt to actual page load time, manual assertions don't retry on failure
test("soft assertions continue after failure", async ({ page }) => {
await page.goto("/profile");
// These won't stop the test if they fail
await expect.soft(page.getByTestId("avatar")).toBeVisible();
await expect.soft(page.getByText("Premium Member")).toBeVisible();
await expect
.soft(page.getByRole("link", { name: /settings/i }))
.toBeEnabled();
// Test continues, all failures reported at end
});
Why good: Collects all failures in one test run, useful for validating multiple independent conditions, reduces test re-runs during debugging
Mock external APIs and control network conditions for reliable, isolated tests.
import { test, expect } from "@playwright/test";
const API_USERS_ENDPOINT = "**/api/users";
const MOCK_USER_ID = "user-123";
const MOCK_USER_NAME = "John Doe";
const MOCK_USER_EMAIL = "john@example.com";
test("displays user profile from mocked API", async ({ page }) => {
// Intercept API call and return mock data
await page.route(API_USERS_ENDPOINT, (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: MOCK_USER_ID,
name: MOCK_USER_NAME,
email: MOCK_USER_EMAIL,
}),
});
});
await page.goto("/profile");
await expect(page.getByText(MOCK_USER_NAME)).toBeVisible();
await expect(page.getByText(MOCK_USER_EMAIL)).toBeVisible();
});
Why good: Eliminates flakiness from external API availability, enables testing error states and edge cases, controls exact data for predictable assertions
const API_USERS_ENDPOINT = "**/api/users";
const HTTP_SERVER_ERROR = 500;
const HTTP_NETWORK_OFFLINE = "failed";
test("handles API error gracefully", async ({ page }) => {
await page.route(API_USERS_ENDPOINT, (route) => {
route.fulfill({
status: HTTP_SERVER_ERROR,
body: JSON.stringify({ error: "Internal server error" }),
});
});
await page.goto("/profile");
await expect(page.getByText(/something went wrong/i)).toBeVisible();
await expect(page.getByRole("button", { name: /retry/i })).toBeVisible();
});
test("handles network failure", async ({ page }) => {
await page.route(API_USERS_ENDPOINT, (route) =>
route.abort(HTTP_NETWORK_OFFLINE),
);
await page.goto("/profile");
await expect(page.getByText(/network error/i)).toBeVisible();
});
Why good: Tests error handling without breaking external services, simulates conditions that are hard to reproduce otherwise, ensures user sees appropriate feedback
test("modifies API response for testing", async ({ page }) => {
await page.route("**/api/products", async (route) => {
// Make the real request
const response = await route.fetch();
const json = await response.json();
// Modify the response
json.products = json.products.map((p: { price: number }) => ({
...p,
price: p.price * 0.9, // Apply 10% discount
}));
// Return modified response
await route.fulfill({ response, json });
});
await page.goto("/products");
// Test with modified data
});
Why good: Combines real API behavior with controlled modifications, useful for testing transformations or specific scenarios, maintains realistic response structure
Capture and compare screenshots to detect unintended visual changes.
import { test, expect } from "@playwright/test";
test("homepage visual regression", async ({ page }) => {
await page.goto("/");
// Full page screenshot comparison
await expect(page).toHaveScreenshot("homepage.png");
});
test("component visual regression", async ({ page }) => {
await page.goto("/components/button");
// Element-specific screenshot
const button = page.getByRole("button", { name: /primary/i });
await expect(button).toHaveScreenshot("primary-button.png");
});
Why good: Catches unintended visual changes automatically, baseline images serve as visual documentation, element-specific screenshots reduce noise from dynamic content
const SCREENSHOT_ANIMATION_DELAY_MS = 500;
test("screenshot with dynamic content handled", async ({ page }) => {
await page.goto("/dashboard");
// Mask dynamic elements
await expect(page).toHaveScreenshot("dashboard.png", {
mask: [page.getByTestId("current-time"), page.getByTestId("random-ad")],
});
// Or wait for animations to complete
await expect(page).toHaveScreenshot("dashboard-stable.png", {
animations: "disabled",
});
// Custom threshold for acceptable differences
await expect(page).toHaveScreenshot("dashboard-fuzzy.png", {
maxDiffPixels: 100,
});
});
Why good: Masking prevents false positives from timestamps or ads, disabling animations ensures deterministic screenshots, threshold allows minor acceptable variations
Use fixtures for reusable setup and hooks for test lifecycle management.
import { test, expect } from "@playwright/test";
// page - isolated browser page
// context - browser context (cookies, storage)
// browser - browser instance
// request - API testing context
test("using built-in fixtures", async ({ page, context }) => {
// Each test gets a fresh page
await page.goto("/app");
// Access context for cookies
await context.addCookies([
{ name: "session", value: "abc123", domain: "localhost", path: "/" },
]);
});
// tests/e2e/fixtures.ts
import { test as base } from "@playwright/test";
import { LoginPage } from "./pages/login-page";
import { DashboardPage } from "./pages/dashboard-page";
const AUTH_SESSION_TOKEN = "test-session-token";
const AUTH_COOKIE_NAME = "session";
const AUTH_COOKIE_DOMAIN = "localhost";
// Extend base test with custom fixtures
export const test = base.extend<{
loginPage: LoginPage;
dashboardPage: DashboardPage;
authenticatedPage: void;
}>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page);
await use(dashboardPage);
},
// Auto-fixture that runs for every test
authenticatedPage: [
async ({ page, context }, use) => {
// Setup: Add auth cookie
await context.addCookies([
{
name: AUTH_COOKIE_NAME,
value: AUTH_SESSION_TOKEN,
domain: AUTH_COOKIE_DOMAIN,
path: "/",
},
]);
await use();
// Teardown: Clear cookies
await context.clearCookies();
},
{ auto: true }, // Runs automatically for all tests
],
});
export { expect } from "@playwright/test";
// tests/e2e/dashboard.spec.ts
import { test, expect } from "./fixtures";
test("dashboard shows user data", async ({ page, dashboardPage }) => {
await dashboardPage.goto();
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
});
Why good: Fixtures encapsulate setup and teardown together, auto fixtures eliminate repetitive auth setup, page objects as fixtures improve reusability
import { test, expect } from "@playwright/test";
test.describe("User Settings", () => {
// Runs once before all tests in this describe block
test.beforeAll(async ({ browser }) => {
// One-time setup (e.g., seed database)
});
// Runs before each test
test.beforeEach(async ({ page }) => {
await page.goto("/settings");
});
// Runs after each test
test.afterEach(async ({ page }) => {
// Cleanup (e.g., reset user preferences)
});
// Runs once after all tests
test.afterAll(async ({ browser }) => {
// Final cleanup
});
test("can update profile", async ({ page }) => {
// Test implementation
});
});
Why good: beforeAll/afterAll handle expensive one-time setup, beforeEach ensures consistent starting state, afterEach prevents test pollution
Configure Playwright for your project's needs in playwright.config.ts.
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
const BASE_URL = "http://localhost:3000";
const CI_WORKERS = 2;
const DEFAULT_TIMEOUT_MS = 30000;
const ACTION_TIMEOUT_MS = 10000;
const NAVIGATION_TIMEOUT_MS = 30000;
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? CI_WORKERS : undefined,
reporter: [["html", { open: "never" }], ["list"]],
use: {
baseURL: BASE_URL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
// Global timeouts
timeout: DEFAULT_TIMEOUT_MS,
expect: {
timeout: ACTION_TIMEOUT_MS,
},
// Browser projects
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 5"] },
},
],
// Local dev server
webServer: {
command: "npm run dev",
url: BASE_URL,
reuseExistingServer: !process.env.CI,
},
});
Why good: fullyParallel maximizes test speed, retries on CI handle transient failures, trace/screenshot/video only on failure saves resources, projects enable cross-browser testing
// playwright.config.ts
import { defineConfig } from "@playwright/test";
const STAGING_URL = "https://staging.example.com";
const PRODUCTION_URL = "https://example.com";
const LOCAL_URL = "http://localhost:3000";
const CI_WORKERS = 4;
export default defineConfig({
use: {
baseURL: process.env.BASE_URL || LOCAL_URL,
},
projects: [
{
name: "staging",
use: {
baseURL: STAGING_URL,
},
testMatch: /.*\.e2e\.ts/,
},
{
name: "production",
use: {
baseURL: PRODUCTION_URL,
},
testMatch: /.*\.smoke\.ts/, // Only smoke tests in prod
},
],
});
Why good: Separate projects for different environments, production limited to smoke tests for safety, environment variables override defaults
Works with your test organization:
tests/e2e/ directory at project rootauth/, checkout/, search/).spec.ts extension to distinguish from unit testsCI/CD Integration:
Playwright integrates with CI pipelines through configuration and CLI flags. Use sharding to split tests across machines:
# Run tests on CI
npx playwright test --shard=1/4
# Update snapshots
npx playwright test --update-snapshots
Debugging Tools:
npx playwright test --uinpx playwright test --debugnpx playwright show-trace trace.zip<red_flags>
High Priority Issues:
page.waitForTimeout() with fixed delays - causes flaky or slow tests, use auto-waiting assertions instead.btn-primary or #submit-btn - fragile and break on refactoring, use getByRoleMedium Priority Issues:
Common Mistakes:
Gotchas and Edge Cases:
toBeVisible() waits for element, toBeInTheDocument() does not - always prefer visibility checksbeforeAll runs once per worker, not once globally - use global setup for true one-time setup</red_flags>
<critical_reminders>
All code must follow project conventions in CLAUDE.md
(You MUST use getByRole() as your primary locator strategy - it mirrors how users interact with the page)
(You MUST test complete user workflows end-to-end - login flows, checkout processes, form submissions)
(You MUST use web-first assertions that auto-wait - toBeVisible(), toHaveText(), not manual sleeps)
(You MUST isolate tests - each test runs independently with its own browser context)
(You MUST use named constants for test data - no magic strings or numbers in test files)
Failure to follow these rules will result in flaky tests, false positives, and maintenance nightmares.
</critical_reminders>
Weekly Installs
1
Repository
First Seen
1 day ago
Security Audits
Installed on
windsurf1
amp1
cline1
openclaw1
trae-cn1
opencode1
通过 LiteLLM 代理让 Claude Code 对接 GitHub Copilot 运行 | 高级变通方案指南
31,600 周安装