CapybaraからPlaywrightへ:ブラウザテスト基盤の全面移行
Lexxyのブラウザテスト基盤をCapybara/SeleniumからPlaywrightへ移行し、ViteのDevServerをテストホストとして採用しました。テスト実行時間が約20秒超から約6秒へと短縮され、ChromiumのみだったブラウザカバレッジがFirefoxとWebKitにまで拡大されています。
背景
Capybaraベースのシステムテストは、Railsの起動・データベース準備・Seleniumの制約という三重のオーバーヘッドを抱えていました。ブラウザカバレッジはSelenium経由のChromeのみに限定され、クリップボード操作やキーボードフォーカスといったネイティブブラウザ挙動の再現精度が低く、escape_formatやツールバーキーボードナビゲーションのテストではSeleniumのタイミング起因のフレーキーな失敗が発生していました。
Playwrightへの移行を可能にした鍵は「テストの関心分離」です。本PRでは、Railsが本当に必要なテスト(Action Textレンダリング・Active Storage統合・サーバーサイドSGID解決)のみをCapybaraシステムテストとして残し、それ以外のエディタ挙動テストをすべてPlaywrightへ移行するという明確な線引きが行われました。この分離により、ブラウザテストの実行にRuby・Bundler・データベースが不要になりました。
技術的な変更
Vite DevServerによるHTMLフィクスチャ
テスト環境のホストをRails dummy appからVite DevServerへ切り替え、test/browser/fixtures/以下に10種類の静的HTMLページが新設されました。各ページはエディタの設定パターン(index・autofocus・markdown-disabled・rich-text-disabled・single-line・toolbar-disabled・toolbar-external・attachments-disabled・attachments-enabled・attachments-invalid)に対応します。
各HTMLフィクスチャはtest/browser/fixtures/editor.js経由でViteのエイリアスを通じてsrc/index.jsをインポートし、app/assets/stylesheets/のCSSを直接参照します。Stimulusコントローラに依存していたイベント追跡は、events_logger.jsがフォームレベルでlexxy:focus・lexxy:changeなど9種類のカスタムイベントをリッスンしてDOMに記録するシンプルな実装に置き換えられました。
test/browser/vite.config.jsではlexxy解決エイリアスとフィクスチャルートが設定され、playwright.config.jsのwebServerディレクティブがViteプロセスを自動起動・待機します。
Active Storageアップロードのモック
Active Storageの直接アップロードフローをRailsサーバーなしで完結させるため、test/browser/helpers/active_storage_mock.jsが新設されました。mockActiveStorageUploads(page) はpage.route()を使って2つのエンドポイントをインターセプトします。
-
POST
/rails/active_storage/direct_uploads: blobレコード作成リクエストを受け取り、mock-signed-id-N形式の署名済みIDを含む現実的なJSONレスポンスを返す - PUT `/rails/active_storage/disk/`**: ファイルバイト書き込みリクエストを受け取り、204を返す
export async function mockActiveStorageUploads(page) {
let blobCounter = 0
const calls = { blobCreations: [], fileUploads: [] }
await page.route("**/rails/active_storage/direct_uploads", async (route) => {
const body = JSON.parse(route.request().postData())
const blob = body.blob
const signedId = `mock-signed-id-${++blobCounter}`
calls.blobCreations.push(blob)
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ signed_id: signedId, /* ... */ }),
})
})
await page.route("**/rails/active_storage/disk/**", async (route) => {
if (route.request().method() !== "PUT") return route.fallback()
await route.fulfill({ status: 204 })
})
}
このモックは@rails/activestorage(devDependencyとして追加)の実際のアップロードフローを全ブラウザで完走させます。テストごとにカウンターが独立しており、共有状態は持ちません。
EditorHandleとアサーションヘルパー
test/browser/helpers/editor_handle.jsにEditorHandleクラスが新設され、RubyのEditorHandlerに対応するJavaScript実装として機能します。value()・setValue()・send()・focus()・uploadFile()などのメソッドがエディタ要素をラップします。
send()の実装では、各キー送信後にflush()を呼び出してLexicalの更新サイクルが完了するまで待機する設計が採用されています。これがescape_formatのタイミング問題を解消した核心です。またtest/browser/helpers/assertions.jsではassertEditorHtml()・assertEditorContent()・assertEditorPlainText()といったPollingベースのアサーション群が提供され、非同期なエディタ状態変化への対応が統一されています。
CIジョブの再構成
.github/workflows/ci.ymlが大幅に再構成され、以下の4ジョブが並列実行されるようになりました:
-
lint: ESLintとRuboCop -
js-unit-tests: VitestによるJSユニットテスト(Node環境のみ) -
rails-system-tests: Rails + Chrome + データベースが必要なCapybaraシステムテスト -
Playwright browser tests(
matrix: [chromium, firefox, webkit]): Node + Playwrightのみ
PlaywrightジョブはRubyもデータベースも不要なため、セットアップが即時完了します。
設計判断
「必要最小限のみRailsに残す」という原則が、この移行の設計軸です。Capybaraシステムテストとして残されたのは、Action Textによるサーバーサイドレンダリング検証・Active Storageプレビュー生成・認証済みストレージ・SGID解決という、Railsスタック全体を実際に動かさなければ検証できないケースのみです。
Viteをテストホストに採用したことで、テストフィクスチャがRailsのアセットパイプラインから独立しました。各HTMLフィクスチャがエディタの設定パターンに1対1で対応する構造は、テスト失敗時の問題箇所の特定を容易にします。
CI環境でのretries: 1・workers: 1に対してローカルではretries: 2・workers: undefined(並列)という非対称な設定は、CI環境での確定的な再現性とローカルでの高速フィードバックを両立させる判断です。
まとめ
本PRは、エディタ挙動テストの実行環境をRailsスタックから切り離すことで、マルチブラウザ対応・高速化・安定化を同時に達成した移行です。「Railsが本当に必要なテストのみRailsで動かす」という関心分離の原則が、依存関係とテスト実行時間の両方を削減する設計判断の根拠となっています。