DevToolsからコピーした絶対XPathがすぐ壊れる理由
Chrome DevToolsの要素検証パネルには「Copy XPath」という機能があります。これを使うと、次のような文字列がクリップボードに入ります。
# アンチパターン: DevToolsからコピーした絶対XPathをそのまま使う
submit_button = driver.find_element(:xpath, "/html/body/div[3]/div[1]/section/div[2]/form/button")
一見、対象を確実に指しているように見えます。ですがこれは「ルートから何番目の子要素か」という位置情報の連続にすぎません。ページ上部にお知らせバナーが1つ増える、A/Bテスト用の見出しdivが追加される、フォームの前に注意書きが挿入される。機能とは無関係なこうした変更だけでdiv[3]はdiv[4]に変わってしまいます。
厄介なのは、この手の変更がリリースノートに載らないくらい些細な修正で頻繁に起きることです。対象の機能自体は何も変わっていないのに、絶対XPathで組んだテストだけが無関係な箇所の修正のたびに壊れます。原因調査に時間を取られるので、私はこのやり方は使うべきではないと思っています。
自動生成されたclass名にも頼らない
同じくらい危険なのが、CSS ModulesやCSS-in-JS、Tailwindのパージ処理が生成するハッシュ化class名です。
# アンチパターン: ビルドのたびに変わるハッシュ化class名に依存する
submit_button = driver.find_element(:css, ".sc-bZQynM.css-1x2y3z")
sc-bZQynMやcss-1x2y3zは、スタイルの衝突を避けるためにビルドツールが自動採番した文字列です。見た目やロジックを一切変えなくても、依存パッケージの更新やビルド設定の変更だけでこの値は変わり得ます(このあたりはビルドツールのバージョンによって挙動が変わりやすいので、都度確認したほうが安全です)。DevToolsで見えているclass名をそのまま貼り付けると、開発側が意図していない箇所でテストが壊れる典型パターンになります。
data-testidがあれば最優先で使う
壊れにくいロケータの基本方針は、「スタイリングのための属性」ではなく「識別のための属性」を使うことです。理想は、テスト用にdata-testidのような属性を開発側に用意してもらうことです。
submit_button = driver.find_element(:css, "[data-testid='contact-submit']")
data-testidはスタイル変更やリファクタリングの影響を受けません。ページ構造やクラス名がどれだけ変わっても指し先がぶれないので、用意できるならこれ一択です。ただ、既存のフォームに後付けで属性を追加してもらえない現場も多いのが実情です。そういうときは、画面上に表示されているラベルやテキストとの「関係」を頼りにしましょう。
相対XPathでテキストや構造の“関係”を指定する
div[3]のような絶対位置ではなく、「送信という文字を含むボタン」のように、意味のある内容との関係で要素を指定するのが相対XPathの使いどころです。
submit_button = driver.find_element(:xpath, "//button[contains(., '送信')]")
contains(., '送信')としているのは、ボタンの中に<span>などの子要素が入っていてテキストが直下ではなく孫要素にある場合でも拾えるようにするためです。text()ではなく.を使うことで、要素配下の全テキストを対象にできます。これ、意外と使えます。
ラベルと入力欄がfor属性で結びついていない古いフォームでも、見た目上の並び順を使って辿れます。
email_input = driver.find_element(
:xpath,
"//label[contains(., 'メールアドレス')]/following-sibling::input"
)
これは「メールアドレスというラベルの直後にある入力欄」という、人間がフォームを見たときの理解と同じ関係を使っています。DOM構造が多少組み替わっても、ラベルと入力欄の並びさえ保たれていれば動き続けることに注意してください。
複数条件で絞り込み、誤爆を防ぐ
テキストや属性1つだけで絞り込むと、同じページに似た要素が複数あるときに意図しない要素を拾ってしまいます。フォームが複数並ぶページで単に「送信」というテキストのボタンを探すと、別のフォームの送信ボタンまでヒットする、といった具合です。
# 「送信」というテキストのボタンが複数存在し、誤爆する可能性がある
driver.find_element(:xpath, "//button[contains(., '送信')]")
# 属性とテキストを組み合わせて対象を絞り込む
driver.find_element(
:xpath,
"//form[@data-form-name='contact']//button[@type='submit'][contains(., '送信')]"
)
@type='submit'という属性条件、contains(., '送信')というテキスト条件、そして//form[@data-form-name='contact']という祖先要素の限定。この3つを組み合わせることで「お問い合わせフォームの中にある、送信タイプのボタンで、かつ送信という文字を含むもの」と一意に絞り込めます。条件を1つ増やすごとに指し先が明確になり、ページ内に似た要素が増えても誤って別の要素を操作するリスクが下がります。
ロケータは1箇所にまとめておきましょう
ロケータの文字列をテストコードのあちこちに直接埋め込んでいると、セレクタが1つ変わっただけで複数ファイルを検索して修正する羽目になります。ロケータは1箇所にまとめておき、テストコードからは名前を通して参照してください。
module ContactFormLocators
SUBMIT_BUTTON = { xpath: "//form[@data-form-name='contact']//button[@type='submit'][contains(., '送信')]" }
EMAIL_INPUT = { xpath: "//label[contains(., 'メールアドレス')]/following-sibling::input" }
end
def find(driver, locator)
strategy, value = locator.first
driver.find_element(strategy, value)
end
find(driver, ContactFormLocators::SUBMIT_BUTTON).click
email = find(driver, ContactFormLocators::EMAIL_INPUT)
こうしておくと、対象サイトの改修でボタンのテキストが「送信」から「送信する」に変わったときも、直すのはContactFormLocatorsモジュールの1行だけで済みます。呼び出し側のコードは何も変えなくてよいので、複数のテストシナリオで同じ要素を参照していても修正漏れが起きません。ロケータの定数化は見た目の整理ではなく、壊れたときの修正コストを1箇所に閉じ込めるための設計だと思っています。
まとめ
- DevToolsの「Copy XPath」で得られる絶対XPathは、ルートからの位置情報でしかなく、機能と無関係な変更でも簡単に壊れる。使うべきではありません
- CSS-in-JSやTailwindのパージが生成するハッシュ化class名は、ビルドのたびに変わり得るため識別子として使わない
data-testidのような識別用属性があれば最優先で使い、なければテキストやラベルとの関係で要素を指定する- 相対XPathの
contains(., "テキスト")やfollowing-siblingを使えば、絶対位置ではなく意味のある関係で要素を辿れる - 属性・テキスト・祖先要素などの条件を組み合わせることで、似た要素が複数あるページでも誤った要素を操作するリスクを下げられる
- ロケータの文字列はテストコードに直書きせず、モジュールや定数に集約する。壊れたときの修正箇所を1つに閉じ込められる
というわけで、絶対XPathとハッシュ化class名さえ避ければ、ロケータ設計の8割は片付きます。もっと良い絞り込み方があれば、コメントで教えてください。


コメント