PlaywrightのgetByRoleとCSSセレクターの壊れにくさの違い
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" の段落)に「登録しました」と出ます。「キャンセル」なら「キャンセルしました」です。元の版のボタンの部分は次のとおりです。
<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つです。
// 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 に探し方の名前と関数を入れて、次のテストを作っています。
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 の版だけ見つからない](https://neko-note.org/wp-content/uploads/2026/11/playwright-getbyrole-vs-css-matrix.png)
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秒待って失敗しました。
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 はエラーにならずに「キャンセル」を押しました。テストが失敗したのは、押したあとに結果を確かめていたからです。押すだけのテストなら、違うボタンを押したまま通ってしまいます。
このときの失敗は、要素が見つからないエラーではなく、確かめた文字が違うというエラーでした。
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 キーを押すテストを書きました。
// 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 でキャンセルしてしまいました。
元の版: 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 を削除" のように名前を付けています。
await page.getByRole('button', { name: '商品 999 を削除' }).click();
await page.locator('#row-999 button').click();
3回くり返した結果の中央値は、次のとおりです。
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() を選んでテストが目に見えて遅くなることはありませんでした。





