mcpField
V
visual-test

visual-test

github.com/ui5テスト・品質1.8k59 回インストール今日更新
リポジトリ

visual-test について

UI5 Web ComponentsのCypressビジュアルテストを作成するコントリビューター向けガイドです。コンポーネント単位の構成、有意義な画面状態の選び方、スクリーンショットを安定させる方法を説明します。

主な機能

  1. 01テストファイルごとに一つのコンポーネント
  2. 02必要な親コンポーネントの案内
  3. 03異なるビジュアル状態のカバー
  4. 04Shadow 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 の内部構造を検証するものではありません。

1 ファイルにつき 1 コンポーネント

各ファイルでテストするコンポーネントは必ず 1 つです。無関係なコンポーネントをインポートしないでください。 コンポーネントが子アイテムを使用する場合(例: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/ から 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 ブロックを分けてください。

すべてのコンポーネントで考慮する標準的なケース
  1. 初期/基本状態 — 最小限の props でコンポーネントをマウントし、操作しません。必ず含めてください。
  2. コンパクトモード — 同じコンポーネントを、ラッパーの <div> に data-ui5-compact-size を指定して再度マウントし、スクリーンショットを撮影します。ほとんどのコンポーネントは、コンパクトモードで表示が変わります。
  3. インタラクティブ/展開状態 — ドロップダウンが開いている、ポップオーバーが表示されている、フォーカスされているなど。
コンポーネントの機能に応じて追加するケース

コンポーネントで表示できる内容を確認し、それに応じたケースを追加します。

  • ドロップダウン内の項目にフォーカス — ピッカーを開き、ArrowDown キーを押して最初の項目にフォーカスし、スクリーンショットを撮影します。もう一度押して、グループヘッダーがあればそこにフォーカスします。
  • フィルタリング — 文字を入力して候補を絞り込み、絞り込まれたリストのスクリーンショットを撮影します。
  • 選択済み項目/トークン — 項目をあらかじめ選択し、トークン化された状態のスクリーンショットを撮影します。
  • 値の状態 — コンポーネントが valueState を受け取る場合、値の状態ごとに 3 つの it ブロックを記述します。
    1. 基本 — 値の状態 prop を指定してコンポーネントをマウントし、操作しません(静止状態のスクリーンショット)。
    2. ポップオーバー/ドロップダウンを開いた状態 — ドロップダウンまたはカレンダーのポップオーバーがあるコンポーネント(ComboBox、MultiComboBox、Select、DatePicker、DateRangePicker、DateTimePicker、MultiInput)では、開いてスクリーンショットを撮影します。このとき、値の状態メッセージストリップがポップオーバー内に表示されます。
    3. フォーカス状態でドロップダウンを閉じた状態 — 同じコンポーネントでは、開いてから 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

コメント