10日で覚えるPlaywrightDay 3: ロケータとDOM操作
books.chapter 310日で覚えるPlaywright

Day 3: ロケータとDOM操作

今日学ぶこと

  • getByRole, getByText, getByLabel などのビルトインロケータ
  • getBy* ロケータがCSS/XPathより推奨される理由
  • CSSセレクタとXPathによるフォールバック
  • filter() と nth() によるロケータの絞り込み
  • locator() によるロケータのチェーン
  • frameLocator() によるiframe操作
  • Shadow DOMとの対話
  • ロケータのベストプラクティス・ティアリスト

ロケータの全体像

Playwrightのロケータは、ページ上の要素を見つけるための仕組みです。Cypressなどの他のツールとは異なり、Playwrightはユーザー視点のロケータを第一級のAPIとして提供しています。

flowchart TB
    subgraph Tier1["Tier 1: ユーザー視点ロケータ(最推奨)"]
        ROLE["getByRole()"]
        TEXT["getByText()"]
        LABEL["getByLabel()"]
    end
    subgraph Tier2["Tier 2: セマンティックロケータ"]
        PLACEHOLDER["getByPlaceholder()"]
        ALTTEXT["getByAltText()"]
        TITLE["getByTitle()"]
    end
    subgraph Tier3["Tier 3: テスト専用ロケータ"]
        TESTID["getByTestId()"]
    end
    subgraph Tier4["Tier 4: フォールバック"]
        CSS["CSSセレクタ"]
        XPATH["XPath"]
    end
    style Tier1 fill:#22c55e,color:#fff
    style Tier2 fill:#3b82f6,color:#fff
    style Tier3 fill:#f59e0b,color:#fff
    style Tier4 fill:#ef4444,color:#fff

getByRole() - ロールベースのロケータ

getByRole() はPlaywrightで最も推奨されるロケータです。ARIAロールに基づいて要素を取得するため、アクセシビリティとテストの両方に配慮した設計になります。

// ボタンを取得
await page.getByRole('button', { name: 'ログイン' }).click();

// リンクを取得
await page.getByRole('link', { name: 'ホームに戻る' }).click();

// テキストボックスを取得
await page.getByRole('textbox', { name: 'メールアドレス' }).fill('user@example.com');

// チェックボックスを取得
await page.getByRole('checkbox', { name: '利用規約に同意する' }).check();

// 見出しを取得
await expect(page.getByRole('heading', { name: 'ようこそ' })).toBeVisible();

// ナビゲーションを取得
const nav = page.getByRole('navigation');

よく使うARIAロール

ロール HTML要素 説明
button <button>, <input type="submit"> ボタン
textbox <input type="text">, <textarea> テキスト入力
checkbox <input type="checkbox"> チェックボックス
radio <input type="radio"> ラジオボタン
link <a href="..."> リンク
heading <h1><h6> 見出し
list <ul>, <ol> リスト
listitem <li> リスト項目
combobox <select> セレクトボックス
navigation <nav> ナビゲーション

getByRole() のオプション

// name: アクセシブルな名前でフィルタ
page.getByRole('button', { name: '送信' });

// exact: 完全一致(デフォルトは部分一致)
page.getByRole('button', { name: '送信', exact: true });

// checked: チェック状態でフィルタ
page.getByRole('checkbox', { checked: true });

// expanded: 展開状態でフィルタ
page.getByRole('button', { expanded: true });

// level: 見出しレベルでフィルタ
page.getByRole('heading', { level: 2 });

getByText() - テキストで要素を取得

ユーザーが画面上で見ているテキストで要素を取得します。

// テキストで要素を取得(部分一致)
await page.getByText('ようこそ').click();

// 完全一致
await page.getByText('ようこそ', { exact: true }).click();

// 正規表現
await page.getByText(/合計: \d+/).isVisible();

getByText() はテキストノードを含む最小の要素を返します。ボタンやリンクを取得する場合は getByRole() の方が適切です。


getByLabel() - ラベルで要素を取得

フォームの <label> テキストに基づいて入力要素を取得します。

// ラベルテキストで入力フィールドを取得
await page.getByLabel('メールアドレス').fill('user@example.com');
await page.getByLabel('パスワード').fill('secret123');
await page.getByLabel('年齢').selectOption('30');
await page.getByLabel('利用規約に同意する').check();
<!-- 対応するHTML -->
<label for="email">メールアドレス</label>
<input id="email" type="email" />

<label>
  <input type="checkbox" /> 利用規約に同意する
</label>

getByPlaceholder() - プレースホルダーで取得

await page.getByPlaceholder('メールアドレスを入力').fill('user@example.com');
await page.getByPlaceholder('検索...').fill('Playwright');

getByAltText() と getByTitle()

// alt属性で画像を取得
await page.getByAltText('会社ロゴ').click();

// title属性で要素を取得
await page.getByTitle('閉じる').click();

getByTestId() - テスト専用ID

他のロケータでは要素を特定できない場合の手段として、data-testid 属性を使います。

// data-testid属性で要素を取得
await page.getByTestId('submit-button').click();
await page.getByTestId('user-avatar').isVisible();
<button data-testid="submit-button">送信</button>
<img data-testid="user-avatar" src="/avatar.png" />

カスタムテストID属性の設定

デフォルトでは data-testid が使用されますが、playwright.config.ts でカスタマイズできます。

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    testIdAttribute: 'data-cy', // Cypress互換の属性名も使える
  },
});

なぜgetBy*ロケータが推奨されるのか

flowchart LR
    subgraph Problem["CSS/XPathの問題"]
        P1["実装の詳細に依存"]
        P2["リファクタリングで壊れる"]
        P3["ユーザー体験と無関係"]
    end
    subgraph Solution["getBy*の利点"]
        S1["ユーザー視点で記述"]
        S2["アクセシビリティと一致"]
        S3["実装変更に強い"]
    end
    Problem -->|"解決"| Solution
    style Problem fill:#ef4444,color:#fff
    style Solution fill:#22c55e,color:#fff
観点 CSS/XPath getBy* ロケータ
可読性 page.locator('.btn-primary.submit') page.getByRole('button', { name: '送信' })
耐久性 クラス名変更で壊れる テキストやロールが変わらない限り安定
アクセシビリティ 関係なし アクセシビリティ属性を活用
意図の明確さ 実装の構造を記述 ユーザーの操作を記述

CSSセレクタとXPath(フォールバック)

getBy* ロケータで要素を特定できない場合は、page.locator() でCSSセレクタやXPathを使います。

// CSSセレクタ
await page.locator('.product-card').first().click();
await page.locator('#main-content').isVisible();
await page.locator('input[type="search"]').fill('キーワード');
await page.locator('nav > ul > li:first-child a').click();

// XPath
await page.locator('xpath=//div[@class="content"]//p[contains(text(), "重要")]').click();
await page.locator('xpath=//table//tr[3]/td[2]').textContent();

CSSセレクタやXPathは実装の詳細に依存するため、getBy* ロケータで取得できない場合のフォールバックとして使いましょう。


filter() によるロケータの絞り込み

filter() を使うと、既存のロケータをさらに条件で絞り込めます。

// テキストでフィルタ
const items = page.getByRole('listitem');
await items.filter({ hasText: '完了' }).count();

// 正規表現でフィルタ
await items.filter({ hasText: /^タスク \d+$/ }).first().click();

// テキストを含まない要素をフィルタ
await items.filter({ hasNotText: '完了' }).count();

// 子要素の存在でフィルタ
await page.getByRole('listitem').filter({
  has: page.getByRole('button', { name: '削除' }),
}).count();

// 子要素が存在しないものをフィルタ
await page.getByRole('listitem').filter({
  hasNot: page.getByRole('img'),
}).count();

filter() の使用例

// 商品一覧から「在庫あり」の商品だけを取得
const products = page.locator('.product-card');
const inStock = products.filter({ hasText: '在庫あり' });
await expect(inStock).toHaveCount(3);

// 「削除」ボタンを持つ行だけを取得
const rows = page.getByRole('row');
const deletableRows = rows.filter({
  has: page.getByRole('button', { name: '削除' }),
});
await deletableRows.first().getByRole('button', { name: '削除' }).click();

nth() による位置指定

// N番目の要素を取得(0始まり)
await page.getByRole('listitem').nth(0).click();  // 最初の要素
await page.getByRole('listitem').nth(2).click();  // 3番目の要素
await page.getByRole('listitem').nth(-1).click(); // 最後の要素

// first() と last()
await page.getByRole('listitem').first().click();
await page.getByRole('listitem').last().click();

locator() によるチェーン

ロケータをチェーンすると、親要素の中から子要素を検索できます。

// .product-card の中の「カートに追加」ボタン
await page.locator('.product-card').first()
  .getByRole('button', { name: 'カートに追加' }).click();

// ナビゲーション内のリンク
await page.getByRole('navigation')
  .getByRole('link', { name: 'ブログ' }).click();

// テーブルの特定行の中のボタン
await page.getByRole('row', { name: '田中太郎' })
  .getByRole('button', { name: '編集' }).click();
flowchart TB
    subgraph Chain["ロケータチェーン"]
        PARENT["page.locator('.product-card')"]
        FILTER["  .filter({ hasText: '在庫あり' })"]
        CHILD["    .getByRole('button', { name: '購入' })"]
    end
    PARENT --> FILTER --> CHILD
    style Chain fill:#3b82f6,color:#fff

frameLocator() によるiframe操作

<iframe> 内の要素にアクセスするには、frameLocator() を使います。

// iframeの中の要素を操作
await page.frameLocator('#payment-iframe')
  .getByLabel('カード番号').fill('4242424242424242');

await page.frameLocator('#payment-iframe')
  .getByLabel('有効期限').fill('12/30');

await page.frameLocator('#payment-iframe')
  .getByRole('button', { name: '支払う' }).click();

ネストされたiframe

// iframe の中の iframe にアクセス
await page.frameLocator('#outer-frame')
  .frameLocator('#inner-frame')
  .getByRole('button', { name: '確認' }).click();

複数のiframe

// 複数のiframeがある場合はnth()で指定
await page.frameLocator('iframe').nth(0)
  .getByText('コンテンツ').isVisible();

// または属性で特定
await page.frameLocator('iframe[name="editor"]')
  .locator('.editor-content').fill('テキスト');

Shadow DOMとの対話

PlaywrightはデフォルトでShadow DOMを透過的に扱います。特別な設定なしに、Shadow DOM内の要素にアクセスできます。

// Shadow DOM内の要素も通常のロケータで取得できる
await page.getByRole('button', { name: 'Shadow Button' }).click();
await page.locator('custom-element').getByText('内部テキスト').isVisible();
<!-- Web Component の例 -->
<custom-dialog>
  #shadow-root
    <div class="dialog-content">
      <p>内部テキスト</p>
      <button>Shadow Button</button>
    </div>
</custom-dialog>

Cypressでは cy.shadow() が必要ですが、Playwrightではそのまま要素にアクセスできます。これはPlaywrightの大きな利点の一つです。


ロケータのベストプラクティス・ティアリスト

ティア ロケータ 推奨度 理由
S getByRole() 最推奨 アクセシビリティとテストの両方に最適
A getByLabel(), getByText() 推奨 ユーザー視点で直感的
B getByPlaceholder(), getByAltText() 状況次第 特定のケースで有効
C getByTestId() 許容 他の方法で特定できない場合のフォールバック
D page.locator() (CSS) 非推奨 実装の詳細に依存する
F page.locator() (XPath) 最終手段 壊れやすく読みにくい

実践的な選択フロー

flowchart TB
    START["要素を取得したい"] --> Q1{"ロールとテキストで\n特定できる?"}
    Q1 -->|はい| ROLE["getByRole() を使う"]
    Q1 -->|いいえ| Q2{"ラベルがある?"}
    Q2 -->|はい| LABEL["getByLabel() を使う"]
    Q2 -->|いいえ| Q3{"テキストで\n特定できる?"}
    Q3 -->|はい| TEXT["getByText() を使う"]
    Q3 -->|いいえ| Q4{"data-testidを\n追加できる?"}
    Q4 -->|はい| TESTID["getByTestId() を使う"]
    Q4 -->|いいえ| CSS["page.locator() を使う"]
    style ROLE fill:#22c55e,color:#fff
    style LABEL fill:#22c55e,color:#fff
    style TEXT fill:#3b82f6,color:#fff
    style TESTID fill:#f59e0b,color:#fff
    style CSS fill:#ef4444,color:#fff

実践: ECサイトの商品操作テスト

import { test, expect } from '@playwright/test';

test.describe('商品一覧ページ', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/products');
  });

  test('在庫ありの商品をカートに追加できる', async ({ page }) => {
    // 在庫ありの商品カードを取得
    const inStockProduct = page.getByRole('listitem')
      .filter({ hasText: '在庫あり' })
      .first();

    // 商品名を取得して記録
    const productName = await inStockProduct
      .getByRole('heading').textContent();

    // カートに追加
    await inStockProduct
      .getByRole('button', { name: 'カートに追加' }).click();

    // 成功通知を確認
    await expect(page.getByText(`${productName}をカートに追加しました`))
      .toBeVisible();

    // カートのバッジが更新されたことを確認
    await expect(page.getByRole('navigation')
      .getByTestId('cart-badge')).toHaveText('1');
  });

  test('商品を検索してフィルタリングできる', async ({ page }) => {
    // 検索ボックスにキーワードを入力
    await page.getByRole('searchbox').fill('TypeScript');
    await page.getByRole('button', { name: '検索' }).click();

    // 検索結果が表示される
    const results = page.getByRole('listitem');
    await expect(results).not.toHaveCount(0);

    // 各結果にキーワードが含まれることを確認
    for (const item of await results.all()) {
      await expect(item).toContainText(/TypeScript/i);
    }
  });
});

まとめ

概念 説明
getByRole() ARIAロールとアクセシブルネームで要素を取得する最推奨ロケータ
getByText() / getByLabel() テキストやラベルでユーザー視点から要素を取得する
getByTestId() テスト専用属性で要素を取得するフォールバック
page.locator() CSSセレクタやXPathで要素を取得する最終手段
filter() hasText, has, hasNot でロケータを絞り込む
nth() / first() / last() 位置で要素を特定する
ロケータチェーン 親ロケータの中から子要素を検索する
frameLocator() iframe内の要素にアクセスする
Shadow DOM Playwrightはデフォルトで透過的に扱う

重要ポイント

  1. getByRole() を第一選択に - ARIAロールベースのロケータは、テストの可読性とアクセシビリティの両方を向上させる
  2. ユーザーが見るものでテストを書く - CSSクラスや内部構造ではなく、テキスト・ラベル・ロールでテストを記述する
  3. page.locator() は最後の手段 - CSSセレクタやXPathに頼る前に、getBy* ロケータで解決できないか検討する

練習問題

問題1: 基本

以下のHTMLに対して、Playwrightでログインフォームのテストを書いてください。

<form>
  <label for="email">メールアドレス</label>
  <input id="email" type="email" placeholder="example@mail.com" />
  <label for="password">パスワード</label>
  <input id="password" type="password" />
  <button type="submit">ログイン</button>
</form>

問題2: 応用

テーブルから特定のユーザー行を見つけて「編集」ボタンをクリックするテストを、ロケータチェーンと filter() を使って書いてください。

チャレンジ問題

iframe内に埋め込まれた決済フォーム(カード番号、有効期限、CVC)に入力して「支払いを確定」ボタンをクリックするテストを書いてください。


参考リンク


次回予告: Day 4では「ページ操作とフォーム」について学びます。ページナビゲーション、フォーム入力、セレクトボックス、ファイルアップロード、ダイアログ処理など、実践的なページ操作を習得しましょう。