关于 visual-test
这份指南面向为 UI5 Web Components 编写 Cypress 视觉测试的贡献者,规定以单个组件为中心的测试结构,并说明如何选择有意义的界面状态和保持截图稳定。
主要功能
- 01每个测试文件专注一个组件
- 02说明必需的父组件
- 03覆盖不同的视觉状态
- 04提供 Shadow DOM 交互方式
- 05保持截图结果确定
适用场景
- 01为组件添加视觉测试
- 02捕获下拉框和焦点状态
- 03测试紧凑模式与值状态
- 04避免日期变化导致截图差异
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ ui5/ webcomponents --skill visual-test安装后在 Claude Code 中输入/visual-test确认已加载。
详细介绍
为 UI5 Web Components 编写视觉测试
视觉测试位于 packages/main/cypress/specs/visuals/,用于捕获组件在关键状态下的屏幕截图。这些测试刻意保持精简——它们的作用是记录组件的外观,而不是断言 DOM 内部结构。
每个文件测试一个组件
每个文件只测试一个组件。不要导入无关组件。如果组件使用子项(例如,MultiComboBox 中的 MultiComboBoxItem),导入这些子项没有问题——它们属于同一个组件系列。
错误: 在 MultiComboBox 测试中导入 RangeSlider,只是为了展示布局。
正确: 只导入 MultiComboBox 和 MultiComboBoxItem。
必需的父容器
某些组件没有特定父容器就无法正常渲染(或完全无法渲染)。始终使用所需的最小父容器包裹它们,并一并导入该父容器。父容器是组件契约的一部分,不是“无关组件”。
| 组件 | 必需的父容器 |
|---|---|
ListItemStandard、ListItemCustom、ListItemGroup | List |
TableRow、TableHeaderRow、TableGroupRow、TableCell、TableHeaderCell | Table |
Tab、TabSeparator | TabContainer |
TreeItem、TreeItemCustom | Tree |
MenuItem、MenuItemGroup、MenuSeparator | Menu |
Option | Select |
BreadcrumbsItem | Breadcrumbs |
Token | Tokenizer |
ToolbarButton、ToolbarSelect、ToolbarSeparator、ToolbarSpacer | Toolbar |
CarouselPage(或任何直接子项) | Carousel |
NotificationListItem、NotificationListGroupItem | NotificationList |
为子组件(例如 ListItemStandard)编写视觉测试时,文件应以子组件命名(ListItemStandard.cy.tsx),但要用最小父容器包裹它——只添加足以展示该组件的条目,不要添加其他内容。
文件位置和命名
packages/main/cypress/specs/visuals/ComponentName.cy.tsx # for @ui5/webcomponents
packages/fiori/cypress/specs/visuals/ComponentName.cy.tsx # for @ui5/webcomponents-fiori
使用与源组件文件相同的 PascalCase 名称。
导入路径
对于 main 包中的组件,从 visuals/ 向上三级导入:
import MultiComboBox from "../../../src/MultiComboBox.js";
import MultiComboBoxItem from "../../../src/MultiComboBoxItem.js";
对于 fiori 包中的组件,从 visuals/ 向上两级导入:
import IllustratedMessage from "../../src/IllustratedMessage.js";
import Timeline from "../../src/Timeline.js";
当 fiori 组件需要 main 包中的组件(例如插槽中的 Button)时,从 dist 包导入:
import Button from "@ui5/webcomponents/dist/Button.js";
对于 IllustratedMessage,没有单独的插图源文件——从 src 导入单个 bundle:
import "../../../src/illustrations/AllIllustrations.js";
当组件使用 icon 属性时,将每个图标作为具名导入(而不是副作用导入),并将导入的值传给该属性:
// WRONG — side-effect import, icon name passed as string
import "@ui5/webcomponents-icons/dist/favorite.js";
<ToggleButton icon="favorite" />
// CORRECT — named import, value passed to prop
import favorite from "@ui5/webcomponents-icons/dist/favorite.js";
<ToggleButton icon={favorite} />
只导入测试中实际使用的内容。
编写前:阅读现有的功能测试
为 ComponentName 编写视觉测试前,务必先阅读现有的功能测试文件:
packages/main/cypress/specs/ComponentName.cy.tsx # main package
packages/fiori/cypress/specs/ComponentName.cy.tsx # fiori package
快速浏览并了解:
- 测试了哪些属性组合(这些状态值得进行视觉捕获)。
- 触发了哪些交互——打开、聚焦、输入、导航——这些交互会产生不同的视觉状态。
- 使用了哪些自定义命令——这些命令已可在视觉测试中复用。
- 使用了哪些子组件和插槽,这有助于确定正确的父容器和插槽名称。
不必覆盖所有功能测试用例,只需覆盖那些会产生明显不同视觉外观的用例。但如果功能测试覆盖了你尚未考虑的内容,就值得进一步检查。
测试结构:每个不同的视觉状态对应一个用例
每个有意义的视觉状态编写一个 it。不要在单个测试中捆绑多个状态——每张屏幕截图都应只捕获一个状态。
如果要逐步执行一系列交互,并且每个中间步骤都值得记录(例如,通过方向键在列表中导航),单个 it 可以包含多个 cy.screenshot() 调用。但如果两个状态是通过完全独立的设置到达的,就使用两个单独的 it 代码块。
每个组件都需要考虑的标准用例
- 初始/基本状态 — 组件以最少的属性挂载,且没有交互。务必包含此用例。
- 紧凑模式 — 在包装
<div>上添加data-ui5-compact-size,重新挂载同一组件,然后截图。大多数组件在紧凑模式下的渲染效果有所不同。 - 交互/打开状态 — 下拉列表打开、弹出框可见、已聚焦等。
根据组件支持的功能添加用例
查看组件可以显示哪些内容,并相应添加用例:
- 下拉列表中的项目焦点 — 打开选择器,按 ArrowDown 将焦点移至第一项,然后截图。再次按下可将焦点移至分组标题(如果有)。
- 筛选 — 输入一个字符筛选建议项,并截图显示筛选后的列表。
- 已选项目/令牌 — 预选项目,并截图显示令牌化状态。
- 值状态 — 当组件接受
valueState时,每个值状态值都要编写 三个it块:- 基本状态 — 组件设置值状态属性后挂载,不进行交互(截取静止状态)。
- 弹出框/下拉列表打开 — 对于带下拉列表或日历弹出框的组件(ComboBox、MultiComboBox、Select、DatePicker、DateRangePicker、DateTimePicker、MultiInput),打开后截图。此时,值状态消息条会显示在弹出框内。
- 已聚焦,下拉列表关闭 — 对于同一组组件,打开后按 Escape 关闭(或直接点击输入区域),使组件保持聚焦且下拉列表关闭。此状态会显示独立的值状态消息弹出框,其渲染效果与下拉列表内的版本不同。
对于没有下拉列表的 Input 和 MultiInput,只需点击输入框使其聚焦。
值状态值全集为
Negative、Critical、Positive、Information。None是默认值,已由基本状态测试覆盖;不要为其另行添加用例。务必包含valueStateMessage插槽,以便渲染消息条。适用于 Input、MultiComboBox、Select、DatePicker、DateTimePicker 等。
- 无项目 — 空列表状态(例如,没有子元素且下拉列表已打开的
<MultiComboBox>)。 - 换行/长内容 — 传入很长的文本或多个令牌,以捕获溢出/换行行为。
无需单独测试的情况
- 不会产生可见差异的简单属性更改。
- 已由其他
it覆盖的状态。
没有固定的数量上限——覆盖组件实际显示的内容即可。
插槽分配
始终将插槽子元素指定为带有 slot 属性的子元素,切勿将其作为父组件的 JSX 属性:
// WRONG — passing slot content as a JSX prop
<Page header={<Bar>...</Bar>} footer={<Bar>...</Bar>} />
// CORRECT — slot attribute on the child element
<Page>
<Bar slot="header">...</Bar>
<div>Main content</div>
<Bar slot="footer">...</Bar>
</Page>
这与 Web 组件在 DOM 中使用插槽的方式一致。将插槽内容作为属性传入会绕过插槽机制,导致无法正确渲染。
使用属性选择器,而非标签名:
cy.get("[ui5-multi-combobox]") // correct
cy.get("ui5-multi-combobox") // wrong
属性选择器会匹配 Web 组件添加到自身的 [ui5-*] 标记,因此比标签名更能适应更改。
触发交互
始终优先使用现有的自定义命令,而非直接查询 DOM——这些命令已能正确处理等待和 Shadow DOM 遍历。
嵌套 Shadow DOM — 日期/时间选择器
DatePicker、DateRangePicker 和 DateTimePicker 会将文本字段渲染为内部的 ui5-datetime-input 组件,该组件有自己的 Shadow DOM。因此,原生 <input> 嵌套在两层 Shadow DOM 中。请使用:
cy.get("[ui5-date-picker]")
.shadow().find("ui5-datetime-input")
.shadow().find("input")
.realClick();
在外层选择器元素上调用 shadow().find("input") 会失败,并显示“Expected to find element: input, but never found it.”
可用的自定义命令(用于交互)
| 组件 | 命令 | 作用 |
|---|---|---|
DatePicker | .ui5DatePickerValueHelpIconPress() | 打开日历弹出框 |
TimePicker | .ui5TimePickerValueHelpIconPress() | 打开时间选择器弹出框 |
Menu | .ui5MenuOpen() | 打开菜单弹出框 |
Menu | .ui5MenuOpened() | 断言菜单已打开(打开后使用) |
ColorPalettePopover | .ui5ColorPalettePopoverOpen() | 打开调色板弹出框 |
DynamicDateRange | .ui5DynamicDateRangeOpen() | 打开 DDR 弹出框 |
TabContainer | .ui5TabContainerOpenEndOverflow() | 打开溢出标签页列表 |
Dialog | .ui5DialogOpened() | 断言对话框已打开 |
Popover | .ui5PopoverOpened() | 断言弹出框已打开 |
ResponsivePopover | .ui5ResponsivePopoverOpened() | 断言响应式弹出框已打开 |
对于没有自定义命令的组件,请使用 realClick():
// Open a select dropdown
cy.get("[ui5-select]").realClick();
// Open a multicombobox dropdown via its icon
cy.get("[ui5-multi-combobox]").shadow().find("[icon]").realClick();
触发交互后,立即调用 cy.screenshot() — 无需断言。
完整示例
下面的示例涵盖了 MultiComboBox 的所有重要视觉状态 — 与 openui5 视觉回归套件测试的状态相同:基础、打开、筛选、项目聚焦、令牌、值状态、无项目。
import MultiComboBox from "../../../src/MultiComboBox.js";
import MultiComboBoxItem from "../../../src/MultiComboBoxItem.js";
describe("MultiComboBox visual", () => {
it("basic state", () => {
cy.mount(
<MultiComboBox>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
<MultiComboBoxItem text="Canada" />
</MultiComboBox>
);
cy.screenshot();
});
it("dropdown open", () => {
cy.mount(
<MultiComboBox>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
<MultiComboBoxItem text="Canada" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").shadow().find("[ui5-icon]").realClick();
cy.get("[ui5-multi-combobox]").shadow().find("ui5-responsive-popover").ui5ResponsivePopoverOpened();
cy.screenshot();
});
it("filtering — type to narrow list", () => {
cy.mount(
<MultiComboBox>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
<MultiComboBoxItem text="Canada" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").realClick();
cy.realType("B");
cy.screenshot();
});
it("dropdown open — first item focused via arrow key", () => {
cy.mount(
<MultiComboBox>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
<MultiComboBoxItem text="Canada" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").shadow().find("[ui5-icon]").realClick();
cy.get("[ui5-multi-combobox]").shadow().find("ui5-responsive-popover").ui5ResponsivePopoverOpened();
cy.realPress("ArrowDown");
cy.screenshot();
});
it("with selected tokens", () => {
cy.mount(
<MultiComboBox>
<MultiComboBoxItem selected text="Algeria" />
<MultiComboBoxItem selected text="Bulgaria" />
<MultiComboBoxItem text="Canada" />
</MultiComboBox>
);
cy.screenshot();
});
it("value state — Negative", () => {
cy.mount(
<MultiComboBox valueState="Negative">
<span slot="valueStateMessage">Error message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.screenshot();
});
it("value state — Negative — dropdown open", () => {
cy.mount(
<MultiComboBox valueState="Negative">
<span slot="valueStateMessage">Error message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").shadow().find("[ui5-icon]").realClick();
cy.get("[ui5-multi-combobox]").shadow().find("ui5-responsive-popover").ui5ResponsivePopoverOpened();
cy.screenshot();
});
it("value state — Negative — focused", () => {
cy.mount(
<MultiComboBox valueState="Negative">
<span slot="valueStateMessage">Error message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").shadow().find("input").realClick();
cy.screenshot();
});
it("value state — Critical", () => {
cy.mount(
<MultiComboBox valueState="Critical">
<span slot="valueStateMessage">Warning message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.screenshot();
});
it("value state — Critical — dropdown open", () => {
cy.mount(
<MultiComboBox valueState="Critical">
<span slot="valueStateMessage">Warning message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").shadow().find("[ui5-icon]").realClick();
cy.get("[ui5-multi-combobox]").shadow().find("ui5-responsive-popover").ui5ResponsivePopoverOpened();
cy.screenshot();
});
it("value state — Critical — focused", () => {
cy.mount(
<MultiComboBox valueState="Critical">
<span slot="valueStateMessage">Warning message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").shadow().find("input").realClick();
cy.screenshot();
});
it("value state — Positive", () => {
cy.mount(
<MultiComboBox valueState="Positive">
<span slot="valueStateMessage">Success message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.screenshot();
});
it("value state — Positive — dropdown open", () => {
cy.mount(
<MultiComboBox valueState="Positive">
<span slot="valueStateMessage">Success message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").shadow().find("[ui5-icon]").realClick();
cy.get("[ui5-multi-combobox]").shadow().find("ui5-responsive-popover").ui5ResponsivePopoverOpened();
cy.screenshot();
});
it("value state — Positive — focused", () => {
cy.mount(
<MultiComboBox valueState="Positive">
<span slot="valueStateMessage">Success message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").shadow().find("input").realClick();
cy.screenshot();
});
it("value state — Information", () => {
cy.mount(
<MultiComboBox valueState="Information">
<span slot="valueStateMessage">Info message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.screenshot();
});
it("value state — Information — dropdown open", () => {
cy.mount(
<MultiComboBox valueState="Information">
<span slot="valueStateMessage">Info message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").shadow().find("[ui5-icon]").realClick();
cy.get("[ui5-multi-combobox]").shadow().find("ui5-responsive-popover").ui5ResponsivePopoverOpened();
cy.screenshot();
});
it("value state — Information — focused", () => {
cy.mount(
<MultiComboBox valueState="Information">
<span slot="valueStateMessage">Info message</span>
<MultiComboBoxItem text="Algeria" />
<MultiComboBoxItem text="Bulgaria" />
</MultiComboBox>
);
cy.get("[ui5-multi-combobox]").shadow().find("input").realClick();
cy.screenshot();
});
it("no items — empty dropdown open", () => {
cy.mount(<MultiComboBox />);
cy.get("[ui5-multi-combobox]").shadow().find("[ui5-icon]").realClick();
cy.screenshot();
});
});
不要这样做
- 不要使用
.should()断言 — 这是视觉测试,不是功能测试。 - 不要进行与组件自身 UI 无关的交互(不要仅仅为了展示组件而打开 Dialog)。
- 不要添加额外的包装组件来“设置”测试 — 如果组件需要值,请直接将其作为 prop 传入。
- 不要使用
cy.wait()或任意超时 — 如果需要等待打开动画,请使用.ui5ResponsivePopoverOpened()(链接到在 shadow DOM 中找到的弹出框元素),在截图前确认弹出框已打开。
避免动态/非确定性内容
每次运行的截图都必须完全一致。任何在运行之间发生变化的内容都会导致误报差异,使测试无法作为视觉基线使用。
避免:
BusyIndicator— 加载动画帧每次运行都会变化。- 任何在运行时获取或生成内容的组件(时钟、相对时间戳、UI 中显示的随机 ID)。
为日期/时间组件冻结时间
任何内部调用 new Date() 的组件 — 无论是为了格式化值、在日历中突出显示今天,还是生成相对标签 — 都会在不同日期生成不同的截图。这包括:
Calendar、SpecialCalendarDate— 今天对应的单元格会突出显示DatePicker、DateRangePicker、DateTimePicker— 打开的日历中会突出显示今天DynamicDateRange—TODAY、LASTDAYS、NEXTWEEKS等运算符会根据当前日期进行格式化
始终添加一个 beforeEach,使用 cy.clock 冻结 Date:
beforeEach(() => {
cy.clock(new Date("Jan 15, 2024").getTime(), ["Date"]);
});
第二个参数 ["Date"] 至关重要 — 它只替换 Date 构造函数,不会影响 setTimeout/setInterval。如果不指定该参数,cy.clock() 也会冻结计时器,从而破坏 UI5 的异步渲染,导致所有测试超时。
为保持整个仓库的一致性,请在所有测试中使用 Jan 15, 2024 作为标准固定日期。
对于 Calendar 和 SpecialCalendarDate,请传入固定的 timestamp prop 来替代 cy.clock,或与其配合使用 — timestamp prop 会固定显示的月份:
<Calendar timestamp={1705276800} /> // Jan 15, 2024 in epoch seconds
对于 DynamicDateRange,还要导入所有选项类,以便它们在组件渲染前完成注册:
import "../../../src/dynamic-date-range-options/Today.js";
import "../../../src/dynamic-date-range-options/SingleDate.js";
// ... all options used in the test
运行测试
cd packages/main
npx cypress run --component --browser chrome --spec cypress/specs/visuals/ComponentName.cy.tsx
或打开交互模式:
cd packages/main
yarn test:cypress:open