47 KiB
组件测试
何时使用:当你需要在隔离环境中测试 UI 组件时——验证渲染、交互和行为,而无需启动整个应用程序。适用于设计系统、共享组件库和复杂交互式小部件。 前置条件:core/configuration.md、core/fixtures-and-hooks.md
快速参考
// 为你的框架安装:
// 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
// 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"] },
},
],
})
<!-- 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>
// playwright/index.ts — 所有组件测试的全局样式和 Provider
import "../src/styles/globals.css"
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"] },
},
],
})
使用以下命令运行组件测试:
npx playwright test -c playwright-ct.config.ts
2. 挂载组件
何时使用:你需要在一个真实浏览器中渲染组件,具备完整的 DOM、CSS 和事件处理能力。 何时避免:组件过于简单(一个返回字符串的纯函数)时——改用单元测试。
mount() 夹具将你的组件渲染到一个真实浏览器页面中。它返回一个指向已挂载组件根元素的 Locator。
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
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
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
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
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
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
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
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
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
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)
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
// 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>
);
});
// 每个测试独立的 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();
});
// 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
// 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>
)
})
// 每个测试独立的 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
// 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
}
})
// 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);
});
// 替代方案:使用 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
// 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
}
})
// 替代方案:网络层面模拟
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
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
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 — 组件测试(隔离行为)
// 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);
});
// 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 — 组件测试(隔离行为)
// 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)
})
// 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 — 夹具在组件测试中的工作方式与 E2E 测试相同
- core/visual-regression.md — 适用于组件测试的截图对比模式
- core/network-mocking.md —
page.route()在组件测试中可用于模拟 API 调用 - core/test-architecture.md — 何时使用组件测试、E2E 测试与 API 测试
- core/react.md — React 特有的组件测试设置
- core/vue.md — Vue 特有的组件测试(插槽和 provide/inject)