PlaywrightのgetByRoleとCSSセレクターの壊れにくさの違い

​​Playwright​CSS,E2Eテスト,TypeScript,アクセシビリティ

Playwright の E2E テストで要素を探すとき、getByRole() はクラス名の変更、要素の入れ子の追加、ボタンの並び順の入れ替えでは落ちませんでした。CSS セレクターは、書き方によってはこの3つのどれかで落ちました。並び順の入れ替えでは、CSS セレクターの1つがクリックではエラーにならず、別のボタンを押していました。デザインの変更やリファクタリングのたびにテストが落ちて、セレクターを直す手間を減らしたいときの選び方の材料になります。

私は検証環境で、会員登録のフォームの元の版と、ボタンの部分を書き換えた5つの版を用意し、「登録する」ボタンを5つの書き方で探して押すテストを、すべての組み合わせで動かしました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、@playwright/test 1.63.0 です。テストは TypeScript で書き、ブラウザは Chromium(ヘッドレス)だけで動かしました。Firefox と WebKit、Python などほかの言語の Playwright では試していません。テストするページは、外部のサイトではなく page.setContent() でその場に作りました。

試したページと、5つの探し方

テストの相手は、メールアドレスの入力欄と「登録する」「キャンセル」の2つのボタンがあるフォームです。「登録する」を押すと、下の欄(role="status" の段落)に「登録しました」と出ます。「キャンセル」なら「キャンセルしました」です。元の版のボタンの部分は次のとおりです。

html
<div class="actions">
  <button type="submit" class="btn btn-primary" data-testid="submit">登録する</button>
  <button type="button" class="btn" data-action="cancel">キャンセル</button>
</div>

このボタンの部分だけを、次の5通りに書き換えた版を作りました。元の版と合わせて6つです。

  • クラス名を変えた:btn btn-primary を Button_root__x1a Button_primary__k9f にした
  • ボタンを要素で囲んだ:「登録する」ボタンを <span class="tooltip"> で囲んだ
  • ボタンの順番を入れ替えた:「キャンセル」を先に置いた
  • ボタンの文言を変えた:「登録する」を「送信する」にした
  • button を div にした:「登録する」を <div class="btn btn-primary" data-testid="submit"> にし、クリックのイベントで「登録しました」と出すようにした

「登録する」ボタンを探す書き方は、次の5つです。

ts
// tests/robustness.spec.ts(一部)
const locators: [string, (page: Page) => Locator][] = [
  ["getByRole('button', { name: '登録する' })", (p) => p.getByRole('button', { name: '登録する' })],
  ["getByTestId('submit')", (p) => p.getByTestId('submit')],
  ["locator('.btn-primary')", (p) => p.locator('.btn-primary')],
  ["locator('.actions > button:first-child')", (p) => p.locator('.actions > button:first-child')],
  ["locator('form button[type=submit]')", (p) => p.locator('form button[type=submit]')],
];

1つ目は役割(ボタン)と名前(ボタンの文字)で探します。2つ目は、テスト用に付けた data-testid 属性で探します。残りの3つが CSS セレクターで、クラス名、親子関係と順番、要素名と属性で探します。

テストは、6つの版と5つの探し方の組み合わせごとに1つずつ、合わせて30個です。メールアドレスを入れて、探したボタンを押し、下の欄に「登録しました」と出るかを確かめます。設定の actionTimeout を2秒にしたので、要素が見つからないときは2秒で失敗します。6つの版と5つの探し方をループで回し、v に版、label と find に探し方の名前と関数を入れて、次のテストを作っています。

ts
test(label, async ({ page }) => {
  await page.setContent(v.html);
  await page.getByLabel('メールアドレス').fill('neko@example.com');
  await find(page).click();
  await expect(page.getByRole('status')).toHaveText('登録しました', { timeout: 1000 });
});

30通りの結果

npx playwright test tests/robustness.spec.ts を実行すると、30個のうち23個が通り、7個が失敗しました。組み合わせごとの結果を表にすると、次のようになります。

6つの版(行)と5つの探し方(列)の結果の表。getByTestId の列はすべて通った。getByRole は文言の変更と div への変更で見つからない。.btn-primary はクラス名の変更で見つからない。.actions > button:first-child は囲んだ版と div の版で見つからず、順番を入れ替えた版ではキャンセルを押した(ピンク)。form button[type=submit] は div の版だけ見つからない
書き換えた版ごとの、「登録する」ボタンを押すテストの結果

6つの版のうち、テストが通った数は次のとおりです。

探し方 通った数 落ちた変更
getByRole('button', { name: '登録する' }) 4/6 文言の変更、div への変更
getByTestId('submit') 6/6 なし
locator('.btn-primary') 5/6 クラス名の変更
locator('.actions > button:first-child') 3/6 要素で囲む、順番の入れ替え、div への変更
locator('form button[type=submit]') 5/6 div への変更

数だけを見ると、getByRole() は CSS セレクターより特に強いわけではありません。違いは、どの変更で落ちたかにあります。

クラス名・入れ子・順番の変更で落ちた CSS セレクター

クラス名を変えた版では、locator('.btn-primary') が2秒待って失敗しました。

text
    TimeoutError: locator.click: Timeout 2000ms exceeded.
    Call log:
      - waiting for locator('.btn-primary')

ボタンを <span> で囲んだ版では、.actions > button:first-child が同じように見つからずに失敗しました。子結合子の > は直接の子要素だけに当てはまるので、間に要素が1つ入ると当てはまらなくなります。

どちらの版も、利用者から見えるボタンの役割と文字は元の版と同じです。getByRole('button', { name: '登録する' }) はどちらでも通りました。

注意

ボタンの順番を入れ替えた版では、.actions > button:first-child はエラーにならずに「キャンセル」を押しました。テストが失敗したのは、押したあとに結果を確かめていたからです。押すだけのテストなら、違うボタンを押したまま通ってしまいます。

このときの失敗は、要素が見つからないエラーではなく、確かめた文字が違うというエラーでした。

text
    Error: expect(locator).toHaveText(expected) failed

    Locator:  getByRole('status')
    Expected: "登録しました"
    Received: "キャンセルしました"
    Timeout:  1000ms

公式ドキュメントの CSS と XPath で探す方法の節にも、CSS や XPath のセレクターは DOM の構造や実装に結びつくことがあり、DOM の構造が変わると壊れることがあると書かれています。nth-child などをつないだ長いセレクターは、悪い書き方の例として挙げられています。

一方で、form button[type=submit] のように要素名と属性で探すセレクターは、クラス名・入れ子・順番のどの変更でも通りました。落ちたのは、見た目のためのクラス名や、直接の親子関係と並び順(> と :first-child)に頼ったセレクターでした。

getByRole が落ちたのは、利用者から見えるものが変わったとき

getByRole() が落ちたのは、ボタンの文言を変えた版と、button を div にした版です。

文言を「送信する」に変えた版では、名前の「登録する」に当てはまるボタンがなくなり、見つからずに失敗しました。利用者が目にする文字が変わったので、テストも直すことになります。文言の変更でテストを落としたくないなら、getByTestId() を使う方法があります。今回の6つの版では、getByTestId('submit') だけがすべて通りました。

button を div にした版では、getByRole() は見つからずに失敗しました。Playwright の ariaSnapshot() でボタンの部分の役割を出すと、元の版は - button "登録する" でしたが、div の版は - text: 登録する で、ボタンではなくただの文字として扱われていました。getByTestId('submit') と locator('.btn-primary') は、どちらもこの div を押して通りました。

ただし、この版の「登録する」には、Tab キーでフォーカスが移りません。メールアドレスを入れたあとに Tab キーを押して、フォーカスの移り先を確かめ、Enter キーを押すテストを書きました。

ts
// tests/keyboard.spec.ts(一部)
await page.getByLabel('メールアドレス').fill('neko@example.com');
await page.keyboard.press('Tab');
const focused = await page.evaluate(() => document.activeElement?.outerHTML);
console.log(`${name}: Tab のあとのフォーカス = ${focused}`);
await page.keyboard.press('Enter');
await expect(page.getByRole('status')).toHaveText('登録しました', { timeout: 1000 });

元の版では Tab で「登録する」に移って登録できました。div の版では「登録する」を飛ばして「キャンセル」に移り、Enter でキャンセルしてしまいました。

text
元の版: Tab のあとのフォーカス = <button type="submit" class="btn btn-primary" data-testid="submit">登録する</button>
  ✓  1 tests/keyboard.spec.ts:6:3 › キーボードで登録する(元の版) (155ms)
buttonをdivにした: Tab のあとのフォーカス = <button type="button" class="btn" data-action="cancel">キャンセル</button>
  ✘  2 tests/keyboard.spec.ts:6:3 › キーボードで登録する(buttonをdivにした) (1.1s)

MDN の button ロールの説明にも、<div> のようにもともとフォーカスできない要素でボタンを作るときは、role="button" を付けたうえで、tabindex でフォーカスできるようにし、Enter と Space のキーの処理を自分で足す必要があると書かれています。getByRole() がこの版で落ちたのは、キーボードやスクリーンリーダーで操作する人にとってボタンでなくなった、という変化を拾った結果です。テスト ID やクラス名で探したテストは、この変化に気づかずに通りました。

公式ドキュメントがすすめる探し方

公式ドキュメントの、要素の探し方の節では、テストを壊れにくくするために、getByRole() のような利用者から見える属性や、テストのためにあらかじめ決めておく ID(テスト ID)を優先するようにすすめています。役割で探す方法の節では、利用者やスクリーンリーダーなどの支援技術がページをとらえる方法にいちばん近いとして、役割で探すことを優先するよう書かれています。

テスト ID で探す方法の節には、文字や役割が変わっても通るので、テスト ID がいちばん壊れにくいと書かれています。同時に、テスト ID は利用者から見えるものではないので、役割や文字が大事なら getByRole() などを使うように、とも書かれています。今回の結果も、この説明のとおりでした。getByTestId() は6つの版すべてで通り、その中には、Tab キーで「登録する」に移れなくなった版も含まれます。

ポイント

getByRole() が落ちるのは、利用者から見える文言や役割が変わったときです。利用者と同じ見方で要素を探しているので、div への変更のように、キーボードで操作する人にとってボタンでなくなった変化にも気づけます。

探す速さの違い

速さも比べました。1000行の表の各行に「削除」ボタンを置き、最後の行のボタンを2つの書き方で探して押すまでの時間を、30回ずつ測りました。ボタンには aria-label="商品 999 を削除" のように名前を付けています。

ts
await page.getByRole('button', { name: '商品 999 を削除' }).click();
await page.locator('#row-999 button').click();

3回くり返した結果の中央値は、次のとおりです。

text
getByRole('button', { name: '商品 999 を削除' }): 30回の中央値 50.7ms(最小 39.7ms、最大 75.5ms)
locator('#row-999 button'): 30回の中央値 49.2ms(最小 32.7ms、最大 52.9ms)

getByRole('button', { name: '商品 999 を削除' }): 30回の中央値 51.4ms(最小 41.7ms、最大 86.7ms)
locator('#row-999 button'): 30回の中央値 49.9ms(最小 32.1ms、最大 56.6ms)

getByRole('button', { name: '商品 999 を削除' }): 30回の中央値 51.6ms(最小 45.3ms、最大 67.4ms)
locator('#row-999 button'): 30回の中央値 49.7ms(最小 32.5ms、最大 53.0ms)

1000行の表でも、中央値の差は1回の操作で2ms以内でした。この検証環境では、getByRole() を選んでテストが目に見えて遅くなることはありませんでした。

​Posted by 86