
PlaywrightでCSS-in-JS・React-Selectを含む複雑な業務フォームを自動化する — セレクタ戦略と過度な抽象化を避ける実践パターン
はじめに
あるアプリケーションのフォームは、代表者情報、所在地、担当者情報、配送先——1つの申込フォームに30以上のフィールドが並びます。
さらに、Emotionやstyled-componentsを使ったアプリケーションでは .css-1euijmo のようなハッシュ化されたCSSクラス名に遭遇し、React-Selectでは #react-select-3-option-0 という動的IDが生成されます。
この記事では、実際のアプリケーション自動化で使った、セレクタ戦略とフォーム自動化の実践パターンを紹介します。
前提・環境
- Playwright + TypeScript
- テスト対象:Ant Design + Emotion(CSS-in-JS)+ React-Select で構築された申込フォーム
- フォームフィールド数:30以上(テキスト入力、ドロップダウン、チェックボックス、ラジオボタン混在)
セレクタの壊れやすさ — 3段階
レベル1:壊れにくい(セマンティックセレクタ)
await page.getByRole("textbox", { name: "代表者氏名" }).fill("山田 太郎");
await page.getByRole("button", { name: "お申し込み内容を確認する" }).click();
getByRole はARIAロールとアクセシブルネームを使います。HTMLの構造やCSSクラスが変わっても、アクセシビリティ属性が維持されている限り動きます。
レベル2:まあまあ(テキストフィルタリング)
await page
.locator("label")
.filter({ hasText: "プランA - スタンダード" })
.locator("span")
.click();
await page
.locator("div")
.filter({ hasText: /^サービス利用規約に同意する。$/ })
.nth(1)
.click();
.filter({ hasText }) を使って、表示テキストで要素を絞り込みます。CSSクラスに依存しない分、CSS-in-JSのハッシュ変更に強いです。正規表現で ^ と $ を使って完全一致にすると、意図しない要素へのマッチを防げます。
レベル3:壊れやすい(CSS-in-JSのクラス名)
// 単体のCSS-in-JSクラス
await page.locator(".css-1euijmo").first().click();
// 深いCSSチェーン
await page.locator(
"form:nth-child(3) > .ant-form-item > .ant-row > div > " +
".ant-form-item-control-input > .ant-form-item-control-input-content > " +
"div > .ant-col > .css-1dsfyp3-container > .css-6ps7q7-control > .css-1euijmo"
).click();
.css-1euijmo はEmotionが生成するハッシュ化クラス名です。スタイルの変更やライブラリのアップデートで変わります。深いCSSチェーンは、HTML構造の変更(divのネストが1つ増えるだけ)で壊れます。
セレクタ判断フローチャート

要素をどのセレクタで取得するか、以下の順で判断します:
1. getByRole が使えるか?
→ ボタン、リンク、テキストボックスなど、ARIAロールとアクセシブルネームがある要素ならこれ一択。
2. テキストで一意に特定できるか?
→ getByText または .filter({ hasText }) で。完全一致が望ましい。
3. HTML属性(name, id)が安定しているか?
→ locator('[name="contractType"]') や locator('#corporateAddressDetail') で。
4. 上記すべてダメなら
→ CSS-in-JSクラスや深いCSSチェーンを使う。ただし、壊れる前提でコメントを残す。
30フィールド超のフォーム自動化
実際のコード
public async createOrder(page: Page): Promise<void> {
await page.getByText("見積もりID:").click();
await page.getByRole("button", { name: "お申し込み手続きに進む" }).click();
// --- 法人情報セクション ---
await page.getByText("日本国籍").first().click();
await page
.getByRole("textbox", { name: "法人代表者氏名", exact: true })
.fill("山田 太郎");
await page
.getByRole("textbox", { name: "法人代表者氏名フリガナ" })
.fill("ヤマダ タロウ");
await page
.getByRole("textbox", { name: "法人所在地(郵便番号)" })
.fill("1111111");
await page.locator(".css-1euijmo").first().click();
await page.getByText("北海道", { exact: true }).click();
await page
.getByRole("textbox", { name: "法人所在地(市区町村)" })
.fill("札幌市中央区");
await page.locator("#corporateAddressDetail").fill("それ以降の住所");
await page
.getByRole("textbox", { name: "法人代表電話番号" })
.fill("07000000000");
// --- 担当者情報セクション ---
await page.getByText("日本国籍").nth(1).click();
await page.getByRole("textbox", { name: "例)総務部" }).fill("営業部");
await page
.getByRole("textbox", { name: "郵便番号(ご担当者自宅住所)" })
.fill("1111111");
await page.locator(
"form:nth-child(3) > .ant-form-item > .ant-row > div > " +
".ant-form-item-control-input > .ant-form-item-control-input-content > " +
"div > .ant-col > .css-1dsfyp3-container > .css-6ps7q7-control > .css-1euijmo"
).click();
await page.locator("#react-select-3-option-0").click();
// ... さらに配送先情報、利用目的など ...
await page
.getByRole("button", { name: "お申し込み内容を確認する" })
.click();
await page
.getByRole("button", { name: "内容を保存し、申込書を作成する" })
.click();
}
なぜ分割しなかったのか
70行のメソッドを見ると、リファクタリングしたくなります。しかし、以下の理由からあえて分割しませんでした。
1. フォームは上から下へ順に入力するもの
フォームの自動化は本質的にシーケンシャルです。ヘルパー関数に分割すると:
await this.fillCompanySection(page);
await this.fillContactSection(page);
await this.fillShippingSection(page);
await this.submitForm(page);
一見きれいですが、各セクションの内部で何が入力されているかを見るには、4つのメソッドを行き来する必要があります。
2. フォームの変更は局所的
「配送先のフリガナ欄が追加された」場合、70行のメソッド内で該当箇所を見つけて1行追加するだけです。分割していると「どのヘルパーに追加するか」を考える一手間が増えます。
3. 再利用の機会がない
このフォームを入力するメソッドは createOrder だけです。セクションごとのヘルパーを他のテストから呼ぶことはありません。再利用しないものを分割しても、複雑さが増すだけです。
ただしコメントで区切る: 分割はしませんが、// --- 法人情報セクション --- のようにコメントでセクションを明示します。
分割した方がいいケース
- 同じセクションが複数のフォームに共通して出現する場合(住所入力コンポーネントなど)
- テストケースごとに入力値を変えたい場合(正常系と異常系で異なるセクションだけ差し替える)
- 100行を超える場合(さすがに長すぎるので、論理的な区切りで分ける)
複雑なフォーム要素への対処パターン
同じテキストが複数回出現する
await page.getByText("日本国籍").first().click(); // 法人代表者の国籍
await page.getByText("日本国籍").nth(1).click(); // 担当者の国籍
フォーム内に「日本国籍」ラジオボタンが2箇所ある場合、.first() と .nth(1) で出現順序で区別します。ただし、フォームのレイアウト変更で順序がずれるリスクがあります。
ドロップダウン(React-Select)
// 1. コンテナをクリックしてドロップダウンを開く
await page.locator(".css-1euijmo").first().click();
// 2. 選択肢をテキストで選ぶ(IDより安定)
await page.getByText("北海道", { exact: true }).click();
React-Selectのオプションには #react-select-N-option-N というIDが付与されますが、「N」はページ内のインスタンス出現順序のため、フィールドの追加・削除で番号がずれます。ドロップダウンが開いた状態で、選択肢のテキストを直接指定する方が安定します。
正規表現を使ったチェックボックス
await page
.locator("div")
.filter({ hasText: /^サービス利用規約に同意する。$/ })
.nth(1)
.click();
チェックボックスのラベルが長い場合、正規表現で完全一致させて特定します。^ と $ がないと、部分一致で意図しない要素にマッチするリスクがあります。
ID属性が使える箇所は使う
await page.locator("#corporateAddressDetail").fill("それ以降の住所");
await page.locator("#contactAddressDetail").fill("それ以降の住所");
await page.locator("#shippingDepartment").fill("営業部");
開発者が意図的に付与したID属性は、CSS-in-JSのハッシュよりも安定しています。
.first() の活用
await page.locator(".css-1euijmo").first().click();
同じクラスの要素が複数ある場合、.first() で最初の要素を明示的に選択します。Playwrightは複数要素がマッチするとエラーになるため、これは必須です。
開発チームとの連携
理想的には data-testid を追加してもらうのがベストです。ただし、依頼する際のポイント:
- 「全要素に追加して」ではなく、壊れやすいセレクタを使っている箇所をリストアップして依頼する
- 優先度をつける:毎回壊れるセレクタ > たまに壊れるセレクタ > 当面安定しているセレクタ
data-testidの命名規則を事前に決めておく(例:data-testid="prefecture-select")
すべてが data-testid で解決するまでのつなぎとして、今回紹介した手法を使います。
まとめ
getByRole>getByText/.filter({ hasText })> HTML属性 > CSSクラスの優先順位で選ぶ- CSS-in-JSのハッシュクラス(
.css-xxxxx)は最終手段、壊れる前提で対策する - React-Selectのドロップダウンは、IDよりもテキストベースで選択する方が安定
- 30フィールド超の業務フォームでも、シーケンシャルな1メソッドがメンテナンスしやすい場合がある
- 分割の判断基準は「再利用するか」「頻繁に変更されるか」
- コメントでセクションを区切るだけで十分な可読性が得られる
data-testid追加依頼は、壊れやすい箇所をリストアップして優先度をつけて出す
「きれいなコード」よりも「壊れにくく直しやすいコード」を優先する。業務フォームの自動化では、この判断が重要です。



