首页 / Playwright 入门教程 / 组件测试 Component Testing

Playwright 入门教程

组件测试 Component Testing

本教程共 59 篇 · 第 50 篇 · 更新于 2026-08-04 · 约 10 分钟阅读

Playwright组件测试ComponentTestingstoriesgalleriesmount1.62新模型

本节目标:学完你能用 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 导出 PrimaryDisabledWithLongTitle,读起来就像组件说明书。
  • 和组件做邻居Button.story.tsxButton.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)。
  • ctViteConfigctPortctTemplateDir → 全没了,端口写在 webServerbaseURL 里。

有个地方要留神:story id 是字符串。改名或挪文件,编译期不会报错,跑起来才崩。用 mount<typeof Story> 至少能把 props 绑死在 story 上。

Tip

迁移别一次性全换。先把画廊和 components project 搭好,旧 CT project 先留着,一个 spec 一个 spec 搬,搬完再删 @playwright/experimental-ct-* 依赖和 playwright/index.htmlplaywright/index.ts 这些旧文件。

什么时候用它

页面结构稳定、但又想单独验证某个组件的各种状态——组件测试正合适。它比整页端到端快、比单元测试更贴近真实渲染。

框架不是 React/Vue?让 agent 按契约给你的框架实现画廊页即可,mount 不在乎对面是哪个框架。

最后提醒一句:别在测试里去够组件实例和内部方法,官方明确不支持。要观察内部状态,就让 story 把它记进 DOM。

小结

组件测试用 mount 夹具把单个组件挂起来测,比整页端到端快、比单元测试贴近真实渲染。核心玩法是写 story 传 props,用 Web-First 断言自动重试。注意 story id 是字符串,改名编译期不报错,跑起来才崩。别去够组件实例内部方法,官方不支持,要让状态显式暴露到 DOM。