
特定商取引法ページのE2Eテストを書いていて、page.getByText('ClusterGuild') がマッチしないという事態に遭遇した。何度確認してもHTMLには確かに ClusterGuild という文字列が存在する。それなのにテストはエラーを吐き続ける。
結論から書くと、DOM上のテキストは ClusterGuild(個人事業) だった。括弧の中身ごと一致させなければ、Playwrightはそのセルを見つけてくれない。同時に、全角括弧と半角括弧を混同していたこと、exact オプションの挙動を誤解していたことも絡み合っていた。
この記事では、その実際の失敗体験をもとに、PlaywrightのgetByTextが日本語テキストマッチングで引き起こしやすい落とし穴と、場面ごとの対処法を整理する。特定商取引法ページや利用規約ページなど、日本語テキストを含むテーブルをE2Eテストで検証する場面で役に立つはずだ。
📋 この記事でわかること
- 一番重要: 全角括弧
()と半角括弧()はPlaywrightが区別する別の文字。DOM上の実テキストで確認することが基本 getByTextのexactオプションの正確な挙動と、部分一致・完全一致の使い分け方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 オプションを正確に理解する

ここで 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 を使うセマンティックな代替手法

テーブルのセルを取得する場合、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: toHaveText と getByText はどう使い分けますか?
A: getByText は要素を「見つける」ためのロケーターです。toHaveText はすでに取得した要素のテキストを「検証する」ためのアサーションです。「特定のテキストを持つ要素が存在するか確認したい」なら getByText → toBeVisible()、「すでに取得した要素のテキストが期待値かどうかを確認したい」なら toHaveText を使います。
Q: ネストした要素のテキストはどう扱われますか?
A: getByText は対象要素の子孫要素のテキストも含めて評価します。
ClusterGuild(個人事業)
の場合、getByText('ClusterGuild(個人事業)') は
にも にもマッチする可能性があります。より具体的な要素を取りたい場合は getByRole や locator.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 に切り替えてみるのがおすすめだ。




コメント