SuperAPI AI Directory

visual-test

Testing & quality

View repository

Updated 10-16 03:42

About this skill

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:

// 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} />

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

  1. Initial/basic state — component mounted with minimal props, no interaction. Always include this.
  2. Compact mode — remount the same component with data-ui5-compact-size on a wrapper <div>, then screenshot. Most components render differently in compact.
  3. 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:
    1. Basic — component mounted with the value state prop, no interaction (screenshot at rest).
    2. 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.
    3. 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:

// 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>

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]")   // correct
cy.get("ui5-multi-combobox")     // wrong

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():

// 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();

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} />  // Jan 15, 2024 in epoch seconds

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";
// ... all options used in the test

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