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

1216 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 组件测试
> **何时使用**:当你需要在隔离环境中测试 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(
<Button label="保存" onClick={() => { clicked = true; }} />
);
await expect(component).toContainText('保存');
await component.click();
expect(clicked).toBe(true);
});
```
## 模式
### 1. 安装与配置
**何时使用**:在现有 Playwright 项目中开始组件测试时。
**何时避免**:当你只需要对运行中的应用进行完整 E2E 测试时——组件测试会增加构建复杂度,对于纯集成测试来说并不合理。
组件测试使用独立的配置文件(`playwright-ct.config.ts`)和专用的 `playwright/index.html` 入口点。Playwright 在底层使用 Vite 打包你的组件。
**TypeScript**
```typescript
// playwright-ct.config.ts
import { defineConfig, devices } from "@playwright/experimental-ct-react"
export default defineConfig({
testDir: "./src",
testMatch: "**/*.ct.tsx",
use: {
ctPort: 3100,
// 用于组件打包的 Vite 配置
ctViteConfig: {
resolve: {
alias: {
"@": "/src",
},
},
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
})
```
```html
<!-- playwright/index.html — 组件测试入口点 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>组件测试</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>
```
```typescript
// playwright/index.ts — 所有组件测试的全局样式和 Provider
import "../src/styles/globals.css"
```
**JavaScript**
```javascript
// playwright-ct.config.js
const { defineConfig, devices } = require("@playwright/experimental-ct-react")
module.exports = defineConfig({
testDir: "./src",
testMatch: "**/*.ct.jsx",
use: {
ctPort: 3100,
ctViteConfig: {
resolve: {
alias: {
"@": "/src",
},
},
},
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
})
```
使用以下命令运行组件测试:
```bash
npx playwright test -c playwright-ct.config.ts
```
### 2. 挂载组件
**何时使用**:你需要在一个真实浏览器中渲染组件,具备完整的 DOM、CSS 和事件处理能力。
**何时避免**:组件过于简单(一个返回字符串的纯函数)时——改用单元测试。
`mount()` 夹具将你的组件渲染到一个真实浏览器页面中。它返回一个指向已挂载组件根元素的 `Locator`
**TypeScript**
```typescript
import { test, expect } from '@playwright/experimental-ct-react';
import { Card } from './Card';
test('挂载带 props 的组件', async ({ mount }) => {
const component = await mount(
<Card title="欢迎" description="开始使用 Playwright" />
);
await expect(component.getByRole('heading', { name: '欢迎' })).toBeVisible();
await expect(component.getByText('开始使用 Playwright')).toBeVisible();
});
test('挂载带子元素', async ({ mount }) => {
const component = await mount(
<Card title="操作">
<button>点击我</button>
</Card>
);
await expect(component.getByRole('button', { name: '点击我' })).toBeVisible();
});
test('挂载后更新 props', async ({ mount }) => {
const component = await mount(<Card title="初始" description="第一个" />);
await expect(component.getByRole('heading', { name: '初始' })).toBeVisible();
// 使用新 props 重新渲染
await component.update(<Card title="已更新" description="第二个" />);
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(<Card title="欢迎" description="开始使用 Playwright" />)
await expect(component.getByRole("heading", { name: "欢迎" })).toBeVisible()
await expect(component.getByText("开始使用 Playwright")).toBeVisible()
})
test("挂载带子元素", async ({ mount }) => {
const component = await mount(
<Card title="操作">
<button>点击我</button>
</Card>
)
await expect(component.getByRole("button", { name: "点击我" })).toBeVisible()
})
test("挂载后更新 props", async ({ mount }) => {
const component = await mount(<Card title="初始" description="第一个" />)
await expect(component.getByRole("heading", { name: "初始" })).toBeVisible()
await component.update(<Card title="已更新" description="第二个" />)
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(<Counter initialCount={0} />);
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(<SearchInput placeholder="搜索..." />);
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(<SearchInput placeholder="搜索..." />);
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(
<Dropdown
label="颜色"
options={['红色', '绿色', '蓝色']}
/>
);
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(<Counter initialCount={0} />)
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(<SearchInput placeholder="搜索..." />)
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(<SearchInput placeholder="搜索..." />)
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(<Dropdown label="颜色" options={["红色", "绿色", "蓝色"]} />)
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(<Alert severity="success" message="已保存!" />);
await expect(success.getByRole('alert')).toContainText('已保存!');
await expect(success.getByRole('alert')).toHaveAttribute('data-severity', 'success');
const error = await mount(<Alert severity="error" message="保存失败" />);
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(<Badge count={5} />);
await expect(low).toContainText('5');
const high = await mount(<Badge count={150} />);
await expect(high).toContainText('99+');
const zero = await mount(<Badge count={0} />);
await expect(zero).toBeHidden();
});
test('Avatar 在未提供图片时显示首字母', async ({ mount }) => {
const withImage = await mount(
<Avatar src="/photo.jpg" name="张三" />
);
await expect(withImage.getByRole('img', { name: '张三' })).toBeVisible();
const withoutImage = await mount(<Avatar name="张三" />);
await expect(withoutImage.getByText('ZS')).toBeVisible();
await expect(withoutImage.getByRole('img')).toHaveCount(0);
});
test('禁用按钮不可交互', async ({ mount }) => {
const component = await mount(
<button disabled>提交</button>
);
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(<Alert severity="success" message="已保存!" />)
await expect(success.getByRole("alert")).toContainText("已保存!")
await expect(success.getByRole("alert")).toHaveAttribute("data-severity", "success")
const error = await mount(<Alert severity="error" message="保存失败" />)
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(<Badge count={5} />)
await expect(low).toContainText("5")
const high = await mount(<Badge count={150} />)
await expect(high).toContainText("99+")
const zero = await mount(<Badge count={0} />)
await expect(zero).toBeHidden()
})
test("Avatar 在未提供图片时显示首字母", async ({ mount }) => {
const withImage = await mount(<Avatar src="/photo.jpg" name="张三" />)
await expect(withImage.getByRole("img", { name: "张三" })).toBeVisible()
const withoutImage = await mount(<Avatar name="张三" />)
await expect(withoutImage.getByText("ZS")).toBeVisible()
await expect(withoutImage.getByRole("img")).toHaveCount(0)
})
test("禁用按钮不可交互", async ({ mount }) => {
const component = await mount(<button disabled>提交</button>)
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(
<Toggle
label="深色模式"
onChange={(checked: boolean) => { 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<string, string> | null = null;
const component = await mount(
<ContactForm
onSubmit={(data: Record<string, string>) => { 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(
<TagInput
onTagAdd={(tag: string) => { 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(
<Toggle
label="深色模式"
onChange={(checked) => {
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(
<ContactForm
onSubmit={(data) => {
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(
<TagInput
onTagAdd={(tag) => {
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(
<Modal open={true} title="确认">
<p>你确定要删除此项吗?</p>
<button>删除</button>
<button>取消</button>
</Modal>
);
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(
<Accordion>
<Accordion.Item title="区域 1">区域 1 的内容</Accordion.Item>
<Accordion.Item title="区域 2">区域 2 的内容</Accordion.Item>
</Accordion>
);
// 默认情况下第一个区域折叠
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(
<Layout
header={<h1>仪表盘</h1>}
sidebar={<nav><a href="/settings">设置</a></nav>}
>
<p>主体内容在此处</p>
</Layout>
);
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(
<Modal open={true} title="确认">
<p>你确定要删除此项吗</p>
<button>删除</button>
<button>取消</button>
</Modal>
)
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(
<Accordion>
<Accordion.Item title="区域 1">区域 1 的内容</Accordion.Item>
<Accordion.Item title="区域 2">区域 2 的内容</Accordion.Item>
</Accordion>
)
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(
<Layout
header={<h1>仪表盘</h1>}
sidebar={
<nav>
<a href="/settings">设置</a>
</nav>
}
>
<p>主体内容在此处</p>
</Layout>
)
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: "<p>卡片主体内容</p>",
footer: "<button>保存</button>",
},
})
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 (
<IntlProvider locale="en">
<ThemeProvider theme="light">
<App />
</ThemeProvider>
</IntlProvider>
);
});
```
```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 (
<AuthContext.Provider value={{ user, isAuthenticated: true }}>
{children}
</AuthContext.Provider>
);
}
test('个人资料页面显示已认证用户信息', async ({ mount }) => {
const user = { name: '张三', email: 'zhangsan@example.com', role: 'admin' };
const component = await mount(
<AuthWrapper user={user}>
<UserProfile />
</AuthWrapper>
);
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(
<Provider store={store}>
<CartSummary />
</Provider>
);
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 (
<IntlProvider locale="en">
<ThemeProvider theme="light">
<App />
</ThemeProvider>
</IntlProvider>
)
})
```
```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(
<Provider store={store}>
<CartSummary />
</Provider>
)
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(<UserDashboard />, {
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(<ProductList />);
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(<ProductList />)
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(<Button variant="primary">保存</Button>);
await expect(primary).toHaveScreenshot('button-primary.png');
const secondary = await mount(<Button variant="secondary">取消</Button>);
await expect(secondary).toHaveScreenshot('button-secondary.png');
const danger = await mount(<Button variant="danger">删除</Button>);
await expect(danger).toHaveScreenshot('button-danger.png');
});
test('按钮状态', async ({ mount }) => {
const component = await mount(<Button variant="primary">保存</Button>);
// 默认状态
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(
<Card title="商品" description="一个很适合测试的商品。">
<Button variant="primary">立即购买</Button>
</Card>
);
await expect(component).toHaveScreenshot('card-with-button.png', {
maxDiffPixelRatio: 0.01,
});
});
test('响应式组件在不同宽度下表现', async ({ mount, page }) => {
const component = await mount(<Card title="响应式" description="适应宽度" />);
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(<Button variant="primary">保存</Button>)
await expect(primary).toHaveScreenshot("button-primary.png")
const secondary = await mount(<Button variant="secondary">取消</Button>)
await expect(secondary).toHaveScreenshot("button-secondary.png")
const danger = await mount(<Button variant="danger">删除</Button>)
await expect(danger).toHaveScreenshot("button-danger.png")
})
test("按钮状态", async ({ mount }) => {
const component = await mount(<Button variant="primary">保存</Button>)
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(<Card title="响应式" description="适应宽度" />)
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(<Button loading={true}>保存</Button>);
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(
<Button onClick={() => { clicked = true; }}>保存</Button>
);
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(<Button loading={true}>保存</Button>)
await expect(component.getByRole("button", { name: "保存" })).toBeDisabled()
await expect(component.getByRole("progressbar")).toBeVisible()
})
test("点击时按钮调用 onClick", async ({ mount }) => {
let clicked = false
const component = await mount(
<Button
onClick={() => {
clicked = true
}}
>
保存
</Button>
)
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(<Component />)``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