visual-test について
UI5 Web ComponentsのCypressビジュアルテストを作成するコントリビューター向けガイドです。コンポーネント単位の構成、有意義な画面状態の選び方、スクリーンショットを安定させる方法を説明します。
主な機能
- 01テストファイルごとに一つのコンポーネント
- 02必要な親コンポーネントの案内
- 03異なるビジュアル状態のカバー
- 04Shadow 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 の内部構造を検証するものではありません。
1 ファイルにつき 1 コンポーネント
各ファイルでテストするコンポーネントは必ず 1 つです。無関係なコンポーネントをインポートしないでください。
コンポーネントが子アイテムを使用する場合(例: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/ から 3 階層上のパスでインポートします。
import MultiComboBox from "../../../src/MultiComboBox.js";
import MultiComboBoxItem from "../../../src/MultiComboBoxItem.js";
fiori パッケージのコンポーネントは、visuals/ から 2 階層上のパスでインポートします。
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 から単一のバンドルをインポートしてください。
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
次の点を確認してください。
- どのプロパティの組み合わせをテストしているか(ビジュアルで記録する価値のある状態です)。
- どのような操作を行っているか(開く、フォーカスする、入力する、移動するなど)。操作によって、異なるビジュアル状態が生まれます。
- どのカスタムコマンドを使っているか(ビジュアルテストでも再利用できます)。
- どの子コンポーネントやスロットを使っているか。それにより、適切な親コンテナーとスロット名が分かります。
すべての機能テストケースを網羅する必要はありません。見た目に意味のある違いが生じるケースのみを対象にします。ただし、検討していなかった内容が機能テストでテストされていた場合は、確認する価値があるサインです。
テストの構成:異なるビジュアル状態ごとに 1 ケース
意味のあるビジュアル状態ごとに it を 1 つ作成してください。複数の状態を 1 つのテストにまとめないでください。各スクリーンショットでは、必ず 1 つの状態だけを撮影します。
順を追った操作を行い、途中の各ステップを記録する価値がある場合(例:リスト内を矢印キーで移動する場合)は、1 つの it 内で複数回 cy.screenshot() を呼び出せます。ただし、2 つの状態にそれぞれ独立したセットアップで到達する場合は、it ブロックを分けてください。
すべてのコンポーネントで考慮する標準的なケース
- 初期/基本状態 — 最小限の props でコンポーネントをマウントし、操作しません。必ず含めてください。
- コンパクトモード — 同じコンポーネントを、ラッパーの
<div>にdata-ui5-compact-sizeを指定して再度マウントし、スクリーンショットを撮影します。ほとんどのコンポーネントは、コンパクトモードで表示が変わります。 - インタラクティブ/展開状態 — ドロップダウンが開いている、ポップオーバーが表示されている、フォーカスされているなど。
コンポーネントの機能に応じて追加するケース
コンポーネントで表示できる内容を確認し、それに応じたケースを追加します。
- ドロップダウン内の項目にフォーカス — ピッカーを開き、ArrowDown キーを押して最初の項目にフォーカスし、スクリーンショットを撮影します。もう一度押して、グループヘッダーがあればそこにフォーカスします。
- フィルタリング — 文字を入力して候補を絞り込み、絞り込まれたリストのスクリーンショットを撮影します。
- 選択済み項目/トークン — 項目をあらかじめ選択し、トークン化された状態のスクリーンショットを撮影します。
- 値の状態 — コンポーネントが
valueStateを受け取る場合、値の状態ごとに 3 つのitブロックを記述します。- 基本 — 値の状態 prop を指定してコンポーネントをマウントし、操作しません(静止状態のスクリーンショット)。
- ポップオーバー/ドロップダウンを開いた状態 — ドロップダウンまたはカレンダーのポップオーバーがあるコンポーネント(ComboBox、MultiComboBox、Select、DatePicker、DateRangePicker、DateTimePicker、MultiInput)では、開いてスクリーンショットを撮影します。このとき、値の状態メッセージストリップがポップオーバー内に表示されます。
- フォーカス状態でドロップダウンを閉じた状態 — 同じコンポーネントでは、開いてから Escape で閉じる(または入力領域をクリックするだけでも可)ことで、フォーカスされていてドロップダウンが閉じた状態にします。この状態では、ドロップダウン内に表示される場合とは描画が異なる、単独の値の状態メッセージポップオーバーが表示されます。Input と MultiInput(ドロップダウンなし)では、入力をクリックしてフォーカスします。
値の状態は
Negative、Critical、Positive、Informationのすべてを含めます。Noneはデフォルトで、基本状態のテストですでにカバーされているため、別のケースを追加する必要はありません。メッセージストリップが描画されるよう、必ずvalueStateMessageスロットを含めます。Input、MultiComboBox、Select、DatePicker、DateTimePicker などに適用されます。
- 項目なし — リストが空の状態(例:子要素がない状態で
<MultiComboBox>を開いたとき)。 - 折り返し/長いコンテンツ — 非常に長いテキストや多数のトークンを渡し、オーバーフローや折り返しの動作をキャプチャします。
独立したテストが不要なもの
- 表示に変化がない、些細な prop の変更。
- ほかの
itですでにカバーされている状態。
固定の上限はありません。コンポーネントで実際に表示される内容をカバーしてください。
スロットの割り当て
スロット付きの子要素は、親コンポーネントの JSX props ではなく、必ず slot 属性を持つ子要素として割り当ててください。
// 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 で機能する仕組みに合致します。スロットのコンテンツを prop として渡すと、スロットの仕組みを迂回するため、正しく描画されません。
タグ名ではなく、属性セレクターを使用してください。
cy.get("[ui5-multi-combobox]") // correct
cy.get("ui5-multi-combobox") // wrong
属性セレクターは、Web コンポーネント自身に付与される [ui5-*] マーカーに一致するため、タグ名の変更にもより強く対応できます。
インタラクションの実行
生の DOM クエリよりも、既存のカスタムコマンドを常に優先してください。カスタムコマンドは待機とシャドウ DOM のトラバーサルを適切に処理します。
ネストされたシャドウ DOM — 日時ピッカー
DatePicker、DateRangePicker、DateTimePicker は、テキストフィールドを内部の ui5-datetime-input コンポーネントとして描画し、このコンポーネントは 独自のシャドウ DOM を持ちます。そのため、ネイティブの <input> はシャドウ DOM の 2 階層下にあります。次のように使用してください。
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 を開かないでください)。
- テストを「セットアップ」するための追加のラッパーコンポーネントは使用しないでください。コンポーネントに値が必要な場合は、プロパティとして直接渡してください。
cy.wait()や任意のタイムアウトは使用しないでください。開くアニメーションを待つ必要がある場合は、スクリーンショットを撮る前に、シャドウ DOM 内で見つけたポップアップ要素に連結して.ui5ResponsivePopoverOpened()を使用し、ポップアップが開いていることを確認してください。
動的/非決定的なコンテンツを避ける
スクリーンショットは毎回同一でなければなりません。実行ごとに変化するものがあると、誤った差分が生じ、ビジュアルベースラインとして役に立たなくなります。
避けるもの:
BusyIndicator— スピナーのアニメーションフレームは実行ごとに異なります。- 実行時にコンテンツを取得または生成するコンポーネント(時計、相対タイムスタンプ、UI に表示されるランダム ID など)。
日付/時刻コンポーネントの時間を固定する
内部で new Date() を呼び出すコンポーネントは、値の書式設定、カレンダーで今日を強調表示する処理、相対ラベルの生成などにより、日によって異なるスクリーンショットを生成します。対象には次が含まれます。
Calendar、SpecialCalendarDate— 今日の日付のセルが強調表示されますDatePicker、DateRangePicker、DateTimePicker— 開いたカレンダーで今日の日付が強調表示されますDynamicDateRange—TODAY、LASTDAYS、NEXTWEEKSなどの演算子は現在の日付に基づいて書式設定されます
必ず cy.clock を使用して Date を固定する beforeEach を追加してください。
beforeEach(() => {
cy.clock(new Date("Jan 15, 2024").getTime(), ["Date"]);
});
2 番目の引数 ["Date"] は重要です。Date コンストラクターのみをスタブ化し、setTimeout/setInterval はそのままにします。この引数がないと、cy.clock() はタイマーも停止させるため、UI5 の非同期レンダリングが壊れ、すべてのテストがタイムアウトします。
このリポジトリ全体で一貫性を保つため、標準の固定日付として Jan 15, 2024 を使用してください。
Calendar と SpecialCalendarDate では、cy.clock の代わりに、または追加で、固定の timestamp プロパティを渡してください。timestamp プロパティによって表示する月を固定できます。
<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