Playwright getByText 日本語マッチングでハマった話と対処法

Playwright getByText 日本語マッチングでハマった話と対処法 コラム
Playwright getByText 日本語マッチングでハマった話と対処法のイメージ

特定商取引法ページのE2Eテストを書いていて、page.getByText('ClusterGuild') がマッチしないという事態に遭遇した。何度確認してもHTMLには確かに ClusterGuild という文字列が存在する。それなのにテストはエラーを吐き続ける。

結論から書くと、DOM上のテキストは ClusterGuild(個人事業) だった。括弧の中身ごと一致させなければ、Playwrightはそのセルを見つけてくれない。同時に、全角括弧と半角括弧を混同していたこと、exact オプションの挙動を誤解していたことも絡み合っていた。

この記事では、その実際の失敗体験をもとに、PlaywrightのgetByTextが日本語テキストマッチングで引き起こしやすい落とし穴と、場面ごとの対処法を整理する。特定商取引法ページや利用規約ページなど、日本語テキストを含むテーブルをE2Eテストで検証する場面で役に立つはずだ。

📋 この記事でわかること

  • 一番重要: 全角括弧 () と半角括弧 () はPlaywrightが区別する別の文字。DOM上の実テキストで確認することが基本
  • getByTextexact オプションの正確な挙動と、部分一致・完全一致の使い分け方
  • getByRole('cell', ...)locator.filter({ hasText: /regex/ }) を使う場面と、5手法の比較表

問題が起きた状況:特定商取引法ページのテーブル

問題が起きた状況:特定商取引法ページのテーブルのイメージ

特定商取引法に基づく表示ページには、事業者名や所在地が表形式で並ぶ。Playwrightで「テーブルの事業者名セルに正しい値が表示されているか」を確認するテストを書いた。

HTMLは次のような構造だった。


<table>
  <tbody>
    <tr>
      <th>事業者名</th>
      <td>ClusterGuild(個人事業)</td>
    </tr>
    <tr>
      <th>代表者</th>
      <td>ささき やすゆき</td>
    </tr>
  </tbody>
</table>

最初に書いたテストコードはこれだ。


// 失敗するコード
await expect(page.getByText('ClusterGuild')).toBeVisible();

テストを実行すると Locator.getByText('ClusterGuild') が要素を見つけられず、タイムアウトエラーになった。

「ページを確認したらHTMLに ClusterGuild って書いてあるのに……」というのが最初の感想だった。ChromeのDevToolsで要素を確認したところ、セルの中身は ClusterGuild(個人事業) だった。括弧付きの文字列が入っていたのだ。

getByText の exact オプションを正確に理解する

getByText の exact オプションを正確に理解するのイメージ

ここで exact オプションの挙動を整理しておく。公式ドキュメントの記述は次の通り。

> Matches by text substring by default (exact: false). However, when exact is set to true, the full string is matched.

つまりデフォルトの exact: false であれば部分一致のはずだ。getByText('ClusterGuild')ClusterGuild(個人事業) の中に ClusterGuild が含まれているから一致するはず——と思うのが直感的だが、実際の挙動はもう少し複雑だ。

exact: false のときに起きること

exact: false は「完全一致しなくていい」という意味ではあるが、Playwrightの内部実装では ターゲットノード単体のテキストと一致するかどうか も考慮される。特に

のような要素では、子要素のテキストを結合したものではなく、そのノード直接のテキストコンテンツが評価されることがある。

より正確に言えば、getByText は「そのテキストを持つ要素の中で最も具体的なもの(最も深いノード)」を優先して返す傾向がある。ClusterGuild(個人事業) というテキストを持つ

があるとき、getByText('ClusterGuild')

を取得したい場合でも、

内に ClusterGuild(個人事業) のような構造があれば が返ってくることもある。

実際の問題は単純で、DOM上には ClusterGuild(個人事業) というテキストが一つの

の中に直接入っており、ClusterGuild という部分文字列でマッチさせるためには exact: false を明示する必要があった。


// 動くコード(部分一致)
await expect(page.getByText('ClusterGuild', { exact: false })).toBeVisible();

あるいは完全一致で指定するなら。


// 動くコード(完全一致)
await expect(page.getByText('ClusterGuild(個人事業)')).toBeVisible();

全角括弧と半角括弧の罠

全角括弧と半角括弧の罠のイメージ

もう一つハマったポイントが全角括弧と半角括弧の混在だ。

日本語の文書では ()(全角括弧)を使うことが多い。特定商取引法ページの事業者名で「ClusterGuild(個人事業)」と書く場合、 は全角文字だ。しかし、普段コードを書いているとつい半角で入力してしまう。


// 失敗する(半角括弧を使っている)
await expect(page.getByText('ClusterGuild(個人事業)')).toBeVisible();

// 正しい(全角括弧)
await expect(page.getByText('ClusterGuild(個人事業)')).toBeVisible();

( は U+0028(半角左括弧)、 は U+FF08(全角左括弧)で、Unicodeコードポイントが異なる別の文字だ。Playwrightはこれらを同一視しない。

DOM上のテキストを正確に確認するには、DevToolsのコンソールで次のように確認するのが確実だ。


// DevToolsコンソールで実行
document.querySelector('td').textContent
// => "ClusterGuild(個人事業)"
// => 括弧の種類をコピーして確認する

あるいはPlaywrightのテスト実行時に page.locator('td').first().textContent()console.log で出力して確認するのも有効だ。

ホワイトスペース正規化の挙動

ホワイトスペース正規化の挙動のイメージ

Playwrightのテキストマッチングには一つ便利な仕様がある。ホワイトスペースは常に正規化される。公式ドキュメントにはこう書かれている。

> Matching by text always normalizes whitespace, even with exact match. For example, it turns multiple spaces into one, turns line breaks into spaces and ignores leading and trailing whitespace.

つまり、次のようなケースでも問題なく一致する。


<!-- HTMLでこういうインデントが入っていても -->
<td>
  ClusterGuild(個人事業)
</td>

// 前後の空白は無視してくれる
await expect(page.getByText('ClusterGuild(個人事業)')).toBeVisible();

ただし、全角スペース(U+3000)は半角スペースとは別物なので注意が必要だ。テーブルセルの中で ささき やすゆき(全角スペース区切り)を検索する場合、ささき やすゆき(半角スペース)で指定してもマッチしない。

getByRole を使うセマンティックな代替手法

getByRole を使うセマンティックな代替手法のイメージ

テーブルのセルを取得する場合、getByText よりも getByRole を使う方が意図が明確になる場合がある。


// getByRole を使ったセマンティックな記述
await expect(
  page.getByRole('cell', { name: 'ClusterGuild(個人事業)' })
).toBeVisible();

getByRole('cell', ...)

要素のARIA roleである cell を使って要素を特定する。name オプションにはアクセシブル名(visible text)を指定する。

この方法のメリットは以下の通り。

  • テーブルのセルであることが明示的でテストの意図が読みやすい
  • テーブル構造を意識したテストが書ける
  • 同じテキストが複数の場所にあってもセルに限定して取得できる

デメリットとしては、

のARIA roleが環境によっては cell でなく gridcell になることがあるため、テーブルの種類によって使い分けが必要な点だ。


// grid tableの場合は gridcell
await expect(
  page.getByRole('gridcell', { name: 'ClusterGuild(個人事業)' })
).toBeVisible();

locator.filter によるフレキシブルな絞り込み

もう一つのアプローチが locator.filter({ hasText: ... }) だ。親要素を先に絞り込んでから、その中で特定のテキストを含む要素を選ぶ構造になる。


// 行全体を先に取得してからフィルター
const row = page.locator('tr').filter({ hasText: '事業者名' });
await expect(row.locator('td')).toHaveText('ClusterGuild(個人事業)');

正規表現も使える。全角・半角どちらの括弧でも対応したい場合や、テキストの一部だけで絞り込みたい場合に有効だ。


// 正規表現を使った柔軟なマッチング
const cell = page.locator('td').filter({ hasText: /ClusterGuild/ });
await expect(cell).toBeVisible();

// 全角・半角括弧どちらでもマッチ
const cell2 = page.locator('td').filter({ hasText: /ClusterGuild[((]個人事業[))]/ });
await expect(cell2).toBeVisible();

この方法は構造が複雑なテーブルや、動的に変わる可能性のあるテキストを扱う場合に特に役立つ。

各アプローチの比較表

実際にどの手法を選ぶべきか、状況ごとに整理した比較表がこれだ。

手法 コード例 用途 注意点
getByText('完全なテキスト') page.getByText('ClusterGuild(個人事業)') 完全一致で確認したい デフォルトはexact: false
getByText('部分', { exact: false }) page.getByText('ClusterGuild', { exact: false }) 部分文字列で一致させたい 複数要素にマッチする場合あり
getByRole('cell', { name: ... }) page.getByRole('cell', { name: 'ClusterGuild(個人事業)' }) テーブルセルを意味的に取得 gridcellとの使い分けに注意
locator.filter({ hasText: '...' }) page.locator('tr').filter({ hasText: '事業者名' }).locator('td') 親要素で絞ってからセルを取得 チェーンが長くなる
locator.filter({ hasText: /regex/ }) page.locator('td').filter({ hasText: /ClusterGuild/ }) 正規表現で柔軟にマッチ 正規表現のエスケープに注意
toHaveText(/regex/) await expect(cell).toHaveText(/ClusterGuild/) アサーション側で正規表現 locator自体の絞り込みには使えない

よくある質問

Q: getByText のデフォルトは exact: true ですか?

A: デフォルトは exact: false(部分一致)です。ただし、バージョンによって挙動の細部が異なる場合があるため、公式ドキュメントで確認することを推奨します。なお exact: false でも大文字小文字は区別されます。

Q: 全角文字と半角文字を同一視させる方法はありますか?

A: Playwright標準の機能では全角・半角を自動で変換する機能はありません。正規表現を使って [((]個人事業[))] のように両方の文字クラスを指定するか、DOM上の実際のテキストに合わせてテストコードを書くのが確実です。

Q: getByText が複数の要素にマッチしてしまう場合はどうすればよいですか?

A: first()nth(0) で最初の要素を取るか、locator.filter() で親要素を絞り込んでから getByText を呼ぶ方法がおすすめです。また getByRole で要素の種類も絞り込むことで、意図しないマッチを防げます。


// 複数マッチする場合の対処例

// 1. 最初の要素を取る(同じテキストが複数箇所にある場合)
await expect(page.getByText('ClusterGuild', { exact: false }).first()).toBeVisible();

// 2. 親要素を先に絞り込む(テーブル内のセルに限定)
const table = page.locator('table.tokushoho');
await expect(table.getByText('ClusterGuild(個人事業)')).toBeVisible();

// 3. getByRole でセルに限定する(最も意味的に正確)
await expect(page.getByRole('cell', { name: 'ClusterGuild(個人事業)' })).toBeVisible();

Q: toHaveTextgetByText はどう使い分けますか?

A: getByText は要素を「見つける」ためのロケーターです。toHaveText はすでに取得した要素のテキストを「検証する」ためのアサーションです。「特定のテキストを持つ要素が存在するか確認したい」なら getByTexttoBeVisible()、「すでに取得した要素のテキストが期待値かどうかを確認したい」なら toHaveText を使います。

Q: ネストした要素のテキストはどう扱われますか?

A: getByText は対象要素の子孫要素のテキストも含めて評価します。

ClusterGuild(個人事業)

の場合、getByText('ClusterGuild(個人事業)')

にも にもマッチする可能性があります。より具体的な要素を取りたい場合は getByRolelocator.filter で絞り込むのが安全です。

✅ まとめ

特定商取引法ページのE2Eテストで page.getByText('ClusterGuild') がマッチしなかった原因は、DOM上のテキストが ClusterGuild(個人事業) だったことと、全角括弧を正確に扱う必要があったことだった。

今回のハマりどころをまとめると以下になる。

  • getByText は部分一致(exact: false)が基本だが、完全なテキストを指定した方が確実に動く
  • 全角括弧 () と半角括弧 () は別の文字。DOM上の実際のテキストを確認してから書く
  • ホワイトスペース(半角スペース、改行)は自動正規化されるが、全角スペースは正規化されない
  • テーブルのセルを取得するなら getByRole('cell', { name: ... }) がセマンティックで読みやすい
  • 柔軟にマッチさせたい場合は locator.filter({ hasText: /regex/ }) が強力

結論として、日本語テキストを含むE2Eテストを書く際は、まずDevToolsかPlaywrightのデバッグモードでDOMの実際のテキストをコピーして確認してから、テストコードに貼り付けるのが最も確実な方法だ。推測でテキストを書いてもマッチしないことが多い。

page.getByText() の挙動で迷ったら、まず exact: false を明示した上で正規表現 /ClusterGuild/ で試してみるか、getByRole に切り替えてみるのがおすすめだ。

コメント