Skip to main content

组件测试

介绍​

🌐 Introduction

Playwright Test 可以单独测试你 Web 应用的组件。组件测试其实就是普通的 Playwright 端到端测试,它会针对你自己开发服务器提供的一个小型 故事图库 页面运行。没有专门的组件测试运行环境,没有打包工具集成,也不需要额外的 npm 包 —— 内置的 @playwright/test 的 fixtures.mount() fixture 就能搞定这一切。

🌐 Playwright Test can test the components of your web application in isolation. A component test is a regular Playwright end-to-end test that runs against a small story gallery page served by your own dev server. There is no dedicated component-testing runtime, no bundler integration and no extra npm packages — the built-in fixtures.mount() fixture of @playwright/test drives it all.

import { test, expect } from '@playwright/test';

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');
});

测试在 Node.js 中运行,而组件在真实浏览器中运行:会触发真实的点击,执行真实的布局,视觉回归也是可能的。同时,测试还能使用 Playwright Test 提供的一切功能:并行化、参数化、重试以及事后追踪。

🌐 Tests run in Node.js while components run in a real browser: real clicks are triggered, real layout is executed, visual regression is possible. At the same time, tests get everything Playwright Test offers: parallelism, parametrization, retries and post-mortem tracing.

note

实验性的 @playwright/experimental-ct-react、-ct-react17 和 -ct-vue 包已经被移除,不再发布。如果你还在使用它们,请继续使用 Playwright 1.62,直到你按照下面的 迁移指南 完成迁移。

🌐 The experimental @playwright/experimental-ct-react, -ct-react17 and -ct-vue packages have been removed and are no longer published. If you are still on them, stay on Playwright 1.62 until you have followed the migration guide below.

为什么选择框架无关的方法​

🌐 Why a framework-agnostic approach

@playwright/experimental-ct-* 包允许测试在内联写 JSX —— mount(<Button onClick={spy} />)。为了实现这一点,Playwright 必须控制整个流程:扫描测试里的组件,用自己的 Vite 和配置编译一个包,通过自己的服务器提供,并在 Node.js 和浏览器之间传递 props 和回调。

这个设计让这些软件包永远处于实验阶段:

🌐 That design kept the packages experimental forever:

  • 它只有在你的设置与我们的匹配时才有效。 路径别名、插件和 CSS 处理都必须手动同步到 ctViteConfig。使用 webpack、Next.js 或自定义流水线的项目根本无法使用它们自己的构建。每个框架都需要自己的包和运行时胶水,而每增加一个框架,就意味着又要增加一个包。
  • Node.js/浏览器的边界泄漏了。 测试中写的 JSX 在 Node.js 中被编译,然后在浏览器中重新组装。实时对象无法跨越,回调在编组时只部分起作用,模块模拟也默默地没有生效。

替换会颠倒控制:

🌐 The replacement inverts the control:

  • 你掌控整个流程。 组件由你自己的开发服务器构建和提供,使用你的插件、别名以及 CSS。Playwright 不会编译或提供任何内容——它只会像在其他测试中一样访问页面。
  • 它与框架无关。 唯一与框架相关的部分是图库页面 — 一个你拥有的小模块。React、Vue、Svelte、Solid 或其他任何东西:只要你的开发服务器能渲染它,Playwright 就能测试它。
  • 它很稳定。 测试从普通的 @playwright/test 导入 test 和 expect,而 fixtures.mount() 是官方文档中说明的内置 fixture。没有需要依赖的实验性包,也没有单独的配置方言。

它是怎么运作的​

🌐 How it works

这个模型由三个概念组成:

🌐 Three concepts make up the whole model:

  • 一个故事是一个小的封装组件,它在一个特定场景中嵌入被测试的组件:硬编码的 props、模拟数据、提供者、已记录的回调。故事文件通常和组件放在一起,文件名是 *.story.tsx(或 .ts/.jsx/.js/.vue);每个命名导出就是一个故事。
  • 图库 是一个单页,由你的开发服务器提供,它会将从你的故事文件中解析出来的 window.mount(params) 和 window.unmount() 函数渲染到 #root 元素中。它是特定于框架的,由你自己管理。
  • fixtures.mount() 夹具会导航到图库 (testOptions.baseURL),使用故事 ID 和属性调用 window.mount(),并返回图库根节点的 Locator。从它开始范围你的查询:component.getByRole('button').click()。

组件所需要的一切都在 故事 里设置好,并且在浏览器中运行。测试断言的一切都可以 通过页面 观察到:DOM、URL、网络。

🌐 Everything the component needs is set up inside the story, which runs in the browser. Everything the test asserts is observable through the page: DOM, URL, network.

入门​

🌐 Getting started

步骤 1:让你的编程助手指向这个技能​

🌐 Step 1: Point your coding agent at the skill

图库是应用代码——它属于你,而不是 Playwright。获取它最快的方法是不自己写:Playwright 以代理技能的形式提供整个方法。安装这些技能,然后让你的编码代理(Claude Code、GitHub Copilot 或类似工具)来完成设置:

🌐 The gallery is application code — it belongs to you, not to Playwright. The fastest way to get one is to not write it yourself: Playwright ships this entire methodology as an agent skill. Install the skills and ask your coding agent (Claude Code, GitHub Copilot or similar) to do the setup:

npx playwright init-skills
Set up component testing using the playwright-component-testing skill.

该代理会检测你的框架和打包工具,为你的技术栈实现图库,向配置中添加一个 Playwright 项目,并写出第一个故事和测试规范。

🌐 The agent detects your framework and bundler, implements the gallery for your stack, adds a Playwright project to the config, and writes the first story and spec.

图库履行的合同很小,但值得了解,即使你从未打开过文件:

🌐 The contract the gallery fulfills is small and worth knowing, even if you never open the file:

  • 这是 playwright/gallery/ 下的单页,由 你自己的开发服务器 提供 —— Vite 应用会用它们已有的开发服务器提供服务;其他设置会在应用旁边运行一个小型独立的 Vite 服务器。
  • 它会发现你的 *.story.* 文件,并提供两个函数:window.mount({ story, props }) 会将给定 id 的故事渲染到 #root 元素中,而 window.unmount() 会将其拆除。未知的故事或渲染错误会导致拒绝,这会表现为测试的 mount() 调用抛出异常。
  • 它在多次调用中重用渲染根,所以 component.update(props) 会进行协调而不是重新挂载,组件状态会被保留。
  • 它以和应用入口相同的方式导入你的全局 CSS,而 window.mount 的主体是进行全应用设置的自然位置——相当于旧的 beforeMount/afterMount 钩子。

如果你更喜欢手写图库,已安装的技能包含完整规范以及在 references/gallery-spec.md 中的 React 和 Vue 示例——整页只有几十行。

🌐 If you prefer to write the gallery by hand, the installed skill contains the full specification with worked React and Vue examples in references/gallery-spec.md — the whole page is a few dozen lines.

步骤 2:配置 Playwright​

🌐 Step 2: Configure Playwright

将一个项目添加到你的 playwright.config.ts 并将 baseURL 指向图库:

🌐 Add a project to your playwright.config.ts and point baseURL at the gallery:

playwright.config.ts
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' 阻止应用自己的服务工作者提供会覆盖你 page.route() 模拟的缓存响应。reuseContext: true 在工作者中复用浏览器上下文——这对组件测试套件来说速度提升很大,也是实验包隐式应用的同样优化。

步骤 3:写一个故事​

🌐 Step 3: Write a story

故事就存在于它们所作用的组件旁边。每个命名导出都是一个场景:

🌐 Stories live next to the component they exercise. Each named export is one scenario:

src/components/Button.story.tsx
import { Button } from './Button';

export const Primary = () => <Button title='提交' />;

export const Disabled = () => <Button title='提交' disabled />;

步骤 4:写个测试​

🌐 Step 4: Write a test

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();
});

步骤 5:运行​

🌐 Step 5: Run

npx playwright test --project=components

以故事为方法​

🌐 Stories as a methodology

故事不仅仅是测试的替代方法——它们是可搜索、可审查的组件状态文档,而这些约定让它们保持这种状态:

🌐 Stories are not just a testing workaround — they are greppable, reviewable documentation of your component states, and the conventions keep them that way:

  • 每个场景只导出一次。 相较于对已有故事进行参数化,更倾向于导出新的故事。Button.story.tsx 导出 Primary、Disabled、WithLongTitle 时,就像是在说明组件的规范。
  • 故事与组件紧邻而居。 src/components/Button.story.tsx 记录了 src/components/Button.tsx。重命名和重构会同时影响两者。
  • 故事ID来源于文件路径:在 src/ 下的路径去掉 .story.* 扩展名,再加上导出名称——components/Button/Primary。任何独特的后缀也可以:mount('Button/Primary')。
  • 故事拥有组件所需的一切:提供者、模拟数据、状态、回调。测试只拥有交互和断言。

因为每个故事都是一个有名称、可寻址的页面状态,图库也就成了一个活的目录:在浏览器中打开图库的 URL,然后渲染任何故事来眼观检查它。

🌐 Because every story is a named, addressable page state, the gallery doubles as a living catalog: open the gallery URL in a browser and render any story to inspect it by eye.

测试模式​

🌐 Testing patterns

记录状态以进行断言​

🌐 Record state for assertions

组件接受回调;测试想要断言它们被触发。与其在 Node.js 和浏览器之间传递回调,故事拥有状态并提供回调——并将可观察的结果记录到组件旁边的隐藏表单中:

🌐 Components take callbacks; tests want to assert they fired. Instead of marshalling callbacks between Node.js and the browser, the story owns the state and provides the callbacks — and records the observable outcome into a hidden form next to the component:

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='标题'>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');
});

这个模式是方法论的核心:

🌐 This pattern is the heart of the methodology:

  • 整个场景都在浏览器里运行——没有回调封送处理,也没有 Node.js/浏览器边界可以泄露。
  • toHaveValue() 是一个以网络为优先的断言:它会不断重试,直到状态达成,所以不需要手动等待或轮询。
  • 将每个观察到的值记录在各自的 data-testid 输入中 — 标量用 String(...),有效载荷用 JSON.stringify(...)。负向操作也是同样的方法:执行操作,然后确认值没有改变。
  • 录制的状态在你打开图库中的故事时可见。手动点击组件并观察数值变化 —— 这个故事也可以作为自动化测试所覆盖的准确场景的手动测试页面。保持 hidden 表单以获得干净的截图基线,或者在开发时去掉 hidden 属性以便在组件旁实时查看状态。

每个测试的属性​

🌐 Per-test props

当某个场景适合参数化时,把普通的可序列化属性作为第二个参数传给 mount。组件库会把它们作为属性传给故事:

🌐 When a scenario benefits from parameterizing, pass plain serializable props as the second argument to mount. The gallery hands them to the story as its props:

src/components/Button.story.tsx
import { Button } from './Button';

export const WithTitle = ({ title = 'Default' }: { title?: string }) =>
<Button title={title} />;
tests/components/button.spec.ts
import type { WithTitle } from '../../src/components/Button.story';

const component = await mount<typeof WithTitle>('Button/WithTitle', { title: 'Hello' });

mount 对故事是通用的:将故事类型作为模板参数传递,并且 props(以及 update())会根据故事签名进行类型检查。保持 props 为简单可序列化的数据 —— 回调函数应该放在故事内部。

带 update() 的属性过渡​

🌐 Prop transitions with update()

要测试组件在 不重新挂载 的情况下对 prop 变化的反应 —— 状态保持不变 —— 调用 component.update(newProps)。它会在现有的根上用新的 props 重新渲染同一个故事:

🌐 To test how a component reacts to a prop change without remounting — state preserved — call component.update(newProps). It re-renders the same story with new props on the existing root:

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');

多种状态和视觉对比​

🌐 Multiple states and visual comparison

每个 mount() 都是全新导航的,所以测试是完全隔离的,而且在一个测试中挂载多个故事是很便宜的:

🌐 Each mount() navigates fresh, so tests are fully isolated and mounting several stories in one test is cheap:

await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');

截屏返回的根定位器,而不是页面,以避免对你可能放在图库里的额外内容进行断言。

🌐 Screenshot the returned root locator, not the page, to avoid asserting on anything extra you might put in the gallery.

处理网络请求​

🌐 Handling network requests

像平常一样使用 page.route() —— 在 mount() 之前注册路由,因为挂载会执行导航:

🌐 Use page.route() as usual — register routes before mount(), since mounting navigates:

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');
});

配置中的 serviceWorkers: 'block' 选项可以防止应用自身的服务工作进程提供会覆盖路由的缓存响应。使用 MSW 处理库的团队可以在故事或装饰器中启动工作进程。

🌐 The serviceWorkers: 'block' option from the config keeps the app's own service worker from serving cached responses that would shadow the routes. Teams with MSW handler libraries can start the worker inside a story or decorator instead.

调试故事​

🌐 Debugging stories

在浏览器中打开图库 URL,然后在 DevTools 控制台调用 await window.mount({ story: 'components/Button/Primary' }) —— 这正是 mount 测试夹具所做的事情。一个未知的故事或渲染错误会导致 window.mount 被拒绝,这会表现为测试的 mount() 抛出实际的堆栈。要在不使用控制台的情况下浏览,可以给你的图库一个可选的索引页面,列出所有发现的故事。

🌐 Open the gallery URL in a browser and call await window.mount({ story: 'components/Button/Primary' }) from the DevTools console — that is exactly what the mount fixture does. An unknown story or a render error rejects window.mount, which surfaces as the test's mount() throwing with a real stack. To browse without the console, give your gallery an optional index page listing all discovered stories.

从实验性包迁移​

🌐 Migration from the experimental packages

实验性包在测试文件中编译了 JSX,并将其整理到浏览器中。图库模式将场景移到一个在浏览器中本地运行的故事导出中。概念映射如下:

🌐 The experimental packages compiled JSX in the test file and marshalled it into the browser. The gallery pattern moves the scenario into a story export that runs natively in the browser. Here is how the concepts map:

@playwright/experimental-ct-*故事图库
mount(<Button onClick={spy} />)有状态的故事:这个故事提供 onClick 并将效果记录到一个隐藏的输入中;测试使用 toHaveValue() 进行断言
测试中的普通数据属性精神上保持不变:mount(id, props)
测试中的 JSX 子组件 / 插槽每个组合导出一个故事(Vue:针对插槽密集的场景使用 .story.vue 文件)
component.update(<Button count={2} />)component.update({ count: 2 })
component.unmount()component.unmount()
beforeMount / afterMount 钩子图库的 window.mount(全局)主体,或故事装饰器(每个故事)
hooksConfig 每次测试的变化属性:mount('App/Routing', { route: '/dashboard' }),由故事解读
router 在 Node.js 中的夹具 / MSW 处理器测试中的 page.route(),或故事中的 MSW setupWorker
playwright/index.html(样式,主题)图库的 index.html 和入口模块导入
ctViteConfig,ctPort,ctTemplateDir不见了——图库通过你自己的开发服务器运行;端口在 webServer 和 baseURL
来自 ct 包的 defineConfig来自 @playwright/test 的普通 defineConfig

一个典型的规范迁移大概是这样的:

🌐 A typical spec migrates like this:

Before: button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import Button from '../src/components/Button';

test('counts clicks', async ({ mount }) => {
let clicks = 0;
const component = await mount(<Button title='提交' onClick={() => ++clicks} />);
await component.getByRole('button').click();
expect(clicks).toBe(1);
});
After: src/components/Button.story.tsx
import { useState } from 'react';
import { Button } from './Button';

export const CountsClicks = () => {
const [clicks, setClicks] = useState(0);
return <>
<Button title='提交' onClick={() => setClicks(count => count + 1)} />
<form hidden><input data-testid='click-count' readOnly value={String(clicks)} /></form>
</>;
};
After: tests/components/button.spec.ts
import { test, expect } from '@playwright/test';

test('counts clicks', async ({ mount }) => {
const component = await mount('components/Button/CountsClicks');
await component.getByRole('button').click();
await expect(component.getByTestId('click-count')).toHaveValue('1');
});

逐步迁移:在固定使用 Playwright 1.62 的同时,搭建图库和 components 项目,与旧的 CT 项目一起运行,一条条地迁移配置,然后去掉 @playwright/experimental-ct-* 依赖以及 playwright/index.html、playwright/index.ts 和 playwright/.cache 并进行升级。

🌐 Migrate incrementally: while pinned to Playwright 1.62, set up the gallery and the components project alongside the old CT project, port spec by spec, then drop the @playwright/experimental-ct-* dependency along with playwright/index.html, playwright/index.ts and playwright/.cache and upgrade.

注意事项:

🌐 Things to watch for:

  • 故事 ID 是字符串。 重命名或移动故事会在运行时破坏规范,而不是在编译时。至少使用 mount<typeof Story> 可以在编译时将属性绑定到故事上。
  • 每个测试的 JSX 不再存在。 每个测试构建不同 JSX 树的情况,现在变成了每个组合一个故事导出——这就是重点:每个值得测试的组合都值得命名和查看。

常见问题​

🌐 Frequently asked questions

如何访问组件的方法或其实例?​

🌐 How do I access the component's methods or its instance?

在测试代码中访问组件的内部方法或其实例既不推荐也不被支持。相反,更应该从用户的角度去观察和互动组件——点击它、查看页面,并通过故事把内部效果记录到 DOM 中。当测试避免依赖实现细节时,它们会变得不那么脆弱,也更有价值。如果一个测试在用户的视角下失败了,那很可能意味着自动化测试发现了一个真正的 bug。

🌐 Accessing a component's internal methods or its instance within test code is neither recommended nor supported. Instead, focus on observing and interacting with the component from a user's perspective — click it, look at the page, and record internal effects into the DOM through the story. Tests become less fragile and more valuable when they avoid implementation details. If a test fails when run from a user's perspective, it likely means the automated test has uncovered a genuine bug.

我还能继续使用我的打包器插件、别名和 CSS 设置吗?​

🌐 Can I keep using my bundler plugins, aliases and CSS setup?

是的——这就是设计的核心。图库由你自己的开发服务器提供支持,所以你的应用能渲染的内容,你的故事也能渲染。不需要额外的打包配置来保持同步。

🌐 Yes — that is the core of the design. The gallery is served by your own dev server, so whatever your app can render, your stories can render. There is no second bundler config to keep in sync.

除了 React 和 Vue,其他框架怎么样?​

🌐 What about frameworks other than React and Vue?

让你的编码代理为你的框架实现图库合同:将故事 ID 解析为组件,渲染到 #root 中,在调用之间重用根节点,这样 update() 就可以保留状态。mount 夹具并不知道也不关心另一边使用的是什么框架。

🌐 Ask your coding agent to implement the gallery contract for your framework: resolve a story id to a component, render it into #root, reuse the root across calls so update() preserves state. The mount fixture does not know or care which framework is on the other side.