Files
2026-07-13 21:36:47 +08:00

47 KiB
Raw Permalink Blame History

组件测试

何时使用:当你需要在隔离环境中测试 UI 组件时——验证渲染、交互和行为,而无需启动整个应用程序。适用于设计系统、共享组件库和复杂交互式小部件。 前置条件core/configuration.mdcore/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 }

相关文档