# 使用 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 特定模式