# 组件测试 > **何时使用**:当你需要在隔离环境中测试 UI 组件时——验证渲染、交互和行为,而无需启动整个应用程序。适用于设计系统、共享组件库和复杂交互式小部件。 > **前置条件**:[core/configuration.md](configuration.md)、[core/fixtures-and-hooks.md](fixtures-and-hooks.md) ## 快速参考 ```typescript // 为你的框架安装: // npm init playwright@latest -- --ct (交互式) // npm install -D @playwright/experimental-ct-react // npm install -D @playwright/experimental-ct-vue // npm install -D @playwright/experimental-ct-svelte // 挂载组件、交互、断言: import { test, expect } from '@playwright/experimental-ct-react'; import { Button } from './Button'; test('按钮渲染并响应点击', async ({ mount }) => { let clicked = false; const component = await mount( ); await expect(component.getByRole('button', { name: '点击我' })).toBeVisible(); }); test('挂载后更新 props', async ({ mount }) => { const component = await mount(); await expect(component.getByRole('heading', { name: '初始' })).toBeVisible(); // 使用新 props 重新渲染 await component.update(); await expect(component.getByRole('heading', { name: '已更新' })).toBeVisible(); }); ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/experimental-ct-react") const { Card } = require("./Card") test("挂载带 props 的组件", async ({ mount }) => { const component = await mount() await expect(component.getByRole("heading", { name: "欢迎" })).toBeVisible() await expect(component.getByText("开始使用 Playwright")).toBeVisible() }) test("挂载带子元素", async ({ mount }) => { const component = await mount( ) await expect(component.getByRole("button", { name: "点击我" })).toBeVisible() }) test("挂载后更新 props", async ({ mount }) => { const component = await mount() await expect(component.getByRole("heading", { name: "初始" })).toBeVisible() await component.update() await expect(component.getByRole("heading", { name: "已更新" })).toBeVisible() }) ``` ### 3. 测试交互 **何时使用**:组件包含可点击元素、表单输入、键盘处理或悬停状态。 **何时避免**:你在测试浏览器级别的行为(导航、Cookie)——这种情况应使用 E2E 测试。 组件测试交互使用与 E2E 测试相同的 Playwright Locator API。`mount()` 的返回值是一个 `Locator`,因此所有标准方法都可以使用。 **TypeScript** ```typescript import { test, expect } from '@playwright/experimental-ct-react'; import { Counter } from './Counter'; import { SearchInput } from './SearchInput'; import { Dropdown } from './Dropdown'; test('点击交互', async ({ mount }) => { const component = await mount(); await component.getByRole('button', { name: '增加' }).click(); await component.getByRole('button', { name: '增加' }).click(); await expect(component.getByText('计数:2')).toBeVisible(); await component.getByRole('button', { name: '减少' }).click(); await expect(component.getByText('计数:1')).toBeVisible(); }); test('输入交互', async ({ mount }) => { const component = await mount(); const input = component.getByRole('textbox', { name: '搜索' }); await input.fill('playwright'); await expect(component.getByText('显示结果:playwright')).toBeVisible(); // 清除并重新输入 await input.clear(); await input.fill('testing'); await expect(component.getByText('显示结果:testing')).toBeVisible(); }); test('键盘交互', async ({ mount }) => { const component = await mount(); const input = component.getByRole('textbox', { name: '搜索' }); await input.fill('playwright'); await input.press('Enter'); await expect(component.getByText('已搜索:playwright')).toBeVisible(); }); test('从下拉框选择', async ({ mount }) => { const component = await mount( ); await component.getByRole('combobox', { name: '颜色' }).click(); await component.getByRole('option', { name: '绿色' }).click(); await expect(component.getByText('已选择:绿色')).toBeVisible(); }); ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/experimental-ct-react") const { Counter } = require("./Counter") const { SearchInput } = require("./SearchInput") const { Dropdown } = require("./Dropdown") test("点击交互", async ({ mount }) => { const component = await mount() await component.getByRole("button", { name: "增加" }).click() await component.getByRole("button", { name: "增加" }).click() await expect(component.getByText("计数:2")).toBeVisible() await component.getByRole("button", { name: "减少" }).click() await expect(component.getByText("计数:1")).toBeVisible() }) test("输入交互", async ({ mount }) => { const component = await mount() const input = component.getByRole("textbox", { name: "搜索" }) await input.fill("playwright") await expect(component.getByText("显示结果:playwright")).toBeVisible() await input.clear() await input.fill("testing") await expect(component.getByText("显示结果:testing")).toBeVisible() }) test("键盘交互", async ({ mount }) => { const component = await mount() const input = component.getByRole("textbox", { name: "搜索" }) await input.fill("playwright") await input.press("Enter") await expect(component.getByText("已搜索:playwright")).toBeVisible() }) test("从下拉框选择", async ({ mount }) => { const component = await mount() await component.getByRole("combobox", { name: "颜色" }).click() await component.getByRole("option", { name: "绿色" }).click() await expect(component.getByText("已选择:绿色")).toBeVisible() }) ``` ### 4. 测试 Props **何时使用**:你需要验证组件在不同 prop 组合下是否正确渲染——状态、变体、边界情况。 **何时避免**:prop 差异纯粹是视觉上的,且 DOM 没有变化——这种情况应使用视觉回归测试。 **TypeScript** ```typescript import { test, expect } from '@playwright/experimental-ct-react'; import { Alert } from './Alert'; import { Badge } from './Badge'; import { Avatar } from './Avatar'; test('Alert 渲染不同严重级别', async ({ mount }) => { const success = await mount(); await expect(success.getByRole('alert')).toContainText('已保存!'); await expect(success.getByRole('alert')).toHaveAttribute('data-severity', 'success'); const error = await mount(); await expect(error.getByRole('alert')).toContainText('保存失败'); await expect(error.getByRole('alert')).toHaveAttribute('data-severity', 'error'); }); test('Badge 渲染计数并在超过 99 时显示 99+', async ({ mount }) => { const low = await mount(); await expect(low).toContainText('5'); const high = await mount(); await expect(high).toContainText('99+'); const zero = await mount(); await expect(zero).toBeHidden(); }); test('Avatar 在未提供图片时显示首字母', async ({ mount }) => { const withImage = await mount( ); await expect(withImage.getByRole('img', { name: '张三' })).toBeVisible(); const withoutImage = await mount(); await expect(withoutImage.getByText('ZS')).toBeVisible(); await expect(withoutImage.getByRole('img')).toHaveCount(0); }); test('禁用按钮不可交互', async ({ mount }) => { const component = await mount( ); await expect(component.getByRole('button', { name: '提交' })).toBeDisabled(); }); ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/experimental-ct-react") const { Alert } = require("./Alert") const { Badge } = require("./Badge") const { Avatar } = require("./Avatar") test("Alert 渲染不同严重级别", async ({ mount }) => { const success = await mount() await expect(success.getByRole("alert")).toContainText("已保存!") await expect(success.getByRole("alert")).toHaveAttribute("data-severity", "success") const error = await mount() await expect(error.getByRole("alert")).toContainText("保存失败") await expect(error.getByRole("alert")).toHaveAttribute("data-severity", "error") }) test("Badge 渲染计数并在超过 99 时显示 99+", async ({ mount }) => { const low = await mount() await expect(low).toContainText("5") const high = await mount() await expect(high).toContainText("99+") const zero = await mount() await expect(zero).toBeHidden() }) test("Avatar 在未提供图片时显示首字母", async ({ mount }) => { const withImage = await mount() await expect(withImage.getByRole("img", { name: "张三" })).toBeVisible() const withoutImage = await mount() await expect(withoutImage.getByText("ZS")).toBeVisible() await expect(withoutImage.getByRole("img")).toHaveCount(0) }) test("禁用按钮不可交互", async ({ mount }) => { const component = await mount() await expect(component.getByRole("button", { name: "提交" })).toBeDisabled() }) ``` ### 5. 测试事件 **何时使用**:组件会触发事件或调用回调 props——表单提交、开关切换、自定义事件。 **何时避免**:你只关心组件渲染了什么——改用 prop/快照测试。 通过向 `mount()` 传递回调 props 来捕获事件。使用闭包或数组收集值以便断言。 **TypeScript** ```typescript import { test, expect } from '@playwright/experimental-ct-react'; import { Toggle } from './Toggle'; import { ContactForm } from './ContactForm'; import { TagInput } from './TagInput'; test('Toggle 触发 onChange 并传入新值', async ({ mount }) => { const events: boolean[] = []; const component = await mount( { events.push(checked); }} /> ); await component.getByRole('switch', { name: '深色模式' }).click(); expect(events).toEqual([true]); await component.getByRole('switch', { name: '深色模式' }).click(); expect(events).toEqual([true, false]); }); test('表单调用 onSubmit 并传入字段值', async ({ mount }) => { let submittedData: Record | null = null; const component = await mount( ) => { submittedData = data; }} /> ); await component.getByLabel('姓名').fill('张三'); await component.getByLabel('邮箱').fill('zhangsan@example.com'); await component.getByLabel('留言').fill('你好!'); await component.getByRole('button', { name: '发送' }).click(); expect(submittedData).toEqual({ name: '张三', email: 'zhangsan@example.com', message: '你好!', }); }); test('Tag 输入触发 onTagAdd 和 onTagRemove', async ({ mount }) => { const added: string[] = []; const removed: string[] = []; const component = await mount( { added.push(tag); }} onTagRemove={(tag: string) => { removed.push(tag); }} /> ); const input = component.getByRole('textbox'); await input.fill('playwright'); await input.press('Enter'); expect(added).toEqual(['playwright']); // 移除标签 await component.getByRole('button', { name: '移除 playwright' }).click(); expect(removed).toEqual(['playwright']); }); ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/experimental-ct-react") const { Toggle } = require("./Toggle") const { ContactForm } = require("./ContactForm") const { TagInput } = require("./TagInput") test("Toggle 触发 onChange 并传入新值", async ({ mount }) => { const events = [] const component = await mount( { events.push(checked) }} /> ) await component.getByRole("switch", { name: "深色模式" }).click() expect(events).toEqual([true]) await component.getByRole("switch", { name: "深色模式" }).click() expect(events).toEqual([true, false]) }) test("表单调用 onSubmit 并传入字段值", async ({ mount }) => { let submittedData = null const component = await mount( { submittedData = data }} /> ) await component.getByLabel("姓名").fill("张三") await component.getByLabel("邮箱").fill("zhangsan@example.com") await component.getByLabel("留言").fill("你好!") await component.getByRole("button", { name: "发送" }).click() expect(submittedData).toEqual({ name: "张三", email: "zhangsan@example.com", message: "你好!", }) }) test("Tag 输入触发 onTagAdd 和 onTagRemove", async ({ mount }) => { const added = [] const removed = [] const component = await mount( { added.push(tag) }} onTagRemove={(tag) => { removed.push(tag) }} /> ) const input = component.getByRole("textbox") await input.fill("playwright") await input.press("Enter") expect(added).toEqual(["playwright"]) await component.getByRole("button", { name: "移除 playwright" }).click() expect(removed).toEqual(["playwright"]) }) ``` ### 6. 测试插槽和子元素 **何时使用**:你的组件接受子元素、具名插槽(Vue)或 render props——布局组件、包裹组件、模态框。 **何时避免**:组件没有插槽/子元素 API。 **TypeScript** ```typescript import { test, expect } from '@playwright/experimental-ct-react'; import { Modal } from './Modal'; import { Accordion } from './Accordion'; import { Layout } from './Layout'; test('Modal 在对话框中渲染子元素', async ({ mount }) => { const component = await mount(

你确定要删除此项吗?

); await expect(component.getByRole('dialog', { name: '确认' })).toBeVisible(); await expect(component.getByText('你确定要删除此项吗?')).toBeVisible(); await expect(component.getByRole('button', { name: '删除' })).toBeVisible(); await expect(component.getByRole('button', { name: '取消' })).toBeVisible(); }); test('Accordion 渲染多个区域', async ({ mount }) => { const component = await mount( 区域 1 的内容 区域 2 的内容 ); // 默认情况下第一个区域折叠 await expect(component.getByText('区域 1 的内容')).toBeHidden(); // 展开第一个区域 await component.getByRole('button', { name: '区域 1' }).click(); await expect(component.getByText('区域 1 的内容')).toBeVisible(); // 第二个区域仍然折叠 await expect(component.getByText('区域 2 的内容')).toBeHidden(); }); test('Layout 组件渲染头部和侧边栏插槽', async ({ mount }) => { const component = await mount( 仪表盘} sidebar={} >

主体内容在此处

); await expect(component.getByRole('heading', { name: '仪表盘' })).toBeVisible(); await expect(component.getByRole('link', { name: '设置' })).toBeVisible(); await expect(component.getByText('主体内容在此处')).toBeVisible(); }); ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/experimental-ct-react") const { Modal } = require("./Modal") const { Accordion } = require("./Accordion") const { Layout } = require("./Layout") test("Modal 在对话框中渲染子元素", async ({ mount }) => { const component = await mount(

你确定要删除此项吗?

) await expect(component.getByRole("dialog", { name: "确认" })).toBeVisible() await expect(component.getByText("你确定要删除此项吗?")).toBeVisible() await expect(component.getByRole("button", { name: "删除" })).toBeVisible() await expect(component.getByRole("button", { name: "取消" })).toBeVisible() }) test("Accordion 渲染多个区域", async ({ mount }) => { const component = await mount( 区域 1 的内容 区域 2 的内容 ) await expect(component.getByText("区域 1 的内容")).toBeHidden() await component.getByRole("button", { name: "区域 1" }).click() await expect(component.getByText("区域 1 的内容")).toBeVisible() await expect(component.getByText("区域 2 的内容")).toBeHidden() }) test("Layout 组件渲染头部和侧边栏插槽", async ({ mount }) => { const component = await mount( 仪表盘} sidebar={ } >

主体内容在此处

) await expect(component.getByRole("heading", { name: "仪表盘" })).toBeVisible() await expect(component.getByRole("link", { name: "设置" })).toBeVisible() await expect(component.getByText("主体内容在此处")).toBeVisible() }) ``` **Vue 插槽示例(TypeScript)** ```typescript import { test, expect } from "@playwright/experimental-ct-vue" import Card from "./Card.vue" test("Vue 具名插槽", async ({ mount }) => { const component = await mount(Card, { props: { title: "我的卡片" }, slots: { default: "

卡片主体内容

", footer: "", }, }) await expect(component.getByText("卡片主体内容")).toBeVisible() await expect(component.getByRole("button", { name: "保存" })).toBeVisible() }) ``` ### 7. 提供上下文(Wrapper 和 Provider) **何时使用**:你的组件依赖 React context、Vue provide/inject 或全局状态(主题、认证、国际化、Store)。 **何时避免**:组件没有上下文依赖——不要不必要地包裹。 使用 `playwright/index.ts` 文件注册全局 wrapper,或使用 wrapper 组件在每个测试中包裹。 **TypeScript** ```typescript // playwright/index.tsx — 所有组件测试的全局 wrapper import '../src/styles/globals.css'; import { ThemeProvider } from '../src/providers/ThemeProvider'; import { IntlProvider } from '../src/providers/IntlProvider'; // beforeMount 在每个组件挂载之前运行 // 使用它来用全局 provider 包裹所有组件 import { beforeMount } from '@playwright/experimental-ct-react/hooks'; beforeMount(async ({ App }) => { return ( ); }); ``` ```typescript // 每个测试独立的 provider 包裹——适用于需要特定上下文的测试 import { test, expect } from '@playwright/experimental-ct-react'; import { UserProfile } from './UserProfile'; import { AuthContext } from '../contexts/AuthContext'; // 创建测试 wrapper 组件 function AuthWrapper({ children, user }: { children: React.ReactNode; user: any }) { return ( {children} ); } test('个人资料页面显示已认证用户信息', async ({ mount }) => { const user = { name: '张三', email: 'zhangsan@example.com', role: 'admin' }; const component = await mount( ); await expect(component.getByText('张三')).toBeVisible(); await expect(component.getByText('admin')).toBeVisible(); }); ``` ```typescript // Redux/Zustand Store 包裹 import { test, expect } from '@playwright/experimental-ct-react'; import { Provider } from 'react-redux'; import { configureStore } from '@reduxjs/toolkit'; import { cartReducer } from '../store/cartSlice'; import { CartSummary } from './CartSummary'; test('购物车摘要从 Store 显示商品数量', async ({ mount }) => { const store = configureStore({ reducer: { cart: cartReducer }, preloadedState: { cart: { items: [ { id: '1', name: '小工具', quantity: 2, price: 9.99 }, { id: '2', name: '配件', quantity: 1, price: 24.99 }, ], }, }, }); const component = await mount( ); await expect(component.getByText('3 件商品')).toBeVisible(); await expect(component.getByText('$44.97')).toBeVisible(); }); ``` **JavaScript** ```javascript // playwright/index.jsx — 全局 wrapper import "../src/styles/globals.css" import { ThemeProvider } from "../src/providers/ThemeProvider" import { IntlProvider } from "../src/providers/IntlProvider" import { beforeMount } from "@playwright/experimental-ct-react/hooks" beforeMount(async ({ App }) => { return ( ) }) ``` ```javascript // 每个测试独立的 Store 包裹 const { test, expect } = require("@playwright/experimental-ct-react") const { Provider } = require("react-redux") const { configureStore } = require("@reduxjs/toolkit") const { cartReducer } = require("../store/cartSlice") const { CartSummary } = require("./CartSummary") test("购物车摘要从 Store 显示商品数量", async ({ mount }) => { const store = configureStore({ reducer: { cart: cartReducer }, preloadedState: { cart: { items: [ { id: "1", name: "小工具", quantity: 2, price: 9.99 }, { id: "2", name: "配件", quantity: 1, price: 24.99 }, ], }, }, }) const component = await mount( ) await expect(component.getByText("3 件商品")).toBeVisible() await expect(component.getByText("$44.97")).toBeVisible() }) ``` ### 8. 模拟导入 **何时使用**:组件导入了不应在测试中运行的模块——API 客户端、分析工具、大型第三方库。 **何时避免**:你可以通过 props 或 context 提供依赖项——显式注入总是比模拟导入更好。 使用 `playwright/index.ts` 中的 `beforeMount` 钩子来拦截和替换模块。 **TypeScript** ```typescript // playwright/index.tsx — 全局模拟模块 import { beforeMount } from "@playwright/experimental-ct-react/hooks" beforeMount(async ({ hooksConfig }) => { // hooksConfig 通过 mount 选项从单个测试传入 if (hooksConfig?.mockApi) { // 在组件加载前模拟 API 模块 const apiModule = await import("../src/api/client") apiModule.fetchUser = async () => hooksConfig.mockUser apiModule.fetchProducts = async () => hooksConfig.mockProducts } }) ``` ```typescript // UserDashboard.ct.tsx import { test, expect } from '@playwright/experimental-ct-react'; import { UserDashboard } from './UserDashboard'; test('仪表盘使用模拟 API 数据渲染', async ({ mount }) => { const component = await mount(, { hooksConfig: { mockApi: true, mockUser: { name: '张三', email: 'zhangsan@example.com' }, mockProducts: [ { id: '1', name: '小工具', price: 9.99 }, { id: '2', name: '配件', price: 24.99 }, ], }, }); await expect(component.getByText('张三')).toBeVisible(); await expect(component.getByRole('listitem')).toHaveCount(2); }); ``` ```typescript // 替代方案:使用 page.route() 在网络层面进行模拟 import { test, expect } from '@playwright/experimental-ct-react'; import { ProductList } from './ProductList'; test('使用网络层面模拟的商品列表', async ({ mount, page }) => { // 拦截组件发出的 fetch/XHR 调用 await page.route('**/api/products', (route) => route.fulfill({ json: [ { id: '1', name: '小工具', price: 9.99 }, { id: '2', name: '配件', price: 24.99 }, ], }) ); const component = await mount(); await expect(component.getByRole('listitem')).toHaveCount(2); await expect(component.getByText('小工具')).toBeVisible(); }); ``` **JavaScript** ```javascript // playwright/index.jsx — 全局模拟模块 const { beforeMount } = require("@playwright/experimental-ct-react/hooks") beforeMount(async ({ hooksConfig }) => { if (hooksConfig?.mockApi) { const apiModule = await import("../src/api/client") apiModule.fetchUser = async () => hooksConfig.mockUser apiModule.fetchProducts = async () => hooksConfig.mockProducts } }) ``` ```javascript // 替代方案:网络层面模拟 const { test, expect } = require("@playwright/experimental-ct-react") const { ProductList } = require("./ProductList") test("使用网络层面模拟的商品列表", async ({ mount, page }) => { await page.route("**/api/products", (route) => route.fulfill({ json: [ { id: "1", name: "小工具", price: 9.99 }, { id: "2", name: "配件", price: 24.99 }, ], }) ) const component = await mount() await expect(component.getByRole("listitem")).toHaveCount(2) await expect(component.getByText("小工具")).toBeVisible() }) ``` ### 9. 视觉组件测试 **何时使用**:你需要对组件外观进行像素级验证——设计系统组件、主题变体、响应式状态。 **何时避免**:组件是纯功能性的,没有有意义的视觉输出。 组件测试支持与 E2E 测试相同的 `toHaveScreenshot()` 方法。这对于测试单个组件状态而不需要完整的应用程序非常有用。 **TypeScript** ```typescript import { test, expect } from '@playwright/experimental-ct-react'; import { Button } from './Button'; import { Card } from './Card'; test('按钮视觉变体', async ({ mount }) => { const primary = await mount(); await expect(primary).toHaveScreenshot('button-primary.png'); const secondary = await mount(); await expect(secondary).toHaveScreenshot('button-secondary.png'); const danger = await mount(); await expect(danger).toHaveScreenshot('button-danger.png'); }); test('按钮状态', async ({ mount }) => { const component = await mount(); // 默认状态 await expect(component).toHaveScreenshot('button-default.png'); // 悬停状态 await component.hover(); await expect(component).toHaveScreenshot('button-hover.png'); // 聚焦状态 await component.focus(); await expect(component).toHaveScreenshot('button-focus.png'); }); test('Card 渲染一致', async ({ mount }) => { const component = await mount( ); await expect(component).toHaveScreenshot('card-with-button.png', { maxDiffPixelRatio: 0.01, }); }); test('响应式组件在不同宽度下表现', async ({ mount, page }) => { const component = await mount(); await page.setViewportSize({ width: 1200, height: 800 }); await expect(component).toHaveScreenshot('card-desktop.png'); await page.setViewportSize({ width: 375, height: 667 }); await expect(component).toHaveScreenshot('card-mobile.png'); }); ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/experimental-ct-react") const { Button } = require("./Button") const { Card } = require("./Card") test("按钮视觉变体", async ({ mount }) => { const primary = await mount() await expect(primary).toHaveScreenshot("button-primary.png") const secondary = await mount() await expect(secondary).toHaveScreenshot("button-secondary.png") const danger = await mount() await expect(danger).toHaveScreenshot("button-danger.png") }) test("按钮状态", async ({ mount }) => { const component = await mount() await expect(component).toHaveScreenshot("button-default.png") await component.hover() await expect(component).toHaveScreenshot("button-hover.png") await component.focus() await expect(component).toHaveScreenshot("button-focus.png") }) test("响应式组件在不同宽度下表现", async ({ mount, page }) => { const component = await mount() await page.setViewportSize({ width: 1200, height: 800 }) await expect(component).toHaveScreenshot("card-desktop.png") await page.setViewportSize({ width: 375, height: 667 }) await expect(component).toHaveScreenshot("card-mobile.png") }) ``` ### 10. 组件测试与 E2E 测试对比 **何时使用**:在决定对某个 UI 是编写组件测试、E2E 测试还是两者都写时。 **何时避免**:你已经为项目制定了清晰的测试策略。 核心区别:组件测试验证**组件在隔离环境中的行为**,而 E2E 测试验证**整个系统如何协同工作**。二者相辅相成。 **TypeScript — 组件测试(隔离行为)** ```typescript // Button.ct.tsx — 在隔离环境中测试 Button 组件 import { test, expect } from '@playwright/experimental-ct-react'; import { Button } from './Button'; test('当 loading prop 为 true 时按钮显示加载中旋转器', async ({ mount }) => { const component = await mount(); await expect(component.getByRole('button', { name: '保存' })).toBeDisabled(); await expect(component.getByRole('progressbar')).toBeVisible(); await expect(component.getByText('保存')).toBeVisible(); }); test('点击时按钮调用 onClick', async ({ mount }) => { let clicked = false; const component = await mount( ); await component.click(); expect(clicked).toBe(true); }); ``` ```typescript // checkout.spec.ts — 验证完整流程的 E2E 测试 import { test, expect } from "@playwright/test" test("完整结账流程", async ({ page }) => { await page.goto("/products") await page.getByRole("button", { name: "加入购物车" }).first().click() await page.getByRole("link", { name: "购物车" }).click() await page.getByRole("button", { name: "结账" }).click() // 填写配送表单 await page.getByLabel("地址").fill("123 Main St") await page.getByLabel("城市").fill("Springfield") await page.getByRole("button", { name: "继续支付" }).click() // 保存按钮的加载状态已在组件测试中测试。 // 这里我们端到端测试真实的用户流程。 await page.getByRole("button", { name: "提交订单" }).click() await expect(page.getByRole("heading", { name: "订单已确认" })).toBeVisible() }) ``` **JavaScript — 组件测试(隔离行为)** ```javascript // Button.ct.jsx const { test, expect } = require("@playwright/experimental-ct-react") const { Button } = require("./Button") test("当 loading prop 为 true 时按钮显示加载中旋转器", async ({ mount }) => { const component = await mount() await expect(component.getByRole("button", { name: "保存" })).toBeDisabled() await expect(component.getByRole("progressbar")).toBeVisible() }) test("点击时按钮调用 onClick", async ({ mount }) => { let clicked = false const component = await mount( ) await component.click() expect(clicked).toBe(true) }) ``` ```javascript // checkout.spec.js — E2E 测试 const { test, expect } = require("@playwright/test") test("完整结账流程", async ({ page }) => { await page.goto("/products") await page.getByRole("button", { name: "加入购物车" }).first().click() await page.getByRole("link", { name: "购物车" }).click() await page.getByRole("button", { name: "结账" }).click() await page.getByLabel("地址").fill("123 Main St") await page.getByLabel("城市").fill("Springfield") await page.getByRole("button", { name: "继续支付" }).click() await page.getByRole("button", { name: "提交订单" }).click() await expect(page.getByRole("heading", { name: "订单已确认" })).toBeVisible() }) ``` ## 决策指南 | UI 元素 | 组件测试 | E2E 测试 | 单元测试 | | ------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------- | | **按钮**(变体、状态、加载中) | 是——测试所有视觉变体、禁用状态、加载状态、点击处理 | 仅作为更大流程的一部分 | 否——需要真实 DOM 来实现样式和可访问性 | | **表单字段**(验证、掩码) | 是——在隔离环境中测试验证消息、输入掩码、错误状态 | 是——测试包含后端交互的完整表单提交流程 | 仅验证逻辑(正则表达式、格式化函数) | | **模态框/对话框**(打开、关闭、内容) | 是——测试打开/关闭行为、焦点陷阱、内容渲染 | 是——测试触发模态框打开的流程 | 否——需要真实 DOM | | **数据表格**(排序、过滤、分页) | 是——使用模拟数据测试排序、过滤、分页 | 是——使用真实 API 数据和 URL 同步测试 | 纯排序/过滤逻辑(作用于数组) | | **导航/菜单** | 部分——测试下拉行为、活动状态 | 是——测试实际路由变更和页面加载 | 否 | | **完整页面**(仪表盘、设置) | 否——需要太多上下文;违背隔离目的 | 是——这就是 E2E 测试的用途 | 否 | | **布局**(侧边栏、头部、网格) | 是——测试响应式行为、插槽渲染 | 仅当布局影响用户流程时(如移动导航) | 否 | | **图表/图形** | 是——对渲染输出进行视觉回归测试 | 仅当图表是关键流程的一部分时 | 仅数据转换逻辑 | | **Toast/通知** | 是——测试出现、自动关闭、操作按钮 | 是——测试真实操作是否触发正确的 Toast | 否 | | **设计系统原语** | 是——这是组件测试的主要用例 | 否——原语不需要 | 否 | **经验法则**:组件测试用于**隔离环境中的行为和外观**。E2E 测试用于**跨多个组件和页面的用户旅程**。单元测试用于**无需 DOM 的纯逻辑**。 ## 反模式 | 不要这样做 | 问题 | 应该这样做 | | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | 检查内部状态(`component.state.count`) | 将测试与实现耦合;任何重构都会导致测试失败 | 断言可见输出:`expect(component.getByText('计数:5')).toBeVisible()` | | 在组件测试中挂载整个页面 | 需要太多 provider、模拟数据和上下文;速度慢;违背隔离目的 | 完整页面使用 E2E 测试;组件测试只测试单个小部件 | | 跳过必需的 provider/上下文 | 组件因报错崩溃 | 在 `playwright/index.tsx` 中包裹必需的 provider,或在每个测试中使用 wrapper | | 测试框架内部机制(React 生命周期、Vue watcher) | 你在测试框架本身,而不是你的代码;这些已经被 React/Vue 测试过了 | 测试用户可见的行为:渲染了什么、点击后发生了什么 | | 挂载后不等待直接截图 | 截图捕获了加载/过渡状态 | 截图前先 `await expect(component.getByText('...')).toBeVisible()` | | 在组件测试中重复 E2E 测试的覆盖范围 | 相同的行为测试两遍,增加了维护成本却没有带来新的信心 | 组件测试:隔离行为。E2E 测试:集成流程。仅在关键边界处重叠 | | 测试 CSS 类名或内联样式 | 实现细节;任何样式重构都会导致测试失败 | 使用 `toHaveScreenshot()` 进行视觉验证,或使用 `toBeVisible()`/`toBeHidden()` 测试行为 | | 每个组件创建一个巨型测试文件 | 难以调试、反馈循环慢、测试隔离性差 | 每个组件一个测试文件,使用 `test.describe()` 按行为分组 | | 传入与真实 API 结构不匹配的模拟数据 | 测试通过但组件在真实数据下崩溃 | 定义共享 TypeScript 类型或使用工厂函数来生成一致的测试数据 | | 在组件测试中使用 `page.goto()` | 组件测试不导航——`mount()` 直接渲染 | 只使用 `mount()`;`page.goto()` 仅在 E2E 测试中使用 | ## 故障排查 | 症状 | 原因 | 修复 | | --------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | | `Cannot find module '@playwright/experimental-ct-react'` | 包未安装 | `npm install -D @playwright/experimental-ct-react`(或 `-vue`、`-svelte`) | | 组件渲染为空白 | 缺少 CSS 导入或 provider 包裹 | 将全局样式添加到 `playwright/index.ts`,并用必需的 provider 包裹 | | `Error: No tests found` | 测试文件与 `playwright-ct.config.ts` 中的 `testMatch` 模式不匹配 | 确保文件使用配置的后缀(如 `*.ct.tsx`)且 `testDir` 正确 | | 测试文件中无法使用 JSX | TypeScript/Vite 未配置 JSX | 确保测试文件使用 `.ct.tsx`/`.ct.jsx` 扩展名,且 `tsconfig.json` 中有 `"jsx": "react-jsx"` | | `useContext 返回 undefined` | 组件依赖的上下文 provider 未被包裹 | 在 `playwright/index.tsx` 中通过 `beforeMount` 钩子添加 provider,或在每个测试中包裹 | | `hooksConfig` 值未定义 | `playwright/index.ts` 的 hooks 文件未设置或未读取 `hooksConfig` | 确保 `beforeMount` 解构了 `{ hooksConfig }`,且文件位于 `playwright/index.ts` | | CI 与本地截图不一致 | 不同操作系统渲染字体不同 | 在 Docker 中运行截图测试,或使用 `maxDiffPixelRatio` 容差;在 CI 中生成基线 | | 组件测试速度慢(每个测试 >5 秒) | 挂载了包含许多 provider 或导入重模块的大型组件树 | 缩小 provider 范围;模拟重型导入;使用 `page.route()` 替代真实 API 调用 | | `mount()` 返回但组件不可见 | 组件默认渲染在屏幕外或带有 `display: none` | 检查 CSS 和 props;使用 `await expect(component).toBeVisible()` 调试 | | `Error: page.route is not available` | 未请求 `page` 夹具就直接使用 | 在测试函数签名中解构 `{ mount, page }` | ## 相关文档 - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) — 夹具在组件测试中的工作方式与 E2E 测试相同 - [core/visual-regression.md](visual-regression.md) — 适用于组件测试的截图对比模式 - [core/network-mocking.md](network-mocking.md) — `page.route()` 在组件测试中可用于模拟 API 调用 - [core/test-architecture.md](test-architecture.md) — 何时使用组件测试、E2E 测试与 API 测试 - [core/react.md](react.md) — React 特有的组件测试设置 - [core/vue.md](vue.md) — Vue 特有的组件测试(插槽和 provide/inject)