| name | visual-test |
| description | Write basic Cypress visual tests for UI5 web components, placing them in cypress/specs/visuals/. Use this skill whenever the user asks to create, add, or write a visual test (or screenshot test) for any UI5 web component. Also trigger when the user says things like "add a visual spec for X", "create a visual test for X", or "write a cypress visual for X".
|
Writing Visual Tests for UI5 Web Components
Visual tests live in packages/main/cypress/specs/visuals/ and capture
screenshots of a component at key states. They are intentionally minimal —
their job is to record what the component looks like, not to assert on DOM
internals.
One component per file
Each file tests exactly ONE component. Do not import unrelated components.
If the component uses child items (e.g. MultiComboBoxItem inside
MultiComboBox), those child imports are fine — they are part of the same
component family.
Wrong: importing RangeSlider in a MultiComboBox test just to
demonstrate a layout.
Right: only MultiComboBox + MultiComboBoxItem.
Required parent containers
Some components cannot render meaningfully (or at all) without a specific
parent. Always wrap them in the minimal required parent — and import that
parent too. The parent is part of the component's contract, not an
"unrelated component".
| Component(s) | Required parent |
|---|
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 (or any direct child) | Carousel |
NotificationListItem, NotificationListGroupItem | NotificationList |
When writing a visual test for a child component (e.g. ListItemStandard),
name the file after the child (ListItemStandard.cy.tsx), but wrap it
in the minimal parent — just enough items to show the component, nothing
more.
File location and naming
packages/main/cypress/specs/visuals/ComponentName.cy.tsx # for @ui5/webcomponents
packages/fiori/cypress/specs/visuals/ComponentName.cy.tsx # for @ui5/webcomponents-fiori
Use the same PascalCase name as the source component file.
Import paths
For main package components, import three levels up from visuals/:
import MultiComboBox from "../../../src/MultiComboBox.js";
import MultiComboBoxItem from "../../../src/MultiComboBoxItem.js";
For fiori package components, import two levels up from visuals/:
import IllustratedMessage from "../../src/IllustratedMessage.js";
import Timeline from "../../src/Timeline.js";
When a fiori component needs a main-package component (e.g. a Button in a slot), import from the dist package:
import Button from "@ui5/webcomponents/dist/Button.js";
For IllustratedMessage, there are no individual illustration source files — import the single bundle from src:
import "../../../src/illustrations/AllIllustrations.js";
When a component uses an icon prop, import each icon as a named import (not a side-effect import) and pass the imported value to the prop:
import "@ui5/webcomponents-icons/dist/favorite.js";
<ToggleButton icon="favorite" />
import favorite from "@ui5/webcomponents-icons/dist/favorite.js";
<ToggleButton icon={favorite} />
Import only what you actually use in the test.
Before writing: read the existing functional test
Before writing a visual test for ComponentName, always read the existing
functional test file at:
packages/main/cypress/specs/ComponentName.cy.tsx # main package
packages/fiori/cypress/specs/ComponentName.cy.tsx # fiori package
Skim it for:
- What prop combinations it exercises (these are the states worth
capturing visually).
- What interactions it triggers — open, focus, type, navigate — that
produce a distinct visual state.
- What custom commands it uses — these are already available for reuse
in the visual test.
- What child components and slots it uses, which tells you the correct
parent container and slot names.
You do not need to cover every functional test case — only the ones that
produce a meaningfully different visual appearance. But anything the
functional test exercises that you had not considered is a signal worth
checking.
Test structure: one case per distinct visual state
Write one it per meaningful visual state. Do not bundle multiple states
into a single test — each screenshot should capture exactly one thing.
A single it can contain multiple cy.screenshot() calls if you are
stepping through a sequential interaction and each intermediate step is
worth recording (e.g. arrow-key navigation through a list). But if two
states are reached by completely independent setups, use two separate it
blocks.
Standard cases to consider for every component
- Initial/basic state — component mounted with minimal props, no
interaction. Always include this.
- Compact mode — remount the same component with
data-ui5-compact-size
on a wrapper <div>, then screenshot. Most components render
differently in compact.
- Interactive/open state — dropdown open, popover visible, focused,
etc.
Additional cases based on what the component supports
Look at what the component can show and add cases accordingly:
- Item focus in dropdown — open the picker, press ArrowDown to focus
the first item, screenshot. Press again to focus a group header if present.
- Filtering — type a character to filter suggestions, screenshot the
narrowed list.
- Selected items / tokens — pre-select items, screenshot the tokenized
state.
- Value state — when a component accepts
valueState, write three it
blocks per value state value:
- Basic — component mounted with the value state prop, no interaction (screenshot at rest).
- Popover/dropdown open — for components that have a dropdown or calendar
popover (ComboBox, MultiComboBox, Select, DatePicker, DateRangePicker,
DateTimePicker, MultiInput), open it and screenshot. The value state message
strip appears inside the popover here.
- Focused, dropdown closed — for the same components, open then close with
Escape (or simply click the input area) so the component is focused but the
dropdown is closed. A standalone value state message popover appears in this
state, which has different rendering to the in-dropdown version.
For Input and MultiInput (no dropdown), just click the input to focus.
The full set of value state values is
Negative, Critical, Positive,
Information. None is the default and is already covered by the basic
state test — do not add a separate case for it. Always include a
valueStateMessage slot so the message strip renders. Applies to Input,
MultiComboBox, Select, DatePicker, DateTimePicker, etc.
- No items — empty list state (e.g.
<MultiComboBox> with no children,
dropdown open).
- Wrapping / long content — pass a very long text or many tokens to
capture overflow/wrapping behaviour.
What does NOT need its own test
- Trivial prop changes that produce no visible difference.
- States already covered by another
it.
There is no fixed maximum — cover what the component genuinely shows.
Slot assignment
Always assign slotted children as child elements with a slot attribute,
never as JSX props on the parent component:
<Page header={<Bar>...</Bar>} footer={<Bar>...</Bar>} />
<Page>
<Bar slot="header">...</Bar>
<div>Main content</div>
<Bar slot="footer">...</Bar>
</Page>
This matches how web component slots work in the DOM. Passing slot content
as a prop bypasses the slot mechanism and will not render correctly.
Use attribute selectors, not tag names:
cy.get("[ui5-multi-combobox]")
cy.get("ui5-multi-combobox")
The attribute selector matches the [ui5-*] marker that web components add
to themselves and is more resilient to tag name changes.
Triggering interactions
Always prefer the existing custom commands over raw DOM queries — they
already handle waiting and shadow DOM traversal correctly.
Nested shadow DOM — date/time pickers
DatePicker, DateRangePicker, and DateTimePicker render their text field
as an internal ui5-datetime-input component, which has its own shadow
DOM. The native <input> is therefore two shadow levels deep. Use:
cy.get("[ui5-date-picker]")
.shadow().find("ui5-datetime-input")
.shadow().find("input")
.realClick();
shadow().find("input") on the outer picker element will fail with
"Expected to find element: input, but never found it."
Available custom commands (use these for interactions)
| Component | Command | What it does |
|---|
DatePicker | .ui5DatePickerValueHelpIconPress() | Opens the calendar popover |
TimePicker | .ui5TimePickerValueHelpIconPress() | Opens the time picker popover |
Menu | .ui5MenuOpen() | Opens the menu popover |
Menu | .ui5MenuOpened() | Asserts menu is open (use after open) |
ColorPalettePopover | .ui5ColorPalettePopoverOpen() | Opens the color palette popover |
DynamicDateRange | .ui5DynamicDateRangeOpen() | Opens the DDR popover |
TabContainer | .ui5TabContainerOpenEndOverflow() | Opens the overflow tab list |
Dialog | .ui5DialogOpened() | Asserts dialog is open |
Popover | .ui5PopoverOpened() | Asserts popover is open |
ResponsivePopover | .ui5ResponsivePopoverOpened() | Asserts responsive popover is open |
For components without a custom command, use realClick():
cy.get("[ui5-select]").realClick();
cy.get("[ui5-multi-combobox]").shadow().find("[icon]").realClick();
After triggering the interaction, call cy.screenshot() immediately — no
assertions needed.
Complete example
The example below covers the full range of meaningful visual states for
MultiComboBox — the same states tested in the openui5 visual regression
suite: basic, open, filtering, item focus, tokens, value state, no items.
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();
});
});
What NOT to do
- No
.should() assertions — this is a visual test, not a functional one.
- No interactions unrelated to the component's own UI (don't open a Dialog
just to show the component inside it).
- No extra wrapper components to "set up" the test — if the component needs
a value, pass it as a prop directly.
- No
cy.wait() or arbitrary timeouts — if you need to wait for an open
animation, use .ui5ResponsivePopoverOpened() (chained off the popover
element found in shadow DOM) to verify the popup is open before screenshotting.
Avoid dynamic / non-deterministic content
Screenshots must be identical on every run. Anything that changes between
runs will cause false diffs and make the test useless as a visual baseline.
Avoid:
BusyIndicator — the spinner animation frame varies per run.
- Any component that fetches or derives content at runtime (clocks,
relative timestamps, random IDs shown in the UI).
Freezing time for date/time components
Any component that calls new Date() internally — to format a value,
highlight today in a calendar, or derive a relative label — will produce
different screenshots on different days. This includes:
Calendar, SpecialCalendarDate — today's cell is highlighted
DatePicker, DateRangePicker, DateTimePicker — today is highlighted in the open calendar
DynamicDateRange — operators like TODAY, LASTDAYS, NEXTWEEKS format to the current date
Always add a beforeEach that freezes Date using cy.clock:
beforeEach(() => {
cy.clock(new Date("Jan 15, 2024").getTime(), ["Date"]);
});
The second argument ["Date"] is critical — it stubs only the Date
constructor and leaves setTimeout/setInterval untouched. Without it,
cy.clock() also freezes timers, which breaks UI5's async rendering and
causes all tests to time out.
Use Jan 15, 2024 as the standard fixed date across all tests in this
repo for consistency.
For Calendar and SpecialCalendarDate, pass a fixed timestamp prop
instead of (or in addition to) cy.clock — the timestamp prop pins
which month is displayed:
<Calendar timestamp={1705276800} />
For DynamicDateRange, also import all option classes so they register
themselves before the component renders:
import "../../../src/dynamic-date-range-options/Today.js";
import "../../../src/dynamic-date-range-options/SingleDate.js";
Running the test
cd packages/main
npx cypress run --component --browser chrome --spec cypress/specs/visuals/ComponentName.cy.tsx
Or open interactive mode:
cd packages/main
yarn test:cypress:open