1216 lines
47 KiB
Markdown
1216 lines
47 KiB
Markdown
# 组件测试
|
||
|
||
> **何时使用**:当你需要在隔离环境中测试 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)
|