Files
2026-07-13 21:36:47 +08:00

39 KiB
Raw Permalink Blame History

使用 Playwright 测试 React 应用

适用场景:测试使用 Create React AppCRA)、Vite 或自定义打包工具构建的 React 应用。涵盖端到端测试、实验性组件测试、React Router 导航、表单库、Portal、错误边界以及通过 UI 验证上下文/状态。 前置知识core/configuration.mdcore/locators.md

快速参考

# 在 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

// 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

// 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

// 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

// 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

// 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

// 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

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

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

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

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)。

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

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

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

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

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

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

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

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.commandbaseURL

React Strict Mode 与双重副作用

开发模式下的 React Strict Mode 会将副作用运行两次。这可能在测试中导致意外行为(例如,API 调用翻倍)。你的测试应对此具有弹性:

  • 不要在开发模式下断言精确的 API 调用次数
  • 如果测试 API 调用次数,请在生产构建上运行或考虑双重调用

测试 Suspense 和懒加载组件

// 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" 警告),可以这样捕获:

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 状态 将测试与实现耦合;状态结构变化会破坏测试 断言状态产生的 UIexpect(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 useStateuseEffect Playwright 在浏览器上下文中运行,而非 React 组件树中 让 Hook 自然运行;通过控制输入(API 响应、组件测试中的 props)来驱动测试
为 Material UI 使用 page.locator('.MuiButton-root') 类名在不同 MUI 版本之间会变化,且是动态生成的 page.getByRole('button', { name: 'Submit' }) 无论使用哪种组件库都能工作
对所有组件使用 @playwright/experimental-ct-react 对简单组件来说,浏览器挂载的开销过大;与单元测试重复 对复杂交互式组件使用组件测试;纯逻辑使用单元测试;流程使用端到端测试
跳过自定义组件的键盘导航测试 自定义下拉菜单、模态框、标签页中可访问性回归问题很常见 在组件测试中测试 TabEnterEscapeArrowDown 等交互
断言 React DevTools 或 __REACT_FIBER__ 内部属性 React 内部属性在不同版本间不稳定 仅与渲染后的 DOM 交互并对其进行断言

相关文档