| name | e2e-locator |
| description | Locatorセレクタ実装パターン集。新しいPage Objectを作るとき、Locatorの具体的な書き方で迷ったとき、UIライブラリ固有要素を扱うときに使用。設計思想と優先順位は .claude/rules/locator-principles.md を参照。 |
E2E Locator Implementation Patterns
設計思想・優先順位・判断フローは rules/locator-principles.md(常時読み込み済み)。
このSkillは具体的な書き方とコード例に特化。
§1. セマンティックLocator(意味層が厚い要素)
page.locator('[data-testid="login-button"]')
page.getByRole('dialog', { name: 'テキストを追加' })
page.getByRole('button', { name: '保存' })
page.getByRole('button', { name: /削除|delete/ })
page.getByLabel('メールアドレス')
page.getByPlaceholder('検索')
§2. :has-text() / :text-is()
3エンジンは「見ている場所」が違う(rules/locator-principles.md 優先順位ピラミッド直下の一覧が正本):
| エンジン | 一致 | 判定対象 |
|---|
:text-is("x") | 完全一致 | 要素直下のテキストノードのみ |
:text("x") | 部分一致 | 子孫込み全テキスト。最小の要素だけマッチ |
:has-text("x") | 部分一致 | 子孫込み全テキスト。祖先まで全部マッチ(入れ子を貫通する唯一のエンジン) |
page.locator('button:has-text("ログイン")')
page.locator('span:text-is("マイページ")')
text-is の直下テキスト制約: ラベルが span 等で包まれた要素には :text-is はマッチしない(黙って0件→タイムアウト)。
page.locator('button:text-is("保存")')
page.getByRole('button', { name: '保存', exact: true })
※ getByText / getByRole の name の既定は部分一致。完全一致は exact: true で明示する。
※ getByText(..., { exact: true }) は同じ「完全一致」でも判定対象が子孫込み全テキストであり、:text-is(直下のみ)とは別物。span 包みでも通るのはこちら。
※ Ant Design Button のラベル罠(span 包み・漢字2文字の自動スペース挿入)の詳細と対処は e2e-locator/ant-design-button-label.md。
has-text の危険性: 部分一致のため意図しない要素にマッチする。
page.locator('button:has-text("保存")')
page.getByRole('button', { name: '保存', exact: true })
page.locator('[role="dialog"] button:has-text("保存")')
XPath変換時の罠:
page.locator(`span:has-text("ログイン")`)
page.locator(`span:text-is("ログイン")`)
正規表現の変更耐性:
.getByRole('button', { name: /完全削除する/ })
.getByRole('button', { name: /完全削除/ }).filter({ hasNotText: 'すべて' })
※ アンカーなしの広いパターンは変更耐性目的の意図的な部分一致 — 一意化は hasNotText / Local Universe が担う。完全一致の代替に使うなら ^ $ 必須(antd 自動スペース回避: e2e-locator/ant-design-button-label.md)。
§3. :near()(意味層が薄い要素)
page.locator('input[type="checkbox"]:near(:text("同意する"))')
page.locator('input[type="radio"]:near(:text("はい"))')
§4. data属性(UIライブラリ固有)
page.locator('button:has(svg[data-icon="edit"])')
page.locator('button:has(svg[data-icon="delete"])')
page.locator('button:has(svg[data-icon="ellipsis"])')
page.getByRole('button').filter({ has: page.locator('svg[data-icon="ellipsis"]') })
プロジェクト導入時: UIライブラリが付与する安定data属性を特定し、constants.tsに定義する。
§5. 属性セレクタ
page.locator('input[name="username"]')
page.locator('input[name="password"]')
page.locator('input[type="email"]')
§6. 親要素で絞り込み(Local Universe)
page.locator('[role="dialog"] button:has-text("保存")')
const row = page.locator('tr').filter({ hasText: targetText });
row.locator('button:has(svg[data-icon="edit"])');
§7. テーブル行のLocator
table.getByRole('row', { name: new RegExp(targetText) })
table.locator('tr').filter({ hasText: targetText })
§8. フィルターの使い分け
.filter({ hasNot: page.getByText('すべて完全削除') })
.filter({ hasNotText: 'すべて' })
page.getByRole('button', { name: /編集/ }).filter({ hasNotText: '一括' })
§9. UIライブラリ固有セレクタ(プロジェクトに合わせて追記)
UIライブラリ固有のセレクタはここに追記する。
セマンティックLocatorを優先し、ライブラリ固有セレクタは補助的に使用。
ライブラリのバージョンアップでクラス名が変わる可能性に注意。
⚠️ ポータルレンダリング Select の罠: 多くの UI ライブラリ(Ant Design / MUI / Headless UI 等)は、Select / Combobox のドロップダウンを body 直下のポータルにレンダリングする。
getByRole('option') は 非表示の元 select 要素 にもマッチし、クリックできない場合がある
combobox.fill() は検索 debounce が発火しない場合がある
- 解決策: ドロップダウンを click で開いてから、ポータル側の表示要素を直接クリックする
await page.getByRole('option', { name: targetName }).click();
await combobox.fill(targetName);
await combobox.click();
await page.locator('.ant-select-item-option')
.filter({ hasText: targetName }).first().click();
⚠️ 中身が空のタブが aria-disabled でクリック不可になる罠: Ant Design Tabs / MUI Tabs / Radix UI Tabs などはタブの中身がゼロのとき aria-disabled="true" を付与する。これを知らずに tab.click() を呼ぶと Playwright の click() が actionable 待ちで test timeout までハングし、最悪のフィードバックループになる(数分後に Fail、原因切り分け困難)。
→ 詳細は ant-design-tabs-disabled.md 参照
await page.getByRole('tab', { name: 'アーカイブ' }).click();
async isTabEnabled(tabName: string): Promise<boolean> {
const tab = this.page.getByRole('tab', { name: tabName });
await tab.waitFor({ state: 'visible', timeout: TIMEOUTS.DEFAULT });
return tab.isEnabled();
}
expect(await action.hasItemsInTab('アーカイブ')).toBeTruthy();
await navigationAction.switchTab('アーカイブ');
⚠️ モーダル閉鎖後の [role="dialog"] 残存(stale dialog): Ant Design Modal をはじめ多くの UI ライブラリのモーダルは、閉じても [role="dialog"] を持つ要素が DOM にしばらく残ることがある。複数モーダル経由フローや、同フローの再表示で getByRole('dialog') が複数マッチし、strict mode 違反でクリックできなくなる。
→ "最後に開いた dialog" を取る activeDialog() ヘルパーで吸収する。
置き場の指針: 単一の Page Object 内でしか使わないなら当該 Page Object に置く。複数の Page Object で使い始める前に BasePage に protected activeDialog() として上げて重複定義を防ぐ。
activeDialog(): Locator {
return this.page.getByRole('dialog').last();
}
await this.activeDialog().getByRole('button', { name: '削除する' }).click();
activeDialog() と SELECTORS.MODAL の使い分け(競合ではなく役割が違う。canonical は prohibited-patterns.md「アクティブモーダルのイディオム」、本表は skill 層の実務クイックリファレンス):
| 用途 | 使うもの |
|---|
stale dialog の中から「最後に開いた=アクティブ」を取る(.last() が要る) | getByRole('dialog').last()(activeDialog())— getByRole は hidden 自動除外で stale に堅牢 |
単一モーダルにスコープして中の要素を取る(.last() 不要) | SELECTORS.MODAL([role="dialog"])— Local Universe の宇宙定数 |
ハイブリッド page.locator(SELECTORS.MODAL).last() | ❌ 禁止(hidden 除外しない属性セレクタに stale 対策の .last() を貼る矛盾。詳細 prohibited-patterns.md「アクティブモーダルのイディオム」) |
activeDialog() の .last() は「フレームワーク不変条件(DOM 末尾=最前面)」に基づくカテゴリB の ordinal。理由コメントは要るが TODO は不要(prohibited-patterns.md「ordinal セレクタの許容境界」)。
カードリストの Local Universe: カード型 UI(Ant Design .ant-card, MUI .MuiCard-root, Tailwind 独自 card class など)で項目が並ぶ画面では、同じテキストがパンくず / サイドメニュー / 一覧で重複しがち。カード本体にスコープを絞ると安定する。
await page.locator(`:text-is("${itemName}")`).click();
const card = page.locator('.ant-card')
.filter({ has: page.locator(`:text-is("${itemName}")`) });
await card.click();
ライブラリごとの詳細パターンは、プロジェクト固有のドキュメントに切り出して保守する。
§10. constants.ts セレクタ定義方針
FIRST_CHECKBOX: 'input[type="checkbox"]',
AGREEMENT_CHECKBOX: 'input[type="checkbox"]:near(:text("同意する"))',
動的値はPageObject層で:
USER_ROW: (name) => `tr:has-text("${name}")`,
async clickUser(name: string) {
await this.page.locator(`tr:has-text("${name}")`).click();
}
§11. ordinal セレクタ(.first() / .last() / .nth())の対応手順
ordinal は用途で3カテゴリに分かれ、要求が異なる(詳細 prohibited-patterns.md「ordinal セレクタの許容境界」)。
カテゴリA: 曖昧マッチの応急処置(.first() が典型)
「複数マッチしたから位置で選ぶ」= 偶然の固定化。次の順で消す努力をし、消せなければ理由コメント + TODO(順序は locator-principles.md「優先順位ピラミッド」に対応)。
- 最優先: name / 完全一致(
getByRole(..., { name, exact: true }) / :text-is())/ Local Universe で一意特定(セマンティック)
- 次善:
:near() で周辺テキストから特定
- 妥協: 親要素で絞り込んでから ordinal
- 最終: ordinal + 詳細コメント + TODO
data-testid を開発チームに追加依頼するのは根本解決として有効だが長期施策。TODO に記載するのは可。
await this.page.locator(`:text-is("${name}")`).first().click();
return this.page.locator(`:text-is("${name}")`).first();
page.locator('[role="dialog"] input[type="checkbox"]').first()
カテゴリB: フレームワークの不変条件(.last() が典型)
.last() が「最後に開いた=最前面」のように z-order / DOM append 順という実在の不変条件を符号化している場合。代替が物理的に無いので消さない。理由コメントは必須だが TODO は不要(恒久的に正しい設計)。
this.page.getByRole('dialog').last()
カテゴリC: ループ消化型イテレーション(.first() が典型)
「先頭1件を取り処理して、また先頭を取る」を0件になるまで繰り返す用途(一括クリーンアップ・janitor 処理)。どの順でも全件消化されるため A の偶然の固定化が起きない。理由コメント必須・TODO 不要(B と同等)。判定条件2つ(①0件までのループで全件消化 ②順序が結果に影響しない)と maxLoops + throw ガード必須の正本は prohibited-patterns.md。
A/B/C 判定の対象外(正本 prohibited-patterns.md 同節): ordinal が曖昧さ解消でなく意図の直接表現である用途(count 走査の nth(i) ループイテレータ・引数由来 index の nth(param))は本分類の対象外 — 理由コメントのみ付与する(例: // ループイテレータ(A/B 外: 位置固定でなく全件走査))。
return this.rowContaining(text).first();