特定商取引法ページのE2Eテストを書いていて、page.getByText('ClusterGuild') がマッチしないという事態に遭遇した。何度確認してもHTMLには確かに ClusterGuild という文字列が存在する。それなのにテストはエラーを吐き続ける。
DOMはClusterGuild(個人事業)でした。ただしgetByText('ClusterGuild')は既定で部分一致するため、この括弧付きテキストだけを失敗の原因にする説明は誤りです。元の実行条件が十分残っていないため、当時の根本原因は断定できません。ここでは再現できる一致条件と調査方法を整理します。
この記事では、その実際の失敗体験をもとに、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: trueなら文字列全体を大文字小文字を含めて照合します。正規表現を渡す場合は、その正規表現のルールで判定します。
exact: false のときに起きること
子要素のテキストも検索対象に含まれます。複数の要素が一致していれば、クリック等でstrictness違反になる場合があります。一致しないのか、複数一致なのか、要素が見えていないのかを切り分けます。
より正確に言えば、getByText は「そのテキストを持つ要素の中で最も具体的なもの(最も深いノード)」を優先して返す傾向がある。ClusterGuild(個人事業) というテキストを持つ <td> があるとき、getByText('ClusterGuild') で <td> を取得したい場合でも、<td> 内に <span>ClusterGuild</span>(個人事業) のような構造があれば <span> が返ってくることもある。
この例ではexact: falseの明示は不要です。指定して結果が変わったように見える場合は、DOMの更新、待機、別のページ、iframeなど他の条件も確認します。
// 動くコード(部分一致)
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();
テキスト照合では連続した空白や改行が正規化されます。全角スペースだけが必ず例外になる、という元の説明は訂正します。対象版と実DOMで確認し、括弧の字形の違いと空白の正規化を混同しないでください。
getByRole を使うセマンティックな代替手法
テーブルのセルを取得する場合、getByText よりも getByRole を使う方が意図が明確になる場合がある。
// getByRole を使ったセマンティックな記述
await expect(
page.getByRole('cell', { name: 'ClusterGuild(個人事業)' })
).toBeVisible();
getByRole('cell', ...) は <td> 要素のARIA roleである cell を使って要素を特定する。name オプションにはアクセシブル名(visible text)を指定する。
この方法のメリットは以下の通り。
- テーブルのセルであることが明示的でテストの意図が読みやすい
- テーブル構造を意識したテストが書ける
- 同じテキストが複数の場所にあってもセルに限定して取得できる
デメリットとしては、<td> の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:trueは区別します。
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 は対象要素の子孫要素のテキストも含めて評価します。<td><span>ClusterGuild</span>(個人事業)</td> の場合、getByText('ClusterGuild(個人事業)') は <td> にも <span> にもマッチする可能性があります。より具体的な要素を取りたい場合は getByRole や locator.filter で絞り込むのが安全です。
✅ まとめ
元の失敗を、括弧付きテキストだけでは説明できません。既定の部分一致を確認し、実DOM・一致件数・表示状態・フレームの順に切り分けます。当時のログがないため、根本原因を再現済みとは扱いません。
今回のハマりどころをまとめると以下になる。
getByTextは部分一致(exact: false)が基本だが、完全なテキストを指定した方が確実に動く- 全角括弧
()と半角括弧()は別の文字。DOM上の実際のテキストを確認してから書く - 空白は正規化される。括弧の全角・半角の違いは同一視しない
- テーブルのセルを取得するなら
getByRole('cell', { name: ... })がセマンティックで読みやすい - 柔軟にマッチさせたい場合は
locator.filter({ hasText: /regex/ })が強力
結論として、日本語テキストを含むE2Eテストを書く際は、まずDevToolsかPlaywrightのデバッグモードでDOMの実際のテキストをコピーして確認してから、テストコードに貼り付けるのが最も確実な方法だ。推測でテキストを書いてもマッチしないことが多い。
まず実DOMとcount()で一致件数を確認し、操作対象が一つに定まっているかを調べてください。正規表現やgetByRoleは要件に応じて選び、first()で曖昧さを隠さないようにします。



