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はデフォルトで透過的に扱う |
重要ポイント
- getByRole() を第一選択に - ARIAロールベースのロケータは、テストの可読性とアクセシビリティの両方を向上させる
- ユーザーが見るものでテストを書く - CSSクラスや内部構造ではなく、テキスト・ラベル・ロールでテストを記述する
- 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)に入力して「支払いを確定」ボタンをクリックするテストを書いてください。
参考リンク
- Locators - Playwright公式ドキュメント
- Other Locators - Playwright公式ドキュメント
- Frame Locators - Playwright公式ドキュメント
- ARIA Roles - MDN Web Docs
次回予告: Day 4では「ページ操作とフォーム」について学びます。ページナビゲーション、フォーム入力、セレクトボックス、ファイルアップロード、ダイアログ処理など、実践的なページ操作を習得しましょう。