# 使用 Playwright 测试 React 应用
> **适用场景**:测试使用 Create React App(CRA)、Vite 或自定义打包工具构建的 React 应用。涵盖端到端测试、实验性组件测试、React Router 导航、表单库、Portal、错误边界以及通过 UI 验证上下文/状态。
> **前置知识**:[core/configuration.md](configuration.md)、[core/locators.md](locators.md)
## 快速参考
```bash
# 在 React 项目中安装 Playwright
npm init playwright@latest
# 安装组件测试(实验性功能)
npm install -D @playwright/experimental-ct-react
# 运行端到端测试
npx playwright test
# 运行组件测试
npx playwright test -c playwright-ct.config.ts
# 调试特定测试
npx playwright test tests/login.spec.ts --headed --debug
```
## 设置
### React 项目的 Playwright 配置(Vite)
**TypeScript**
```typescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./tests",
testMatch: "**/*.spec.ts",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? "50%" : undefined,
use: {
baseURL: "http://localhost:5173", // Vite 默认端口
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "mobile",
use: { ...devices["iPhone 14"] },
},
],
webServer: {
command: process.env.CI
? "npm run build && npx serve -s build -l 5173" // CRA
: // ? 'npm run build && npx vite preview --port 5173' // Vite
"npm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
```
**JavaScript**
```javascript
// playwright.config.js
const { defineConfig, devices } = require("@playwright/test")
module.exports = defineConfig({
testDir: "./tests",
testMatch: "**/*.spec.js",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? "50%" : undefined,
use: {
baseURL: "http://localhost:5173",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "mobile",
use: { ...devices["iPhone 14"] },
},
],
webServer: {
command: process.env.CI ? "npm run build && npx serve -s build -l 5173" : "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
```
### 组件测试配置
组件测试可在真实浏览器中挂载单个 React 组件,无需完整开发服务器。该功能为实验性,但可用于隔离测试复杂组件。
**TypeScript**
```typescript
// playwright-ct.config.ts
import { defineConfig, devices } from "@playwright/experimental-ct-react"
export default defineConfig({
testDir: "./tests/components",
testMatch: "**/*.ct.ts",
use: {
trace: "on-first-retry",
ctPort: 3100,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
})
```
**JavaScript**
```javascript
// playwright-ct.config.js
const { defineConfig, devices } = require("@playwright/experimental-ct-react")
module.exports = defineConfig({
testDir: "./tests/components",
testMatch: "**/*.ct.js",
use: {
trace: "on-first-retry",
ctPort: 3100,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
})
```
## 模式
### 使用 `@playwright/experimental-ct-react` 进行组件测试
**适用场景**:隔离测试复杂的交互式组件——数据表格、表单向导、富文本编辑器、支持键盘导航的下拉菜单。组件需要真实浏览器(而非 jsdom),但不需要完整的应用程序。
**避免场景**:组件行为严重依赖后端数据或路由上下文。此时应使用端到端测试。
**TypeScript**
```typescript
// tests/components/Counter.ct.ts
import { test, expect } from '@playwright/experimental-ct-react';
import Counter from '../../src/components/Counter';
test('increments count on button click', async ({ mount }) => {
const component = await mount();
await expect(component.getByText('Count: 0')).toBeVisible();
await component.getByRole('button', { name: 'Increment' }).click();
await expect(component.getByText('Count: 1')).toBeVisible();
});
test('decrements count on button click', async ({ mount }) => {
const component = await mount();
await component.getByRole('button', { name: 'Decrement' }).click();
await expect(component.getByText('Count: 4')).toBeVisible();
});
test('calls onChange callback when count changes', async ({ mount }) => {
const values: number[] = [];
const component = await mount(
values.push(val)} />
);
await component.getByRole('button', { name: 'Increment' }).click();
await component.getByRole('button', { name: 'Increment' }).click();
expect(values).toEqual([1, 2]);
});
test('disables decrement at zero when min is set', async ({ mount }) => {
const component = await mount();
await expect(component.getByRole('button', { name: 'Decrement' })).toBeDisabled();
});
```
**JavaScript**
```javascript
// tests/components/Counter.ct.js
const { test, expect } = require("@playwright/experimental-ct-react")
const Counter = require("../../src/components/Counter")
test("increments count on button click", async ({ mount }) => {
const component = await mount()
await expect(component.getByText("Count: 0")).toBeVisible()
await component.getByRole("button", { name: "Increment" }).click()
await expect(component.getByText("Count: 1")).toBeVisible()
})
test("decrements count on button click", async ({ mount }) => {
const component = await mount()
await component.getByRole("button", { name: "Decrement" }).click()
await expect(component.getByText("Count: 4")).toBeVisible()
})
test("calls onChange callback when count changes", async ({ mount }) => {
const values = []
const component = await mount( values.push(val)} />)
await component.getByRole("button", { name: "Increment" }).click()
await component.getByRole("button", { name: "Increment" }).click()
expect(values).toEqual([1, 2])
})
```
### 通过 UI 间接测试 Hook
**适用场景**:验证自定义 Hook 是否能产生正确的 UI 行为。Playwright 无法直接调用 Hook——需要通过使用这些 Hook 的组件来测试它们。
**避免场景**:Hook 的逻辑是纯计算且没有 UI 副作用——此时应使用单元测试框架。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("useDebounce hook (tested through SearchInput)", () => {
test("debounced search triggers after user stops typing", async ({ page }) => {
await page.goto("/search")
// 设置响应拦截器以追踪 API 调用
const apiCalls: string[] = []
await page.route("**/api/search*", async (route) => {
apiCalls.push(route.request().url())
await route.continue()
})
// 快速输入——防抖 Hook 应将这些请求合并处理
await page.getByRole("textbox", { name: "Search" }).pressSequentially("playwright", {
delay: 50,
})
// 等待结果出现(防抖已触发)
await expect(page.getByRole("listitem")).toHaveCount(5)
// 防抖 Hook 应只产生 1-2 次 API 调用,而不是 10 次(每次按键一次)
expect(apiCalls.length).toBeLessThanOrEqual(2)
})
})
test.describe("usePagination hook (tested through ProductList)", () => {
test("pagination controls navigate between pages", async ({ page }) => {
await page.goto("/products")
await expect(page.getByText("Page 1 of 5")).toBeVisible()
await page.getByRole("button", { name: "Next page" }).click()
await expect(page.getByText("Page 2 of 5")).toBeVisible()
await page.getByRole("button", { name: "Previous page" }).click()
await expect(page.getByText("Page 1 of 5")).toBeVisible()
// 第一页:上一页按钮应被禁用
await expect(page.getByRole("button", { name: "Previous page" })).toBeDisabled()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("useDebounce hook (tested through SearchInput)", () => {
test("debounced search triggers after user stops typing", async ({ page }) => {
await page.goto("/search")
const apiCalls = []
await page.route("**/api/search*", async (route) => {
apiCalls.push(route.request().url())
await route.continue()
})
await page.getByRole("textbox", { name: "Search" }).pressSequentially("playwright", {
delay: 50,
})
await expect(page.getByRole("listitem")).toHaveCount(5)
expect(apiCalls.length).toBeLessThanOrEqual(2)
})
})
test.describe("usePagination hook (tested through ProductList)", () => {
test("pagination controls navigate between pages", async ({ page }) => {
await page.goto("/products")
await expect(page.getByText("Page 1 of 5")).toBeVisible()
await page.getByRole("button", { name: "Next page" }).click()
await expect(page.getByText("Page 2 of 5")).toBeVisible()
await page.getByRole("button", { name: "Previous page" }).click()
await expect(page.getByText("Page 1 of 5")).toBeVisible()
await expect(page.getByRole("button", { name: "Previous page" })).toBeDisabled()
})
})
```
### 通过用户交互测试上下文和状态变化
**适用场景**:验证 React context(主题、认证、语言环境)和状态管理(Redux、Zustand、Jotai)是否能产生正确的 UI 变化。
**避免场景**:你想要断言原始状态对象——Playwright 测试的是 UI,而非内部状态。如果 UI 正确,则状态正确。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("theme context", () => {
test("dark mode toggle changes the visual theme", async ({ page }) => {
await page.goto("/settings")
// 验证初始亮色模式
const body = page.locator("body")
await expect(body).not.toHaveClass(/dark/)
// 切换暗色模式
await page.getByRole("switch", { name: "Dark mode" }).click()
// 验证暗色模式已应用
await expect(body).toHaveClass(/dark/)
// 验证切换状态在导航后仍然保持
await page.getByRole("link", { name: "Home" }).click()
await expect(page.locator("body")).toHaveClass(/dark/)
})
})
test.describe("shopping cart state", () => {
test("adding items updates cart badge across pages", async ({ page }) => {
await page.goto("/products")
// 购物车徽标应显示 0 或被隐藏
const cartBadge = page.getByTestId("cart-count")
// 添加第一件商品
await page
.getByRole("listitem")
.filter({ hasText: "Running Shoes" })
.getByRole("button", { name: "Add to cart" })
.click()
await expect(cartBadge).toHaveText("1")
// 添加第二件商品
await page
.getByRole("listitem")
.filter({ hasText: "Trail Jacket" })
.getByRole("button", { name: "Add to cart" })
.click()
await expect(cartBadge).toHaveText("2")
// 导航到其他页面——购物车数量保持不变(全局状态)
await page.getByRole("link", { name: "About" }).click()
await expect(cartBadge).toHaveText("2")
})
})
test.describe("auth context", () => {
test("login updates the UI across all components consuming auth context", async ({ page }) => {
await page.goto("/")
// 未认证状态
await expect(page.getByRole("link", { name: "Sign in" })).toBeVisible()
await expect(page.getByRole("button", { name: "Sign out" })).toBeHidden()
// 登录
await page.getByRole("link", { name: "Sign in" }).click()
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).click()
// 已认证状态——多个组件同时更新
await expect(page.getByRole("link", { name: "Sign in" })).toBeHidden()
await expect(page.getByText("user@example.com")).toBeVisible()
await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("theme context", () => {
test("dark mode toggle changes the visual theme", async ({ page }) => {
await page.goto("/settings")
const body = page.locator("body")
await expect(body).not.toHaveClass(/dark/)
await page.getByRole("switch", { name: "Dark mode" }).click()
await expect(body).toHaveClass(/dark/)
await page.getByRole("link", { name: "Home" }).click()
await expect(page.locator("body")).toHaveClass(/dark/)
})
})
test.describe("shopping cart state", () => {
test("adding items updates cart badge across pages", async ({ page }) => {
await page.goto("/products")
const cartBadge = page.getByTestId("cart-count")
await page
.getByRole("listitem")
.filter({ hasText: "Running Shoes" })
.getByRole("button", { name: "Add to cart" })
.click()
await expect(cartBadge).toHaveText("1")
await page
.getByRole("listitem")
.filter({ hasText: "Trail Jacket" })
.getByRole("button", { name: "Add to cart" })
.click()
await expect(cartBadge).toHaveText("2")
await page.getByRole("link", { name: "About" }).click()
await expect(cartBadge).toHaveText("2")
})
})
```
### 测试 React Router 导航
**适用场景**:测试使用 React Router v6+ 的客户端路由。验证路由切换、URL 参数、受保护路由和浏览器历史行为。
**避免场景**:你的应用仅使用服务端路由(Next.js App Router 的处理方式不同——参见 [core/nextjs.md](nextjs.md))。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("React Router navigation", () => {
test("client-side navigation does not cause full page reload", async ({ page }) => {
await page.goto("/")
// 在 window 上存储标记以检测完整页面重新加载
await page.evaluate(() => {
;(window as any).__testMarker = "no-reload"
})
// 通过 React Router Link 导航
await page.getByRole("link", { name: "Products" }).click()
await page.waitForURL("/products")
// 如果页面被完整重新加载,标记将消失
const marker = await page.evaluate(() => (window as any).__testMarker)
expect(marker).toBe("no-reload")
})
test("URL params update the page content", async ({ page }) => {
await page.goto("/products?category=electronics")
await expect(page.getByRole("heading", { name: "Electronics" })).toBeVisible()
// 通过 UI 交互更改分类(这会更新 URL 参数)
await page.getByRole("link", { name: "Clothing" }).click()
await page.waitForURL("/products?category=clothing")
await expect(page.getByRole("heading", { name: "Clothing" })).toBeVisible()
})
test("nested routes render parent and child layouts", async ({ page }) => {
await page.goto("/settings/profile")
// 父布局(设置)
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
await expect(page.getByRole("navigation", { name: "Settings" })).toBeVisible()
// 子路由(个人资料)
await expect(page.getByRole("heading", { name: "Profile", level: 2 })).toBeVisible()
// 导航到同级路由
await page.getByRole("link", { name: "Notifications" }).click()
await page.waitForURL("/settings/notifications")
// 父布局保持不变
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
await expect(page.getByRole("heading", { name: "Notifications", level: 2 })).toBeVisible()
})
test("browser back button works with client-side routing", async ({ page }) => {
await page.goto("/")
await page.getByRole("link", { name: "Products" }).click()
await page.waitForURL("/products")
await page.getByRole("link", { name: "About" }).click()
await page.waitForURL("/about")
// 返回
await page.goBack()
await expect(page).toHaveURL(/\/products/)
await page.goBack()
await expect(page).toHaveURL(/\/$/)
})
test("protected route redirects unauthenticated users", async ({ page }) => {
await page.goto("/admin")
// React Router 的受保护路由应重定向到登录页
await expect(page).toHaveURL(/\/login/)
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
})
test("404 route shows not found page", async ({ page }) => {
await page.goto("/this-route-does-not-exist")
await expect(page.getByRole("heading", { name: "Page Not Found" })).toBeVisible()
await expect(page.getByRole("link", { name: "Go home" })).toBeVisible()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("React Router navigation", () => {
test("client-side navigation does not cause full page reload", async ({ page }) => {
await page.goto("/")
await page.evaluate(() => {
window.__testMarker = "no-reload"
})
await page.getByRole("link", { name: "Products" }).click()
await page.waitForURL("/products")
const marker = await page.evaluate(() => window.__testMarker)
expect(marker).toBe("no-reload")
})
test("URL params update the page content", async ({ page }) => {
await page.goto("/products?category=electronics")
await expect(page.getByRole("heading", { name: "Electronics" })).toBeVisible()
await page.getByRole("link", { name: "Clothing" }).click()
await page.waitForURL("/products?category=clothing")
await expect(page.getByRole("heading", { name: "Clothing" })).toBeVisible()
})
test("browser back button works with client-side routing", async ({ page }) => {
await page.goto("/")
await page.getByRole("link", { name: "Products" }).click()
await page.waitForURL("/products")
await page.getByRole("link", { name: "About" }).click()
await page.waitForURL("/about")
await page.goBack()
await expect(page).toHaveURL(/\/products/)
await page.goBack()
await expect(page).toHaveURL(/\/$/)
})
test("protected route redirects unauthenticated users", async ({ page }) => {
await page.goto("/admin")
await expect(page).toHaveURL(/\/login/)
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible()
})
})
```
### 测试表单库(React Hook Form、Formik)
**适用场景**:测试使用 react-hook-form、Formik 或类似库构建的表单。Playwright 与 DOM 交互,因此表单库是透明的——测试的是用户体验,而非库的内部实现。
**避免场景**:你正在测试表单库本身。你应该测试的是你自己的表单行为。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("registration form (react-hook-form)", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/register")
})
test("shows validation errors on submit with empty fields", async ({ page }) => {
await page.getByRole("button", { name: "Create account" }).click()
// react-hook-form 在尝试提交后显示错误
await expect(page.getByText("Email is required")).toBeVisible()
await expect(page.getByText("Password is required")).toBeVisible()
})
test("shows inline validation on blur", async ({ page }) => {
const emailInput = page.getByLabel("Email")
await emailInput.fill("not-an-email")
await emailInput.blur()
// react-hook-form 在 mode: 'onBlur' 模式下会立即验证
await expect(page.getByText("Invalid email address")).toBeVisible()
})
test("password requirements update in real time", async ({ page }) => {
const passwordInput = page.getByLabel("Password", { exact: true })
await passwordInput.fill("short")
await expect(page.getByText("At least 8 characters")).toHaveClass(/text-red/)
await passwordInput.fill("longenough")
await expect(page.getByText("At least 8 characters")).toHaveClass(/text-green/)
await passwordInput.fill("LongEnough1!")
await expect(page.getByText("Contains uppercase")).toHaveClass(/text-green/)
await expect(page.getByText("Contains number")).toHaveClass(/text-green/)
await expect(page.getByText("Contains special character")).toHaveClass(/text-green/)
})
test("successful registration submits form and redirects", async ({ page }) => {
await page.getByLabel("Full name").fill("Jane Doe")
await page.getByLabel("Email").fill("jane@example.com")
await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
await page.getByLabel("Confirm password").fill("Str0ng!Pass")
await page.getByLabel("I agree to the terms").check()
await page.getByRole("button", { name: "Create account" }).click()
await page.waitForURL("/dashboard")
await expect(page.getByText("Welcome, Jane")).toBeVisible()
})
test("disables submit button while form is submitting", async ({ page }) => {
// 减慢 API 响应速度以便观察提交中状态
await page.route("**/api/register", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ id: 1, email: "jane@example.com" }),
})
})
await page.getByLabel("Full name").fill("Jane Doe")
await page.getByLabel("Email").fill("jane@example.com")
await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
await page.getByLabel("Confirm password").fill("Str0ng!Pass")
await page.getByLabel("I agree to the terms").check()
await page.getByRole("button", { name: "Create account" }).click()
// 按钮应显示加载状态
await expect(page.getByRole("button", { name: /Creating|Loading|Submitting/ })).toBeDisabled()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("registration form (react-hook-form)", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/register")
})
test("shows validation errors on submit with empty fields", async ({ page }) => {
await page.getByRole("button", { name: "Create account" }).click()
await expect(page.getByText("Email is required")).toBeVisible()
await expect(page.getByText("Password is required")).toBeVisible()
})
test("shows inline validation on blur", async ({ page }) => {
const emailInput = page.getByLabel("Email")
await emailInput.fill("not-an-email")
await emailInput.blur()
await expect(page.getByText("Invalid email address")).toBeVisible()
})
test("successful registration submits form and redirects", async ({ page }) => {
await page.getByLabel("Full name").fill("Jane Doe")
await page.getByLabel("Email").fill("jane@example.com")
await page.getByLabel("Password", { exact: true }).fill("Str0ng!Pass")
await page.getByLabel("Confirm password").fill("Str0ng!Pass")
await page.getByLabel("I agree to the terms").check()
await page.getByRole("button", { name: "Create account" }).click()
await page.waitForURL("/dashboard")
await expect(page.getByText("Welcome, Jane")).toBeVisible()
})
})
```
### 测试 React Portal(模态框、提示框、下拉菜单)
**适用场景**:测试通过 `ReactDOM.createPortal()` 渲染的组件——模态框、对话框、提示框、下拉菜单、Toast 通知。这些组件在父级 DOM 层级之外渲染,但 Playwright 可以看到完整的文档。
**避免场景**:该组件不是 Portal——它在行内渲染。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("portals", () => {
test("modal opens and is accessible", async ({ page }) => {
await page.goto("/products")
await page.getByRole("button", { name: "Delete product" }).first().click()
// 模态框通过 Portal 渲染到 document.body,但 Playwright
// 可以像其他元素一样看到它
const modal = page.getByRole("dialog", { name: "Confirm deletion" })
await expect(modal).toBeVisible()
// 验证焦点被锁定在模态框内
await expect(modal.getByRole("button", { name: "Cancel" })).toBeFocused()
// 验证背景遮罩层阻止与后方元素的交互
//(Playwright 会自动等待元素变为可操作状态)
await modal.getByRole("button", { name: "Delete" }).click()
await expect(modal).toBeHidden()
})
test("modal closes on Escape key", async ({ page }) => {
await page.goto("/products")
await page.getByRole("button", { name: "Delete product" }).first().click()
const modal = page.getByRole("dialog", { name: "Confirm deletion" })
await expect(modal).toBeVisible()
await page.keyboard.press("Escape")
await expect(modal).toBeHidden()
})
test("tooltip shows on hover and hides on leave", async ({ page }) => {
await page.goto("/dashboard")
const triggerButton = page.getByRole("button", { name: "Info" })
await triggerButton.hover()
// 通过 Portal 渲染的提示框
await expect(page.getByRole("tooltip")).toBeVisible()
await expect(page.getByRole("tooltip")).toContainText("Click for more details")
// 将鼠标移开
await page.mouse.move(0, 0)
await expect(page.getByRole("tooltip")).toBeHidden()
})
test("toast notifications stack and auto-dismiss", async ({ page }) => {
await page.goto("/settings")
// 触发多条 Toast 通知
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Settings saved")).toBeVisible()
// Toast 应在一段延迟后自动消失
await expect(page.getByText("Settings saved")).toBeHidden({ timeout: 10_000 })
})
test("dropdown menu rendered via portal positions correctly", async ({ page }) => {
await page.goto("/dashboard")
await page.getByRole("button", { name: "More actions" }).click()
const menu = page.getByRole("menu")
await expect(menu).toBeVisible()
await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeVisible()
await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible()
// 点击菜单项
await menu.getByRole("menuitem", { name: "Edit" }).click()
await expect(menu).toBeHidden()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("portals", () => {
test("modal opens and is accessible", async ({ page }) => {
await page.goto("/products")
await page.getByRole("button", { name: "Delete product" }).first().click()
const modal = page.getByRole("dialog", { name: "Confirm deletion" })
await expect(modal).toBeVisible()
await expect(modal.getByRole("button", { name: "Cancel" })).toBeFocused()
await modal.getByRole("button", { name: "Delete" }).click()
await expect(modal).toBeHidden()
})
test("modal closes on Escape key", async ({ page }) => {
await page.goto("/products")
await page.getByRole("button", { name: "Delete product" }).first().click()
const modal = page.getByRole("dialog", { name: "Confirm deletion" })
await expect(modal).toBeVisible()
await page.keyboard.press("Escape")
await expect(modal).toBeHidden()
})
test("tooltip shows on hover and hides on leave", async ({ page }) => {
await page.goto("/dashboard")
await page.getByRole("button", { name: "Info" }).hover()
await expect(page.getByRole("tooltip")).toBeVisible()
await page.mouse.move(0, 0)
await expect(page.getByRole("tooltip")).toBeHidden()
})
})
```
### 测试错误边界
**适用场景**:验证 React 错误边界能否优雅地捕获渲染错误,并显示备用 UI 而非白屏。
**避免场景**:你正在测试事件处理程序或异步代码中的错误处理——错误边界仅捕获渲染错误。
**TypeScript**
```typescript
import { test, expect } from "@playwright/test"
test.describe("error boundaries", () => {
test("error boundary shows fallback UI when child crashes", async ({ page }) => {
// 捕获控制台错误以验证错误已被抛出
const consoleErrors: string[] = []
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text())
}
})
await page.goto("/dashboard")
// 触发一个导致组件在渲染时抛出的操作
//(例如,一个在特定数据下崩溃的组件)
await page.route("**/api/dashboard/widgets", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ widgets: null }), // null 会导致 .map() 抛出异常
})
})
await page.reload()
// 错误边界应捕获错误并显示备用 UI
await expect(page.getByText("Something went wrong")).toBeVisible()
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible()
// 页面的其余部分应仍然可用
await expect(page.getByRole("navigation", { name: "Main" })).toBeVisible()
})
test("retry button in error boundary recovers the component", async ({ page }) => {
let requestCount = 0
await page.route("**/api/dashboard/widgets", (route) => {
requestCount++
if (requestCount === 1) {
// 第一次请求:返回会导致组件崩溃的坏数据
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ widgets: null }),
})
} else {
// 后续请求:返回正常数据
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ widgets: [{ id: 1, name: "Sales" }] }),
})
}
})
await page.goto("/dashboard")
// 错误边界显示备用 UI
await expect(page.getByText("Something went wrong")).toBeVisible()
// 点击重试——组件使用正常数据重新挂载
await page.getByRole("button", { name: "Try again" }).click()
// 组件恢复
await expect(page.getByText("Something went wrong")).toBeHidden()
await expect(page.getByText("Sales")).toBeVisible()
})
})
```
**JavaScript**
```javascript
const { test, expect } = require("@playwright/test")
test.describe("error boundaries", () => {
test("error boundary shows fallback UI when child crashes", async ({ page }) => {
const consoleErrors = []
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(msg.text())
}
})
await page.route("**/api/dashboard/widgets", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ widgets: null }),
})
})
await page.goto("/dashboard")
await expect(page.getByText("Something went wrong")).toBeVisible()
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible()
await expect(page.getByRole("navigation", { name: "Main" })).toBeVisible()
})
test("retry button in error boundary recovers the component", async ({ page }) => {
let requestCount = 0
await page.route("**/api/dashboard/widgets", (route) => {
requestCount++
if (requestCount === 1) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ widgets: null }),
})
} else {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ widgets: [{ id: 1, name: "Sales" }] }),
})
}
})
await page.goto("/dashboard")
await expect(page.getByText("Something went wrong")).toBeVisible()
await page.getByRole("button", { name: "Try again" }).click()
await expect(page.getByText("Something went wrong")).toBeHidden()
await expect(page.getByText("Sales")).toBeVisible()
})
})
```
## 框架特定提示
### CRA 与 Vite:关键区别
| 方面 | Create React App (CRA) | Vite |
| ------------------ | ---------------------------- | ------------------------------ |
| 默认端口 | `3000` | `5173` |
| 构建输出目录 | `build/` | `dist/` |
| 运行生产构建 | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |
| 环境变量 | `REACT_APP_*` 前缀 | `VITE_*` 前缀 |
| 开发服务器启动时间 | 慢(Webpack) | 快(esbuild) |
请相应调整 `webServer.command` 和 `baseURL`。
### React Strict Mode 与双重副作用
开发模式下的 React Strict Mode 会将副作用运行两次。这可能在测试中导致意外行为(例如,API 调用翻倍)。你的测试应对此具有弹性:
- 不要在开发模式下断言精确的 API 调用次数
- 如果测试 API 调用次数,请在生产构建上运行或考虑双重调用
### 测试 Suspense 和懒加载组件
```typescript
// React.lazy() 组件在加载时显示回退内容
test("lazy-loaded route shows spinner then content", async ({ page }) => {
await page.goto("/")
// 导航到懒加载路由
await page.getByRole("link", { name: "Reports" }).click()
// reports 模块懒加载——你可能会短暂看到一个加载指示器
// Playwright 会自动等待最终内容出现
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible()
})
```
### 组件测试:何时使用 vs 端到端测试
| 场景 | 使用组件测试 | 使用端到端测试 |
| ---------------------------------------- | ----------------------------------- | ----------------------------------------- |
| 带键盘导航的复杂下拉菜单 | 是——使用 mount() 隔离测试 | 也要测试——验证在页面内是否正常 |
| 带排序、筛选、分页的数据表格 | 是——直接控制 props | 也要测试——验证 API 集成 |
| 提交表单的按钮 | 否——隔离测试太简单 | 是——测试完整的表单流程 |
| 主题切换组件 | 是——验证视觉变化 | 也要测试——验证持久化 |
| 连接到认证上下文的登录表单 | 否——依赖项过多 | 是——测试真实流程 |
### 在长时间运行测试中检测内存泄漏
如果你的 React 应用存在已卸载组件的状态更新("Can't perform a React state update on an unmounted component" 警告),可以这样捕获:
```typescript
test("no state-update-on-unmounted warnings during navigation", async ({ page }) => {
const warnings: string[] = []
page.on("console", (msg) => {
if (msg.type() === "warning" && msg.text().includes("unmounted")) {
warnings.push(msg.text())
}
})
// 快速导航以触发卸载边界情况
await page.goto("/dashboard")
await page.getByRole("link", { name: "Settings" }).click()
await page.goBack()
await page.getByRole("link", { name: "Profile" }).click()
await page.goBack()
expect(warnings).toEqual([])
})
```
## 反模式
| 不要这样做 | 问题 | 应该这样做 |
| ---------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| 使用 `page.evaluate(() => store.getState())` 断言 Redux 状态 | 将测试与实现耦合;状态结构变化会破坏测试 | 断言状态产生的 UI:`expect(cartBadge).toHaveText('3')` |
| 在端到端测试文件中导入 React 组件 | 端到端测试在 Node.js 中运行,而非浏览器;组件导入会失败 | 使用 `@playwright/experimental-ct-react` 进行组件测试,使用端到端测试进行完整应用测试 |
| React 状态变更后使用 `page.waitForTimeout(500)` | React 批量更新;执行时间因机器而异 | 使用 `expect(locator).toHaveText('new value')`,它会自动重试 |
| 使用 `page.evaluate` 测试内部组件状态 | 脆弱;重构时会中断;测试的是实现而非行为 | 通过 UI 进行交互并断言可见输出 |
| 在 Playwright 测试中 Mock `useState` 或 `useEffect` | Playwright 在浏览器上下文中运行,而非 React 组件树中 | 让 Hook 自然运行;通过控制输入(API 响应、组件测试中的 props)来驱动测试 |
| 为 Material UI 使用 `page.locator('.MuiButton-root')` | 类名在不同 MUI 版本之间会变化,且是动态生成的 | `page.getByRole('button', { name: 'Submit' })` 无论使用哪种组件库都能工作 |
| 对所有组件使用 `@playwright/experimental-ct-react` | 对简单组件来说,浏览器挂载的开销过大;与单元测试重复 | 对复杂交互式组件使用组件测试;纯逻辑使用单元测试;流程使用端到端测试 |
| 跳过自定义组件的键盘导航测试 | 自定义下拉菜单、模态框、标签页中可访问性回归问题很常见 | 在组件测试中测试 `Tab`、`Enter`、`Escape`、`ArrowDown` 等交互 |
| 断言 React DevTools 或 `__REACT_FIBER__` 内部属性 | React 内部属性在不同版本间不稳定 | 仅与渲染后的 DOM 交互并对其进行断言 |
## 相关文档
- [core/locators.md](locators.md)——适用于任何 React 组件库的定位器策略
- [core/assertions-and-waiting.md](assertions-and-waiting.md)——针对 React 状态变化的自动等待断言
- [core/forms-and-validation.md](forms-and-validation.md)——适用于 react-hook-form 和 Formik 的表单测试模式
- [core/component-testing.md](component-testing.md)——深入的组件测试指南
- [core/accessibility.md](accessibility.md)——测试 React 组件库中的 ARIA 模式
- [core/test-architecture.md](test-architecture.md)——何时使用端到端测试 vs 组件测试 vs 单元测试
- [core/nextjs.md](nextjs.md)——适用于带有 SSR 的 React 应用的 Next.js 特定模式