1101 lines
39 KiB
Markdown
1101 lines
39 KiB
Markdown
# 使用 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(<Counter initialCount={0} />);
|
||
|
||
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(<Counter initialCount={5} />);
|
||
|
||
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(
|
||
<Counter initialCount={0} onChange={(val) => 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(<Counter initialCount={0} min={0} />);
|
||
|
||
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(<Counter initialCount={0} />)
|
||
|
||
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(<Counter initialCount={5} />)
|
||
|
||
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(<Counter initialCount={0} onChange={(val) => 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 特定模式
|