组件测试 Component Testing
本教程共 59 篇 · 第 50 篇 · 更新于 2026-08-04 · 约 10 分钟阅读
本节目标:学完你能用 Playwright 1.62.x 的新模型,在真实浏览器里把单个组件拆开测,而不是整页端到端地跑。
组件测不住,整页测试就会又慢又脆。
Playwright 1.62 把组件测试(component testing)彻底改了。旧的实验包已弃用,新模型是 stories(故事)+ galleries(画廊)+ fixtures.mount()。
Warning下面这种旧写法在 1.62 已经弃用,不要这样做:
// ❌ 旧写法(已弃用,1.62 不要这样写) import { test, expect } from '@playwright/experimental-ct-react'; test('counts clicks', async ({ mount }) => { let clicks = 0; const component = await mount(<Button onClick={() => ++clicks} />); await component.getByRole('button').click(); expect(clicks).toBe(1); // 回调跨 Node/浏览器边界,半坏不坏 });它要在测试里编译 JSX、还要把回调跨边界传给浏览器,坑很多。新模型不再这么干。
新模型三个概念
story(故事):把一个组件包成「某个具体状态」的小包装。写死的 props、假数据、providers 都塞在这里。每个具名导出就是一个状态。它和组件文件放一起,比如 Button.story.tsx。
gallery(画廊):一个由你自己的开发服务器(dev server)托管的页面。它暴露 window.mount({ story, props }) 和 window.unmount(),把 story 渲染进 #root。它是框架相关的,归你所有。React、Vue、Svelte 都行。
fixtures.mount():@playwright/test 的内置夹具(Fixture,夹具)。它导航到画廊页,按 id 挂载某个 story,返回挂载根元素的 Locator(定位器)——story 就渲染在里面。之后你从这个 Locator 出发去查、去点。
Note关键点:组件在浏览器里跑,测试在 Node.js 里跑。真实点击、真实布局、还能做视觉回归。同时你白拿 Playwright Test 的全部能力:并行、参数化、重试、Trace。
为什么换思路
旧包要接管整套编译管线:扫测试里的组件、用自己的 Vite 编包、用自己的服务起服务、还要把 props 和回调跨边界搬。
结果就是只对「跟它配置一模一样」的项目好使。webpack、Next.js、自定义管线的项目基本没法用;路径别名和插件还得手抄一份到 ctViteConfig 里。
新模型把控制权还给你。组件由你自己的 dev server 构建和托管,Playwright 不编译、不托管,它只是导航到一个页面——和别的测试没两样。
唯一框架相关的,是那个你拥有的画廊页。
第一步:准备画廊页
画廊页是应用代码,该归你。最快的办法是让 coding agent 帮你建:
npx playwright init-skills
然后对它说「用 playwright-component-testing skill 搭好组件测试」。它会识别你的框架和打包器,写好画廊、往配置里加一个 project、再写第一个 story 和 spec。
画廊要履行的契约很小,记住它也有助于排查问题:
- 放在
playwright/gallery/,由你自己的 dev server 托管(Vite 应用直接用现成的;别的就旁边起个小 Vite)。 - 发现你的
*.story.*文件,暴露window.mount({ story, props })渲染到#root,以及window.unmount()卸载。story 找不到或渲染报错,会让mount()直接抛错。 - 复用同一个根元素,所以
component.update(props)是「调和」而不是重挂,组件状态得以保留。 - 按应用入口的方式引入全局 CSS,样式才跟线上一致。
不想让 agent 代劳,技能包里的 references/gallery-spec.md 有完整规范和 React / Vue 范例,整页也就几十行。
第二步:配置 Playwright
在 playwright.config.ts 里加一个 project,把 baseURL 指向画廊:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'components',
testDir: './tests/components',
use: {
...devices['Desktop Chrome'],
baseURL: 'http://localhost:5173/playwright/gallery/index.html',
serviceWorkers: 'block',
reuseContext: true,
},
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173/playwright/gallery/index.html',
reuseExistingServer: !process.env.CI,
},
});
三个配置项各有用处:
mount会导航到baseURL,所以它必须指向画廊。serviceWorkers: 'block'防止你 app 自己的 service worker 用缓存盖掉page.route()的 mock。reuseContext: true在多个测试间复用浏览器上下文,组件套件能快很多。
第三步:写一个 story
story 和组件放一起,每个具名导出是一个状态:
// src/components/Button.story.tsx
import { Button } from './Button';
export const Primary = () => <Button title='Submit' />;
export const Disabled = () => <Button title='Submit' disabled />;
第四步:写测试
测试里只管交互和断言,状态的事交给 story:
// tests/components/button.spec.ts
import { test, expect } from '@playwright/test';
test('renders primary button', async ({ mount }) => {
const component = await mount('components/Button/Primary');
await expect(component.getByRole('button')).toHaveText('Submit');
});
test('disabled button is disabled', async ({ mount }) => {
const component = await mount('components/Button/Disabled');
await expect(component.getByRole('button')).toBeDisabled();
});
注意 mount 的第一个参数是 story id 字符串,不是组件。id 由文件路径推导:src/ 下去掉 .story.* 后缀、再加导出名,即 components/Button/Primary。写个唯一后缀也行,比如 mount('Button/Primary')。
第五步:跑
npx playwright test --project=components
story 的写法约定
- 一个状态一个导出。与其给一个 story 加参数,不如多写几个导出。
Button.story.tsx导出Primary、Disabled、WithLongTitle,读起来就像组件说明书。 - 和组件做邻居。
Button.story.tsx配Button.tsx,改名重构时一起动。 - story 拥有组件所需的一切:providers、假数据、状态、回调。测试只拥有交互和断言。
顺带一个好处:每个 story 都是一个可访问的页面状态,画廊本身就成了活的组件目录,打开 URL 就能挨个看。
实战模式:把状态记进 DOM
组件要回调,测试要验证回调触发了。别跨边界传回调,让 story 自己持有状态,把结果写进一个隐藏的输入框:
// src/components/Expandable.story.tsx
import { useState } from 'react';
import { Expandable } from './Expandable';
export const Stateful = () => {
const [expanded, setExpanded] = useState(false);
return <>
<Expandable expanded={expanded} setExpanded={setExpanded} title='Title'>Details</Expandable>
<form hidden><input data-testid='expanded' readOnly value={String(expanded)} /></form>
</>;
};
// tests/components/expandable.spec.ts
test('click should expand', async ({ mount }) => {
const component = await mount('components/Expandable/Stateful');
await component.getByRole('button').click();
await expect(component.getByTestId('expanded')).toHaveValue('true');
});
这套写法的好处不止一点。整个场景在浏览器里跑,没有回调搬运的破事;toHaveValue() 是 Web-First 断言,会自动重试,不用手写等待。
标量用 String(...) 记,复杂载荷用 JSON.stringify(...)。开发时把 hidden 去掉,还能一边点一边看状态变化。
测试里传 props 和改 props
场景想参数化,把可序列化的普通数据作为 mount 的第二个参数传进去:
// src/components/Button.story.tsx
export const WithTitle = ({ title = 'Default' }: { title?: string }) =>
<Button title={title} />;
import type { WithTitle } from '../../src/components/Button.story';
const component = await mount<typeof WithTitle>('Button/WithTitle', { title: 'Hello' });
mount 对 story 是泛型的:传模板参数后,props 和 update() 都会按 story 签名做类型检查。回调还是留在 story 里。
想测「props 变了组件怎么反应」且不重挂(保留状态),调 component.update(newProps):
const component = await mount('components/Counter/Default', { value: 1 });
await expect(component.getByTestId('value')).toHaveText('1');
await component.update({ value: 2 });
await expect(component.getByTestId('value')).toHaveText('2');
想显式卸载,调 component.unmount()。
视觉对比与网络 mock
挂多个 story 很便宜,因为每个 mount() 都是重新导航,天然隔离:
await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');
截图截的是返回的根 Locator,不是整页,避免把画廊里的杂项也算进去。
网络 mock 照常用 page.route(),但要在 mount() 之前注册,因为挂载会触发导航:
test('renders the error state', async ({ page, mount }) => {
await page.route('**/api/items', route => route.fulfill({ status: 500 }));
const component = await mount('components/ItemList/Default');
await expect(component.getByRole('alert')).toContainText('Something went wrong');
});
story 挂不上怎么查
打开画廊 URL,在 DevTools 控制台里手动敲:
await window.mount({ story: 'components/Button/Primary' });
这跟 mount 夹具干的事一模一样。story id 写错或组件渲染报错,这里就会直接 reject,错误栈是真的,比在测试里猜快。
从旧包迁移
概念对应关系:
mount(<Button onClick={spy} />)→ Stateful story:story 提供onClick并把结果记进隐藏 input,测试用toHaveValue()断言。component.update(<Button count={2} />)→component.update({ count: 2 })。component.unmount()→ 不变。- JSX 子树/插槽 → 一个 story 导出对应一种组合。
beforeMount/afterMount钩子 → 画廊window.mount的函数体(全局),或 story 装饰器(单 story)。ctViteConfig、ctPort、ctTemplateDir→ 全没了,端口写在webServer和baseURL里。
有个地方要留神:story id 是字符串。改名或挪文件,编译期不会报错,跑起来才崩。用 mount<typeof Story> 至少能把 props 绑死在 story 上。
Tip迁移别一次性全换。先把画廊和
componentsproject 搭好,旧 CT project 先留着,一个 spec 一个 spec 搬,搬完再删@playwright/experimental-ct-*依赖和playwright/index.html、playwright/index.ts这些旧文件。
什么时候用它
页面结构稳定、但又想单独验证某个组件的各种状态——组件测试正合适。它比整页端到端快、比单元测试更贴近真实渲染。
框架不是 React/Vue?让 agent 按契约给你的框架实现画廊页即可,mount 不在乎对面是哪个框架。
最后提醒一句:别在测试里去够组件实例和内部方法,官方明确不支持。要观察内部状态,就让 story 把它记进 DOM。
小结
组件测试用 mount 夹具把单个组件挂起来测,比整页端到端快、比单元测试贴近真实渲染。核心玩法是写 story 传 props,用 Web-First 断言自动重试。注意 story id 是字符串,改名编译期不报错,跑起来才崩。别去够组件实例内部方法,官方不支持,要让状态显式暴露到 DOM。