「生成に失敗しても、入力した原稿は消えない」を確認したいとき、次のテストだけでは不十分な場合があります。
await page.getByRole('button', { name: 'Generate' }).click();
await expect(page.getByRole('textbox', { name: 'Script' }))
.toHaveValue('approved message');
入力値は完全一致で確認しています。それでも、失敗処理がまだ終わっていなければ、処理中に残っている入力を見てPASSできます。
HTTP 422の応答を意図的に保留する小さなUIを作り、「早すぎる検査はPASS」「失敗後まで待つとFAIL」「UIを直すとPASS」の3ケースをPlaywright 1.64.0で実行しました。
これは合成UIによる検証です。実在する生成サービスの障害報告ではなく、生成APIやLLMへのアクセスもありません。aichanceは人間が所有・監督するAI運用プロジェクトで、本文と実行例をAIで作成し、掲載内容を実行結果と照合しています。
auto-retryは「後から壊れない」の保証ではない
PlaywrightのWeb向けassertionは、条件を満たすかタイムアウトするまで再試行します。しかし、検査時点で入力値が一致していれば、そのassertionは成功できます。以後の非同期処理が値を変えないことまで継続的に検証するものではありません。公式のAssertionsの説明
今回の壊れたUIでは、次の順序になります。
Generateをクリック
↓
応答を保留中。入力は approved message のまま
↓
toHaveValue('approved message') → PASS
↓
HTTP 422を返す
↓
壊れた失敗ハンドラーが入力を空にする
await click()が終わることと、そのクリックから始まったアプリの処理が終わることは、別に考える必要があります。
固定sleepを入れずに順序を再現する
「たまたま遅かった」を再現条件にしないため、テスト側のPromiseで応答を保留します。公開コードから要点を抜粋すると、次の構造です。
let release;
const gate = new Promise(resolve => { release = resolve; });
// 対象リクエストを処理するrouteの中
await gate;
return route.fulfill({
status: 422,
contentType: 'application/json',
body: '{"error":"synthetic failure"}'
});
上記は抜粋で、単独で実行するコードではありません。完全版ではHTMLもPlaywrightのrouteから返し、それ以外の通信を遮断しています。アプリ用サーバーは起動しません。
最初のケースは、応答を保留したまま入力のassertionが成功することを確認し、その後でrelease()を呼びます。最後に失敗表示と空の入力を確認します。つまりこのケースのPASSは、入力保持に成功したという意味ではなく、検査をすり抜ける順序を再現できたという意味です。
今回の失敗が完了してから入力を確認する
この合成UIでは、失敗ハンドラーが入力を処理した後にGeneration failedを表示します。そのため、先にその表示を待つ形に変えます。
await page.getByRole('button', { name: 'Generate' }).click();
await expect(page.getByRole('status')).toHaveText('Generation failed');
await expect(page.getByRole('textbox', { name: 'Script' }))
.toHaveValue('approved message');
この抜粋は検査の順序を示しています。応答を保留する完全版のテストでは、クリック後に対象リクエストの開始を待ち、release()してから失敗表示を待ちます。
| UI | 検査のタイミング | 入力保持の検査 |
|---|---|---|
| 失敗時に入力を消す | 応答を保留している間 | PASSしてしまう |
| 同じ壊れたUI | 失敗表示の後 | FAIL |
| 失敗時に入力を保持する修正版 | 同じ失敗表示の後 | PASS |
待つ条件だけを変えて壊れたUIを検出し、UIだけを修正すると同じ検査が通る、という対応を確認しました。
6ファイルだけで実行する
実行例と説明に、テスト・設定・依存のlockfile・README・MITライセンスをまとめています。6ファイルのZIPを展開し、app-crash-lab-failure-boundary-exampleフォルダ内で実行してください。
前提はNode.js 22以上とnpmです。セットアップ時に依存パッケージとChromiumをダウンロードします。Linuxでは追加のブラウザー用システム依存が必要になる場合があります。
npm ci --prefix . --ignore-scripts
npx playwright install chromium
npm test
通常の表示は3 passedです。壊れたUIも正しかった、という意味ではありません。 中央のケースはtest.fail()で意図的な失敗として扱っています。実際の各結果を確認する場合は次を実行します。
npm test -- --reporter=json
検証時のactual statusはpassed / failed / passedで、期待する結果も同じでした。予期しない結果は0件です。失敗を期待する指定は最終的な入力assertionの直前に置いているので、セットアップや失敗表示の確認で起きたエラーを、今回の反例として成功扱いにはしません。
自分のテストに戻すときの注意
実アプリでは、今回のselectorや文言をそのまま使わず、検証したい処理の完了を示す条件を選びます。
- 前のリクエストのエラーが画面に残る場合は、今回の処理に対応した結果かも確認する。
- HTTPエラーを受け取っただけでは、UIの失敗ハンドラーが処理を終えたとは限らない。
- 失敗表示の後にもデータを変える処理があるなら、その振る舞いに合った観測範囲を設ける。
今回調べたのは編集中の入力保持です。保存済みデータの保持とは異なる条件ですが、App Crash Labの拒否更新チェックでも、拒否されたことを確認してから保存済みの値を比較しています。
この実験の出発点はDiiiiiさんのQA設計記事の説明用Playwright例です。元記事もselectorは仮と説明しています。リンク先の製品実装を調査・検証したものではありません。