モバイルエミュレーションを追加する
デスクトップの3ブラウザが揃ったら、次はモバイル環境です。スマートフォンでの表示崩れやタッチ操作の不具合は、実際のサービスで頻繁に問題になる領域です。
devicesプリセットでモバイル環境を再現する
モバイル向けのプロジェクトも、同じくdevicesで定義できます。端末名を指定すれば、ビューポートサイズ、ユーザーエージェント、デバイスピクセル比、タッチ操作の有無がまとめて設定されます。
// Android Chromeのエミュレーション
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
// iOS Safariのエミュレーション
{ name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
注意しておきたいのは、これがあくまでエミュレーションである点です。実機ではなく、クラウド上のブラウザに画面サイズやユーザーエージェントを合わせているにすぎません。レイアウトの検証には有効ですが、端末固有の描画差異や性能に起因する問題までは検出できません。
モバイル固有の検証を書く
プロジェクトを追加したら、モバイルでしか意味を持たないテストも用意します。
const viewport = page.viewportSize();
const box = await page.locator('#todo-form').boundingBox();
// フォームが画面幅からはみ出していないことを確認します
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width);
boundingBoxは要素の位置とサイズを返すメソッドです。viewportSizeと突き合わせれば、レイアウトが画面外へあふれていないことを機械的に判定できます。
タッチ操作の検証には、clickではなくtapを使います。tapはタッチイベントを発生させるため、タッチ専用のハンドラーが正しく動くかまで確認できます。利用にはタッチが有効になっている必要がありますが、モバイル端末のプリセットには最初から含まれています。
図はPixel 7のエミュレーション環境でTODO一覧を表示したものです。デスクトップとは異なる幅で描画されていることが分かります。なお、モバイル向けのテストはデスクトップのプロジェクトで実行しても意味がないため、projectsのtestIgnoreやtestMatchで対象ファイルを絞り込んでおきましょう。
並列化で顕在化するテストの脆さ
プロジェクトが5つになりました。しかし現時点でこれらのテストを実行すると、並列実行時に問題が発生します。
並列実行したら落ちるテストの例
前回書いたテストのまま5つのプロジェクトを並列実行すると、次のエラーが発生します。
Error: strict mode violation:
getByText('記事を執筆する') resolved to 2 elements:
1) <span>記事を執筆する</span>
2) <span>記事を執筆する</span>
4 failed
[firefox] › TODOを追加・削除できる
[webkit] › TODOを追加・削除できる
...
単独では通っていたテストが、並列化した途端に落ちるようになりました。しかも失敗するプロジェクトは実行のたびに変わります。典型的なFlaky(不安定な)テストの症状です。原因は2つあります。
原因1:テストデータがサーバー全体で共有されている
サンプルのTODOアプリは、TODOをサーバー側の配列ひとつで保持しており、ユーザーごとにデータが分かれる作りにはなっていません。
つまり、5つのプロジェクトが同時にテストを走らせると、全員が同じ一覧に書き込みます。前回のテストは「記事を執筆する」という固定の文言でTODOを追加していたため、同じ文言のTODOが次々と積み上がってしまいます。
原因2:ロケーターが複数の要素にマッチしている
原因1に加えて、前回のテストは次のように書かれていました。
await page.getByPlaceholder('やることを入力').fill('記事を執筆する');
await page.getByRole('button', { name: '追加' }).click();
await expect(page.getByText('記事を執筆する')).toBeVisible();
await page.getByRole('button', { name: '削除' }).click();
Playwrightのロケーターにはstrict modeという仕組みがあり、ひとつの要素を指すはずのロケーターが複数にマッチするとエラーになります。一覧に「記事を執筆する」が複数並べば、getByTextはその全てにマッチします。削除ボタンも同様で、一覧全体から探せば行の数だけマッチしてしまいます。
これは意図せず別の要素を操作してしまう事故を防ぐ安全装置であり、エラーが出たらロケーターの絞り込みが不十分であったサインだと受け止め、修正をしていきます。
修正1:テストデータを一意にする
まず、テストが投入するデータが衝突しないようにします。プロジェクト名を文言に含めるのが簡単で確実です。プロジェクト名はtest.info().project.nameで取得できます。
// 他プロジェクトの投入分と衝突しないよう、文言を一意にする
const text = `${baseText}(${testInfo.project.name})`;
await page.getByPlaceholder('やることを入力').fill(text);
await page.getByRole('button', { name: '追加' }).click();
await expect(todoItem(page, text)).toHaveCount(1);
これで、chromiumが追加したTODOとfirefoxが追加したTODOは別物として区別されます。アプリ側にデータ分離の仕組みがなくても、テスト側の工夫だけで独立性を確保することができます。
修正2:操作対象のスコープを絞る
次に、削除の対象を正しく指定します。一覧全体から削除ボタンを探すのではなく、目的のTODOが含まれる行に絞り込んでから、その中のボタンを押します。
// 一覧から特定のTODO行だけを取り出すロケーター
export function todoItem(page: Page, text: string): Locator {
return page.getByRole('listitem').filter({ hasText: text });
}
// 該当行の中の「削除」ボタンだけを押す
await todoItem(page, text).getByRole('button', { name: '削除' }).click();
filterは、ロケーターの候補を条件で絞り込むメソッドです。hasTextで目的の文言を含むリスト項目だけに限定し、そのうえでgetByRoleを続けることで、検索範囲がその行の内部に閉じます。
「まず領域を特定し、その中の要素を操作する」というのは、ロケーターを安定させるための基本パターンです。ページ全体を対象にしたロケーターは、画面に要素が増えた瞬間に壊れます。
修正3:テストが作ったデータを片付ける
最後に、投入したTODOを終了時に削除します。放置すると一覧がテストデータで埋まり、後続のテストが重くなっていきます。
この後片付けは、Playwrightのフィクスチャで自動化できます。テストごとに追加した文言を記録しておき、useの呼び出しより後に削除処理を書けば、テストが失敗した場合でも確実に実行されます。並列実行を前提にするなら自分が作ったものは自分で片付ける、を徹底しておきましょう。
retriesとtraceで原因を追う
修正を入れても、ネットワークの揺らぎで散発的に失敗することはあります。そうした失敗に備え、設定ファイル(playwright.config.ts)でretriesに2を、useのtraceにon-first-retryを指定しておきます。
on-first-retryは、最初のリトライのときだけTraceを記録する設定です。常時採取すると実行時間とアーティファクトの容量が膨らむため、失敗時だけ詳細を残すこの設定が実用的なバランスになります。Traceの読み方は次回詳しく扱います。
なお、リトライで通ったテストは「Flaky」として集計されます。通ったからよしとせず、件数は継続的に監視しておきましょう。増えているなら、テストの独立性が崩れかけているサインです。
