Playwright で Rails 7 (Turbo) のフォーム送信が効かなかった件と解決策
はじめに
Playwright(Python)で Rails 7 製アプリのフォーム自動化スクリプトを書いていた際、
「画面上はボタンを押しているはずなのに、サーバには POST が飛んでいない」
という現象に遭遇しました。
原因は Rails 7 のデフォルト構成に含まれる Turbo の挙動で、
解決策を見つけるまでに半日溶けたので備忘録としてまとめます。
検索しても「Playwright + Rails 7 + Turbo」の組み合わせでこの現象を解説した記事が見つからず、自力で原因を切り分けることになりました。同じ罠を踏む方の参考になれば幸いです。
この記事で分かること:
- Turbo が Playwright の click をどう intercept するか
- form 送信の各種方法(5パターン)の挙動比較
- Rails 7 + Playwright で確実にフォームを送信する書き方
環境
| 項目 | バージョン |
|---|---|
| Ruby | 3.2.5 |
| Rails | 7.2.2.1 |
| Turbo | デフォルト(Rails 7 同梱) |
| Devise | 4.x |
| Playwright (Python) | 1.60.0 |
| Chromium | Playwright 同梱版 |
対象アプリは「Rails 7 + Turbo + Devise」のオーソドックスな構成です。
やりたかったこと
ある業務アプリで、マスタデータを6件まとめて登録する作業を Playwright で自動化することになりました。
処理の流れはシンプルです:
-
/users/sign_inでログイン -
/resources/newに遷移 - フォームを埋める
- 送信ボタンをクリック
- 一覧に戻ったら次の1件へ
これまで何度も書いてきたパターンで、30分もあれば終わるはず…と思っていました。
まず素直に書いた版(うまくいかなかった)
最初に書いたコードです。よくあるパターンだと思います。
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_context().new_page()
# ログイン
page.goto("https://app.example.com/users/sign_in")
page.fill('input[name="user[email]"]', "user@example.com")
page.fill('input[name="user[password]"]', "password")
page.click('button[type="submit"]')
page.wait_for_load_state("networkidle")
# 新規登録画面
page.goto("https://app.example.com/resources/new")
page.fill('input[name="resource[name]"]', "Test Resource")
# 登録ボタン
page.click('button[type="submit"]')
page.wait_for_load_state("networkidle")
print(page.url)
# 期待: /resources(一覧画面)
# 実際: /resources/new(変わらない)
結果は次のとおりでした:
- ログイン自体は成功(URL が
/に遷移) - 新規登録フォームへの遷移も成功
- だが click 後、URL が
/resources/newのまま変わらない - エラーメッセージも flash も表示されない
「サブミットされていない」とだけ分かっていて、原因の手がかりがほぼゼロでした。
調査:レスポンス監視を仕込む
ブラックボックスを開けるために、ネットワークレスポンスを観察するハンドラを仕込みました。
page.on("response", lambda r: print(
f"{r.request.method} {r.url} -> {r.status}"
) if "example.com" in r.url else None)
実行結果(一部抜粋):
GET https://app.example.com/resources/new -> 200
GET https://app.example.com/assets/application.css -> 200
GET https://app.example.com/assets/application.js -> 200
…POST が飛んでいません。
ボタンは確かに page.click() で押しているはずなのに、サーバに何のリクエストも届いていない状態でした。
試した送信方法(比較)
ここから、フォーム送信を成立させるためにいくつかの方法を試しました。
なお、「セレクタを間違えていないか」「要素が disabled になっていないか」「要素がオーバーレイで隠れていないか」といった基本的なポイントは事前に確認済みです。
方法① button[type="submit"] を click
page.click('button[type="submit"]')
結果: ❌ URL 変わらず、POST も飛ばない。
方法② input フィールドで Enter キーを press
page.press('input[name="resource[name]"]', 'Enter')
結果: △ Devise のログインフォームでは動く。マスタ登録フォームでは動かない。
実は、ログインのほうも最初は同じ click で詰まっていたのですが、Enter キー押下に切り替えたところうまく動いていました。同じ書き方が別のフォームでは通用しなかった点が、今回の問題を一層わかりにくくしていました。
方法③ JavaScript で form.submit() を直接呼ぶ
page.evaluate("HTMLFormElement.prototype.submit.call(document.querySelector('form'))")
結果: ❌ それどころか 謎のログアウト が発生しました。
submit ボタンの click イベントを完全に bypass して submit() を直接呼ぶと、ページが /users/sign_in に戻り、body に「ログアウトしました」と表示されていました。
Turbo を経由せずに送信した結果、想定外の経路を通っているような挙動でした(後述)。
方法④ JavaScript で form.requestSubmit()
page.evaluate("document.querySelector('form').requestSubmit()")
結果: ❌ URL が変わらない・新規登録できない。
リダイレクトされて body だけログイン画面に置き換わるケースもありました。
方法⑤ input[name="commit"] を force=True で click
page.click('input[name="commit"]', force=True)
結果: ✅ POST /resources -> 302 → /resources への遷移成功。
これが最終的な解決策でした。
比較表
| # | 方法 | コード | 結果 |
|---|---|---|---|
| ① | submit ボタン click | page.click('button[type="submit"]') |
❌ POST 飛ばず |
| ② | Enter キー press | page.press('input', 'Enter') |
△ フォームによる |
| ③ | form.submit() |
HTMLFormElement.prototype.submit.call(form) |
❌ ログアウト |
| ④ | form.requestSubmit() |
form.requestSubmit() |
❌ 未遷移 |
| ⑤ |
input[name="commit"] を force click |
page.click('input[name="commit"]', force=True) |
✅ 成功 |
何が起きていたか(考察)
なお、本記事の現象は Rails 6 以前(Turbolinks の世代)では再現しないと思われます。Turbolinks と Turbo では submit イベントの intercept の仕方が異なるため、Rails 7 以降に固有 の挙動とご認識ください。
Turbo はフォーム送信に何をしているか
Rails 7 ではデフォルトで Turbo が読み込まれています。Turbo は通常のフォーム送信を JavaScript でハイジャックし、fetch ベースのリクエストに置き換えることでページ遷移を高速化する仕組みです(公式ドキュメント参照)。
このとき、Turbo は内部で submit イベントの event.preventDefault() を呼び、HTML 本来の同期 POST をキャンセルしてから自前で fetch を発火します。
なぜ ① の click が効かなかったか
Playwright の page.click() は要素の actionability チェック(可視・無効化されていない・マウスポインタが届く位置にある等)を行ったうえで click イベントを発行します。
button[type="submit"] をターゲットに click する場合、Turbo の submit ハンドラが先に発火し、想定通りに submit イベントが流れない構造になっているケースがあるようです(※筆者の環境では再現)。
特に Rails の form_with は submit ボタンを <input type="submit" name="commit"> で出力することがあり、button[type="submit"] セレクタが意図した要素を捉えていなかった可能性もあります。
なぜ ③ の form.submit() でログアウトしたか
HTMLFormElement.prototype.submit() は submit イベントを発火しません(MDN参照)。これにより Turbo の submit ハンドラが完全に bypass されます。
このとき、フォームに含まれる authenticity_token などは通常通り送信されるはずですが、Rails 側のセッション周りで「Turbo を通っていない不正なリクエスト」と判定される可能性があります。
筆者の環境では、結果として /users/sign_in にリダイレクトされ、Devise のログアウト処理が走ったかのような挙動になりました。ここは推測の域を出ませんので、より正確な原因をご存知の方はコメントで教えていただけると助かります。
なぜ ⑤ の force=True で成功したか
force=True を渡すと Playwright は actionability チェックを skip して、強制的に click を発行します。
これにより:
- click イベントが正しく submit ボタンに届く
- submit イベントが発火する
- Turbo の submit ハンドラが正常に動作する
- fetch ベースの POST が飛んで Rails が処理する
これで本来の挙動に戻ります。
加えて input[name="commit"] を明示的にセレクタにしたことで、button[type="submit"] が他の要素にマッチしていた場合の混乱も避けられました。
最終コード
最終的に動いたコードの最小サンプルです。
from playwright.sync_api import sync_playwright
def submit_rails_form(page, form_data):
"""Rails 7 + Turbo のフォームを確実に送信する"""
for selector, value in form_data.items():
page.fill(selector, value)
# Turbo の intercept を avoid するため force=True で click
page.click('input[name="commit"]', force=True)
page.wait_for_load_state("networkidle")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_context().new_page()
# ログイン(Devise のフォームは Enter キーでも通る)
page.goto("https://app.example.com/users/sign_in")
page.fill('input[name="user[email]"]', "user@example.com")
page.fill('input[name="user[password]"]', "password")
with page.expect_navigation():
page.press('input[name="user[password]"]', 'Enter')
# マスタ登録フォーム(force click が必要)
page.goto("https://app.example.com/resources/new")
submit_rails_form(page, {
'input[name="resource[name]"]': "Test Resource",
'textarea[name="resource[description]"]': "テスト用データ",
})
print(page.url)
# 出力例: https://app.example.com/resources
まとめ
Rails 7 + Turbo のフォームを Playwright で送信するときの学び:
-
page.click('button[type="submit"]')だけでは送信されないケースがある -
input[name="commit"]をforce=Trueで click すると確実 - Devise のログインフォームは Enter キー押下でも通ることが多い
-
form.submit()は submit イベントを bypass するため Turbo + Devise 環境ではセッション周りで弾かれることがある - レスポンス監視(
page.on("response", ...))は調査の強い味方
Rails 7 が普及していくにつれ、同じ現象に遭遇する方は増えると思います。本記事が少しでもデバッグ時間の節約になれば幸いです。
考察部分(特に③のログアウト挙動)には推測が含まれます。より正確な原因をご存知の方は、コメントで教えていただけると嬉しいです(マサカリ歓迎)。