QR Send

スマホとPCのChrome間で、URLやテキストをQRコード1回のペアリングで送り合える自作Chrome拡張。アカウント不要・無料。

使ってみる →

AIはスクリーンショットではなくアクセシビリティツリーを見ている:getByRoleが壊れにくい理由

Selenium・自動化

getByRoleを使えとは言われるけど、正直ピンと来ていませんでした

Playwrightのベストプラクティスを読むと、必ず「locatorはgetByRoleを使いましょう」と書いてあります。CSSセレクタやXPathより壊れにくいから、と。私も最初は「はいはい、そういうものか」くらいの理解で使っていました。

ただ、なぜ壊れにくいのかを人に説明しようとすると、正直言葉に詰まっていました。「ARIAのroleだから」で終わらせていて、その先がありませんでした。今回、AIエージェントがブラウザ操作でページをどう認識しているかを調べていたら、getByRoleが壊れにくい理由とAIの仕組みが同じところでつながっていることに気付きました。というわけで、今回はそれを一本にまとめてみます。

クラス名が変わっただけでテストが落ちる、あの現象

デザイナーさんがCSSのクラス名を整理しただけでテストが軒並み落ちる。CSSセレクタでテストを書いていれば誰もが一度は経験することだと思います。

// Before: クラス名 btn-primary に依存
await page.locator('.btn-primary').click();

// リファクタでクラス名が xK7_submit_v3 に変わると、この行は即死する

一方でgetByRoleを使っておくと、こういうリファクタでは落ちません。試しに書き換えてみましょう。

await page.getByRole('button', { name: '送信' }).click();

クラス名がどう変わっても、ボタンが「送信」という名前のボタンであることに変わりはないからです。ここからが本番です。この「変わらない」が何に基づいているのか、その正体を見ていきましょう。

ブラウザはDOMツリーとは別にもう一つツリーを作っている

ブラウザは画面を描画するためにDOMツリーを解析しますが、それとは別に「アクセシビリティツリー」というもう一つの木構造を内部で組み立てています。スクリーンリーダーなどの支援技術は、DOMではなくこのアクセシビリティツリーを読んで利用者に情報を伝えています。

アクセシビリティツリーの各ノードは、だいたい次のような情報だけを持っています。

  • role — button・link・heading・textboxなど、そのノードが何であるか
  • name — 支援技術が読み上げる名前(ボタンの表示テキストやaria-labelなど)
  • そのほか、checked・disabled・expandedのような状態

divが何個ネストしているか、classが何と書かれているか、といった見た目・実装上の情報はここには含まれません。人間が画面を見て「これは送信ボタンだ」と理解するのと同じ抽象度の情報だけが残るイメージです。

nameがどう決まるかにはW3Cの「Accessible Name and Description Computation」という仕様があり、aria-labelledby・aria-label・表示テキストなどの優先順位に沿って1つの名前が計算されます(かなり細かい仕様なので、私も全部は把握できていません)。getByRoleの第二引数に渡すnameは、まさにこの計算結果とマッチさせています。

だからgetByRoleはクラス名の変更に巻き込まれない

つまりgetByRoleはCSSクラスやDOM構造ではなく、ブラウザが計算した「役割と名前」を見ています。クラス名を変えても、divをsectionに変えても、role=buttonでname=送信であることが変わらなければテストは通り続けます。

Playwrightにはこのアクセシビリティツリーをそのままテストの検証対象にするtoMatchAriaSnapshotという機能もあります。

await expect(page.locator('form')).toMatchAriaSnapshot(`
  - textbox "メールアドレス"
  - button "送信"
`);

YAML形式でアクセシビリティツリーの一部を書いて、それと一致するかを見ています。CSSの見た目ではなく「ユーザーにとってどう見えているか」を検証する、という発想がgetByRoleと地続きになっているのが分かると思います。

実はAIエージェントも同じものを見ている

ここまではPlaywrightのテストの話でしたが、以前このブログで紹介したPlaywright MCPも、実は全く同じアクセシビリティツリーを使っています。

Playwright MCP入門の記事で触れたように、AIエージェントにブラウザを触らせるときは、AIが今のページの状態を「見て」判断する必要があります。ここで愚直にスクリーンショットを撮って画像としてAIに渡すと、ある検証記事では1枚のスクリーンショットだけで23万トークンを消費したという報告もありました。画像1枚でこれだけ食うと、コストもコンテキストもあっという間に厳しくなります。

そこでPlaywright MCPが標準の見せ方として採用しているのが、スクリーンショットではなくアクセシビリティツリーをテキストで渡す方式です。ページ上の要素をrole・name・階層構造だけのテキストに落とし込んで渡すと、数百〜数千トークン程度に収まります(報告によって数字に幅があり、正確な値はページの複雑さ次第ですが、画像より一桁以上小さくなる点はどの資料でも共通していました)。AIはそのテキストを読んで「ここにログインというボタンがある」と理解し、クリックする対象を選びます。

想像してみてください。人間がスクリーンショットを見るような感覚ではなく、スクリーンリーダーの利用者に近い感覚でAIエージェントはページを認識しています(もちろんAI自身に画像認識能力がないわけではなく、あくまでブラウザ操作の主な手段としてこちらが選ばれている、という話です)。

getByRoleとAIエージェント、根っこは同じ

ここまでを整理すると、次のようにつながります。

  • getByRoleが壊れにくいのは、CSSの実装詳細ではなくアクセシビリティツリーのroleとnameを見ているから
  • AIエージェントがスクリーンショットではなくアクセシビリティツリーを使うのは、同じ理由でページの意味を安く・安定して読み取れるから

どちらも「見た目の実装」ではなく「意味の層」を見ている、という一つの話です。以前紹介した録画からgetByRoleへ書き換える記事で、AIが生成したテストをgetByRole中心に直すべき理由を書きましたが、今回調べてみて、あれはAI自身がすでにアクセシビリティツリーの世界でページを見ているからこそ自然に出てくる書き方だった、と腑に落ちました。

正直に言うと、限界もあります

アクセシビリティツリー万能というわけではありません。もともとアプリ側のARIA属性がきちんと付いていないと、roleやnameが曖昧なまま(role未設定・name空欄)になり、getByRoleでもAIエージェントでも要素を正確に拾えなくなります。「アクセシビリティ対応をサボっているサイトほど、人間にもAIにも扱いにくい」というのは、地味ですが実感として正しいと思います。

また、AIエージェントが実際に何をどこまでスクリーンショットと併用しているかは製品によって差があります。視覚的な最終確認だけはスクリーンショットに頼る、というハイブリッドな実装も多いようです(このあたりは各社の実装依存が大きく、私も全部を検証できたわけではありません)。

まとめ

getByRoleを「なんとなく推奨されているから」で使っていた身としては、AIエージェントの仕組みを調べていたら理由が自分の中でつながった、というのが今回の一番の収穫でした。次にgetByRoleを書くときは、ブラウザの中でもう一つのツリーが計算されていて、AIエージェントもそこを見ている、というのを思い出してもらえると嬉しいです。

プロンプトインジェクションのように、AIにブラウザを触らせる上での注意点はこちらの記事でも書いているので、合わせて読んでみてください。

コメント

タイトルとURLをコピーしました