mcpField
V
visual-test

visual-test

github.com/ui5测试与质量1.8k59 次安装今天更新
代码仓库

关于 visual-test

这份指南面向为 UI5 Web Components 编写 Cypress 视觉测试的贡献者,规定以单个组件为中心的测试结构,并说明如何选择有意义的界面状态和保持截图稳定。

主要功能

  1. 01每个测试文件专注一个组件
  2. 02说明必需的父组件
  3. 03覆盖不同的视觉状态
  4. 04提供 Shadow DOM 交互方式
  5. 05保持截图结果确定

适用场景

  1. 01为组件添加视觉测试
  2. 02捕获下拉框和焦点状态
  3. 03测试紧凑模式与值状态
  4. 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、ListItemGroupList
TableRow、TableHeaderRow、TableGroupRow、TableCell、TableHeaderCellTable
Tab、TabSeparatorTabContainer
TreeItem、TreeItemCustomTree
MenuItem、MenuItemGroup、MenuSeparatorMenu
OptionSelect
BreadcrumbsItemBreadcrumbs
TokenTokenizer
ToolbarButton、ToolbarSelect、ToolbarSeparator、ToolbarSpacerToolbar
CarouselPage(或任何直接子项)Carousel
NotificationListItem、NotificationListGroupItemNotificationList

为子组件(例如 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 代码块。

每个组件都需要考虑的标准用例
  1. 初始/基本状态 — 组件以最少的属性挂载,且没有交互。务必包含此用例。
  2. 紧凑模式 — 在包装 <div> 上添加 data-ui5-compact-size,重新挂载同一组件,然后截图。大多数组件在紧凑模式下的渲染效果有所不同。
  3. 交互/打开状态 — 下拉列表打开、弹出框可见、已聚焦等。
根据组件支持的功能添加用例

查看组件可以显示哪些内容,并相应添加用例:

  • 下拉列表中的项目焦点 — 打开选择器,按 ArrowDown 将焦点移至第一项,然后截图。再次按下可将焦点移至分组标题(如果有)。
  • 筛选 — 输入一个字符筛选建议项,并截图显示筛选后的列表。
  • 已选项目/令牌 — 预选项目,并截图显示令牌化状态。
  • 值状态 — 当组件接受 valueState 时,每个值状态值都要编写 三个 it 块:
    1. 基本状态 — 组件设置值状态属性后挂载,不进行交互(截取静止状态)。
    2. 弹出框/下拉列表打开 — 对于带下拉列表或日历弹出框的组件(ComboBox、MultiComboBox、Select、DatePicker、DateRangePicker、DateTimePicker、MultiInput),打开后截图。此时,值状态消息条会显示在弹出框内。
    3. 已聚焦,下拉列表关闭 — 对于同一组组件,打开后按 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

评论

支持 Markdown