PlaywrightのE2Eで、プルダウン選択だけが時々タイムアウトしました。
再実行では通ります。当初は描画待ちを疑いました。しかし、原因は待機時間以外にありました。テスト側は、画面から操作不能な候補を選択していました。
一覧に存在しても、UIから選べるとは限らない
テストは、条件なしの一覧APIから候補を1件取得していました。
一方、画面側はキーワード検索後の結果をプルダウンへ表示します。
以下は説明用に置き換えた架空APIです。名称、パス、パラメータ、データ構造は実在するプロジェクトと無関係です。
GET /example-api/items/search
?keyword=...
&active_only=true
&leaf_only=true
&match_aliases=true
一覧APIと検索APIでは条件が異なります。そのため、一覧へ現れても検索結果から外れる候補が混ざります。例として、無効状態、階層条件、別名検索の差があります。
該当候補をテストが引くと、クリック対象は最後まで現れません。
UIと同じ条件で候補を絞る
ブラウザ操作の前に、画面側と同じ条件で候補を再照会します。
type ItemCandidate = {
id: number;
label: string;
};
type ItemSearchResponse = {
data?: Array<{ id: number }>;
};
private readonly isIncludedInDropdownResults = async (
item: ItemCandidate,
): Promise<boolean> => {
const searchParams = new URLSearchParams({
keyword: item.label,
active_only: 'true',
leaf_only: 'true',
match_aliases: 'true',
});
const response = await this.json<ItemSearchResponse>(
`/example-api/items/search?${searchParams.toString()}`,
);
return (response.data ?? []).some((candidate) => candidate.id === item.id);
};
重要なのは、画面側が実際に送る条件を再現する点です。
ただし、この確認だけでは「描画済み」を保証しません。プルダウンには、件数上限、ページング、並び順、遅延読み込みなどもあります。表示範囲まで一致させる必要があります。仮想スクロールを使う画面なら、対象行がDOMへ現れる条件まで確認します。
選択可能性の確認には追加リクエストが必要です。そこで、安価な条件を先に適用します。
private readonly findSelectableItem = async (
items: Array<ItemCandidate & { relatedCount: number }>,
minimumRelatedCount: number,
): Promise<ItemCandidate | undefined> => {
const primaryCandidates = items.filter(
(item) => item.relatedCount >= minimumRelatedCount,
);
for (const item of primaryCandidates) {
if (await this.isIncludedInDropdownResults(item)) {
return item;
}
}
return undefined;
};
保証対象は「一覧にある候補」から「画面検索の条件を満たす候補」へ変わります。
不明なUIタイムアウトを事前条件エラーへ変える
本当の利点は別にあります。flakeを減らすだけの対策ではありません。
適格な候補が見つからない場合、操作前に明確な理由で失敗させられます。
private readonly requireSelectableItem = async (
items: Array<ItemCandidate & { relatedCount: number }>,
minimumRelatedCount: number,
): Promise<ItemCandidate> => {
const item = await this.findSelectableItem(items, minimumRelatedCount);
if (!item) {
throw new Error(
'E2E事前条件エラー: プルダウン検索の条件を満たすテストデータがありません',
);
}
return item;
};
従来は、クリック処理が30秒後に落ちていました。修正後は、データ準備の段階で即座に止まります。原因もログから判別できます。
リトライでは直らない
表示遅延が原因ではありません。対象は検索結果へ含まれないため、何秒待っても現れません。
再実行が通る理由は単純です。次回は別候補を選ぶ可能性があります。タイムアウト延長やクリック再試行は、原因を隠します。
同じ考え方を使える場面
| 画面側の制約 | データ選択側のガード |
|---|---|
| 親項目のクリックは展開になる | 末端項目だけを選ぶ |
| 未入力欄があると検証できない | 必須値が揃った候補だけを選ぶ |
| 自動入力は有効候補が1件の場合だけ動く | 有効候補を1件だけ持つデータを選ぶ |
| 一覧にはあるが検索結果へ出ない | 同じ検索条件でIDを確認する |
共通点は、失敗する候補を操作前に除外する設計です。
操作後に行うリトライを、事前の適格性判定へ置き換えます。
検索条件の同期ずれを検知する
コメントだけでは、仕様変更を見逃す可能性があります。
小さなcanary testを用意します。画面側の実リクエストと、探索側が想定する条件を比較可能です。監視だけなら、通信を横取りするpage.route()よりpage.waitForRequest()の方が単純です。
import { expect, test } from '@playwright/test';
const createExpectedSearchParams = (keyword: string): URLSearchParams =>
new URLSearchParams({
keyword,
active_only: 'true',
leaf_only: 'true',
match_aliases: 'true',
});
test('プルダウン検索とデータ探索で条件が一致する', async ({ page }) => {
const keyword = 'sample';
const requestPromise = page.waitForRequest((request) => {
const url = new URL(request.url());
return (
request.method() === 'GET' &&
url.pathname === '/example-api/items/search'
);
});
await page.getByRole('combobox', { name: '項目' }).fill(keyword);
const request = await requestPromise;
const actual = new URL(request.url()).searchParams;
const expected = createExpectedSearchParams(keyword);
expect(Object.fromEntries(actual)).toEqual(Object.fromEntries(expected));
});
このテストは、パラメータ追加や既定値変更をCIで検知します。件数上限や並び順も表示条件に含まれる場合、比較対象へ追加します。
注意点
追加APIは、新しい失敗要因にもなります。通信失敗をUI操作のflakeへ混ぜないでください。データ準備エラーとして報告してください。候補数が多い場合は、一次条件による絞り込みや結果キャッシュも有効です。
本番コードの検索関数を直接流用すると、実装ミスまで共有する恐れがあります。条件生成だけをテスト側へ置きます。実リクエストとの一致は、canary testを使って確認する構成が安全です。
まとめ
- 一覧へ出ることと、画面検索の条件を満たすことは別
- 件数上限やページングがある場合、表示範囲も再現する
- 適格な候補がなければ、UI操作前に明確なエラーを返す
- 検索条件の同期ずれはcanary testで検知する
- flakeはリトライで隠さず、適格な候補だけを選ぶ
E2Eの不安定さは、データ選択に原因がある場合もあります。クリック待機の調整前に、対象候補を確認してください。対象は、画面検索で要求される条件を満たす必要があります。