🔬

【Rails】システムスペックでplaywrightドライバとcupriteドライバを比較してみる

に公開
2

Rails7.1からはシステムスペックでPlaywrightがサポートされるようになっています。
必要なGemを導入すれば、テストドライバをrack_testからselenium_chromeに切り替えるのと同じようなノリでplaywrightを指定し、システムスペックでplaywightドライバを利用することが可能です。
今回はこのplaywrightドライバを試してみるとともに、せっかくなので、他のJSドライバとの比較検証を行ってみることにしました。今回比較対象とするドライバはCupriteです。(SeleniumではなくCupriteにした理由は、以下の記事を読んで、この機会に触ってみたいと思ったからです)
https://techracho.bpsinc.jp/hachi8833/2023_06_28/95282#cuprite_systemtest
https://github.com/rubycdp/cuprite

検証用サンプルアプリ

検証用のサンプルアプリは以下になります。

認証機能もないお粗末なタスク管理アプリではありますが、Turbo FrameによるDOMの部分差し替えや、Stimulusを使ったモーダル操作、そして意味なく時間のかかる非同期処理などを実装し、それぞれのドライバの比較結果に何らかの影響が出ることを期待しています。

今回は以下の3つのシナリオに注目して比較を行います。

  1. タスク追加ボタンをクリックすると、モーダルが現れること
  2. 追加したタスクがproject-tasks(期待したturbo-frame-tag)内に正しく追加されること
  3. すべて完了にするボタンで一定時間待機後全タスクが完了状態になること

検証環境

- Ruby: 3.3.0
- Rails: 8.0.2
- Node.js: 18.20.4
- RSpec: 3.13
- Capybara: 3.40.0
- cuprite: 0.17
- capybara-playwright-driver: 0.5.7
- playwright (npm): 1.55.0

テスト実行環境は以下

- ローカル: macOS Sonoma 14.4 (Apple Silicon M2)
- CI: Ubuntu (GitHub Actions ubuntu-latest, 2025/09時点)

導入

検証を始める前に、playwright、cupriteのそれぞれの導入手順について触れておきます。
cupriteの方が設定項目が少ないので、先にcupriteの導入手順から確認します。

cupriteを導入する

Gemfileに必要なgemを追加します。

# Gemfile
group :test do
  gem "capybara"
  gem "cuprite"
end

rails_helper.rbで以下を読み込みます。

require "capybara/rspec"
require "capybara/cuprite"

ドライバの設定はspec/support/capybara.rbにまとめました。

RSpec.configure do |config|
  # 以下はデフォルトのrack_testの設定
  config.before(:each, type: :system) do
    driven_by(:rack_test)
  end

  config.before(:each, type: :system, js: true) do
    driven_by(:cuprite, screen_size: [1400, 1400], options: {
      timeout: 30,
      headless: true,
    })
  end
end

これにより、JavaScriptが絡む動作をテストするexampleにはjs: trueを指定することで、そのテストではcupriteが利用できるようになりました。

playwrightを導入する

引き続いてplaywrightの導入手順です。
まずはplaywrightをインストールします。

npm install playwright
npx playwright install chromium

Gemfileに必要なgemを追加します。(capybaraが重複していますが、playwrightのみ利用する場合を想定し、あえて表記として残しています)

# Gemfile
group :test do
  gem "capybara"
  gem "capybara-playwright-driver"
end

rails_helper.rbで以下を読み込みます。

require "capybara/rspec"
require "capybara/playwright"

Cupriteの場合と同じくドライバの設定をspec/support/capybara.rbにまとめて記述します。

require 'capybara/playwright'

# 以下2つはplaywright向けの設定
Capybara.default_max_wait_time = 15

Capybara.register_driver(:playwright) do |app|
  Capybara::Playwright::Driver.new(app,
    browser_type: :chromium,
    headless: true
  )
end

RSpec.configure do |config|
  # ...

  # rack_test, cupriteの設定に以下を追記
  config.before(:each, type: :system, playwright: true) do
    driven_by(:playwright, screen_size: [1400, 1400])
  end
end

playwrightの場合はRSpecが使用するドライバ(playwright)の設定だけでなく、playwright自体の設定も行っています。

  • 待機時間の延長
    • Playwrightはネットワーク経由でブラウザと通信するため、Cupriteより処理が重い場合があります。そのため、Capybara.default_max_wait_time = 15の指定でデフォルトの待機時間(2秒)を延長しています。
  • playwrightのブラウザ設定
    • Capybara.register_driver(:playwright)のブロックでは、Playwright自体の設定を行っており、PlaywightがChromeを使うこと、そしてheadlessモードで動作することを定義しています。

これらの設定により、テストコード内のexampleでplaywright: trueを指定することで、playwrightドライバを利用したテストを実行することが可能になります。

CIの設定

CI環境も整えておきます。Playwrightのインストールを行う必要があります。

name: Test Suite

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    env:
      RAILS_ENV: test
      DATABASE_URL: sqlite3:db/test.sqlite3

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.3.0'
          bundler-cache: true

      - name: Setup Database
        run: |
          mkdir -p db
          bundle exec rails db:create
          bundle exec rails db:migrate

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'

      - name: Install Playwright
        run: |
          npm install playwright
          npx playwright install --with-deps

      - name: Run RSpec tests
        run: bundle exec rspec

検証1: タスク追加ボタンをクリックすると、モーダルが現れること

この挙動をテストします。

モーダルが開くきっかけとなるのは「タスクを追加する」ボタンです。

<%= link_to "タスクを追加する", new_project_task_path(@project),
    data: { turbo_frame: "modal_content_frame", action: "click->modal#open"} %>

このlink_toヘルパーには、data-turbo-frame="modal_content_frame"が付与されているので、Turboがリクエストをフックし、GET new_project_task_path(@project)に対するレスポンスのHTMLを

<turbo-frame id="modal_content_frame">...</turbo-frame>

で囲まれた中に描画します。

また、data-action="click->modal#open"も記述されているので、Turboの挙動と同時にStimulusによるクリックイベントも発生します。
つまり、「タスクを追加する」というボタンをクリックすると以下2つの挙動が発生します。

  1. modal_controller.jsopenアクションを実行する。
  2. GET new_project_task_path(@project)のレスポンスを指定したTurbo Frameに挿入する。

1のSimulusの挙動によってモーダルの”ガワ”の部分が表示され、2のTurbo Frameによってそのモーダルの中身が実装されています。

Cupriteでのテスト

モーダル出現のシナリオをテストするcupriteのコードは以下になります。

it "タスク追加ボタンをクリックすると、モーダルが現れること", js: true do
  visit project_path(project1.id)
  
  # 初期状態:モーダルは非表示(画面上にDOMは存在するが、見えない)
  expect(page).to have_selector('[data-modal-target="modal"]', visible: false)
  expect(page).to have_selector("a", text: "タスクを追加する")
  
  # ボタンクリックでモーダルが表示される
  click_on "タスクを追加する"
  expect(page).to have_selector('[data-modal-target="modal"]', visible: true)
  expect(page).to have_selector('[data-modal-target="content"]', visible: true)
  
  # モーダル内容の確認
  within('[data-modal-target="content"]') do
    expect(page).to have_content("タスクを登録する")
    expect(page).to have_field("タスク名")
    expect(page).to have_field("優先度") 
    expect(page).to have_button("登録する")
    expect(page).to have_button("やめる")
  end
end

このテストのポイントは、click_on "タスクを追加する"の直後に記載している2つのexpectです。

先述の通り、「タスクを追加する」ボタンをクリックすると、Stimulusによるモーダル表示と、Turbo FrameによるDOM挿入が行われます。そのため、ボタンがクリックされると、即座にモーダルが表示されるわけではなく、Stimulusのイベント処理メカニズムを経由してDOM変更が行われるため、わずかな遅延が生じる可能性があります。

つまり、クリックイベントの直後にモーダルの内容を確認するexpectを置いてしまうと、そのタイミングでは、DOM更新がまだ完了していない場合があります。
そうすると、期待したテスト結果が得られないため、expect(page).to have_selector('[data-modal-target="modal"]', visible: true)の記述により、画面内にdata-modal-target="modal"要素が現れたことを保証してから、モーダル内部の内容の検証を行うようにしています。
expect(page).to have_selector('[data-modal-target="content"]', visible: true)についても同様です。こちらはTurbo FrameによるDOM挿入が完了したことを担保するためのものです。

Playwrightでのテスト

Playwrightドライバを使った場合でも上記と全く同じテストコードを記述することができます。
今回の設定の場合、cupriteのテストでjs: trueと記載していた箇所をplaywright: trueとするだけで、ドライバをplaywrightに切り替えたテストを実行することができました。

1つ目の「基本的な機能」の検証ではドライバ間の差はあまり見られないということがわかりました。

検証2: 追加したタスクがproject-tasks(期待したturbo-frame-tag)内に正しく追加されること

次はタスク追加モーダルから実際にタスクを追加した場合の挙動のテストです。

モーダルからタスクを追加すると、以下のイベントが発生します。

  • 登録したタスクがタスク表示エリアの末尾に追加される
  • モーダル画面が閉じる

Cupriteでのテスト

まずはCupriteドライバによるテストから見ていきましょう。

it "新規タスクがproject-tasks内に正しく追加されること", js: true do
  visit project_path(project1.id)
  expect(page).not_to have_selector('[data-modal-target="modal"]')
  expect(page).to have_selector("a", text: "タスクを追加する")

  click_on "タスクを追加する"
  expect(page).to have_selector('[data-modal-target="modal"]', visible: true)
  expect(page).to have_selector('[data-modal-target="content"]')

 # UI操作によってTaskレコードが追加されていることを確認
  expect{
    within('[data-modal-target="content"]') do
      fill_in "タスク名", with: "テスト追加タスク"
      click_on "登録する"
    end
  }.to change(Task, :count).by(1)

  # project-tasks内に追加されていることを確認
  within("#project-tasks") do
    expect(page).to have_content("テスト追加タスク")
  end

  # project-tasks外に重複していないことを確認
  outside_project_tasks = page.all("#project-tasks ~ *", text: "テスト追加タスク")
  expect(outside_project_tasks).to be_empty
end

このテストではモーダルからタスクを追加した結果、以下2つの変化が起きていることを検証しています。

  • DB上に新しくTaskレコードが追加されていること
  • 追加したタスクのカードが画面上に表示されていること

ここでのポイントは後者の検証になるかと思います。
単純に「追加したタスクが画面上に表示されていること」を確認するだけなら、クリックイベントの後にexpect(page).to have_content("追加したタスク名")と書くだけでもOKなのですが、今回はもう少し踏み込んで、

  • 追加タスクのDOMがid="project-tasks"を持つdiv要素の内部に追加されていること
  • id="project-tasks"外部に追加タスクのDOMが挿入されていないこと
    を検証しています。

少々くどい気がしますが、これには以下の理由があります。

Turbo Frameを使う場合、新規タスクを追加する際のTasksコントローラのcreateアクションに対応したビューcreate.turbo_stream.erbを用意する必要があります。
createアクションの結果、追加されたTaskレコードを、画面上のどのTurbo Frameに挿入するのかを指定する必要があるためです(「createアクションに対応するビューを用意する必要がある」というのは個人的には違和感があり、まだ慣れません…。)。

今回のtasks/create.turbo_stream.erbでは次のように記述しています。

<%= turbo_stream.append "project-tasks", @task %>

これは大雑把にいうと、「id="project-tasks"を持つdiv要素の末尾に新規タスク要素を挿入する」という動きになります。
ではここで、挿入先のidを誤って指定してしまったケースを考えてみます。
誤って指定したidが表示先のHTML上にそもそも存在しない場合は、動作確認時に画面にタスクが追加されないということにすぐ気がつけるので、そこまで問題にはならないとは思います。
しかし、表示先のHTML上に実際に存在するidを誤って指定している場合は、「追加した要素をとりあえず画面に表示できる(DOMの挿入先は違うけどね)」という状況が発生します。

そして先ほどの、

「追加したタスクが画面上に表示されていること」を単に確認するだけなら、クリックイベントの後にexpect(page).to have_content("追加したタスク名")と書くだけ

では、この状況を適切に検証できません。画面上には追加したタスクが表示されているので、テストはパスします。しかし、追加タスクがid="project-tasks"の要素内に表示されている前提で動作する機能があれば、その機能は満足に動作しないでしょう。そのため、本テストでは挿入されるDOM要素がどの部分に追加されているかまでを厳密にチェックすることとしました。

Playwrightでのテスト

次に、Playwrightドライバによるテストをみていきます。
なんと、先ほどのCupriteドライバのテストコードをそのままplaywrightドライバに切り替えるとエラーになりました。

テストをパスさせるためにあれこれ試行錯誤した結果、playwrightドライバではレコード数の変化を検証する部分を以下のように書き換えることでようやくテストを通すことができました。

it "新規タスクがproject-tasks内に正しく追加されること", playwright: true do
  visit project_path(project1.id)
  expect(page).not_to have_selector('[data-modal-target="modal"]')
  expect(page).to have_selector("a", text: "タスクを追加する")

  # UI操作の前にTask数を変数に代入
  initial_count = Task.count

  click_on "タスクを追加する"
  expect(page).to have_selector('[data-modal-target="modal"]', visible: true)
  expect(page).to have_selector('[data-modal-target="content"]', visible: true)

  within('[data-modal-target="content"]') do
    fill_in "タスク名", with: "テスト追加タスク"
    click_on "登録する"
  end

  within("#project-tasks") do
    expect(page).to have_content("テスト追加タスク")
  end

  # UIの変化が起きてからTask数が増加したことを確認
  expect(Task.count).to eq(initial_count + 1)

  outside_project_tasks = page.all("#project-tasks ~ *", text: "テスト追加タスク")
  expect(outside_project_tasks).to be_empty
end

違いが出た原因

Playwrightドライバでエラーになったのはexpect{ }.to changeを使ったレコード数の増減検証の部分でした。そこでbenchmarkを使って、それぞれのテストコードの要所要所にデバッグ出力処理を差し込み、このエラー原因を調査することとしました。

Cupriteのテストのデバッグ

it "デバッグ: 新規タスクがproject-tasks内に正しく追加されること", js: true do
  require 'benchmark'
      
  visit project_path(project1.id)
  # デバッグ出力1
  puts "初期タスク数: #{Task.count}"
      
  click_on "タスクを追加する"
  expect(page).to have_selector('[data-modal-target="modal"]', visible: true)
      
  form_time = Benchmark.realtime do
    expect {
      within('[data-modal-target="content"]') do
        fill_in "タスク名", with: "検証タスク"
        click_on "登録する"
        # デバッグ出力2
        puts "「登録する」をクリック直後のタスク数: #{Task.count}"
      end
    }.to change(Task, :count).by(1)
  end

  # デバッグ出力3
  puts "タスク追加フォームの入力処理にかかった時間: #{(form_time * 1000).round(1)}ms"
  # デバッグ出力4
  puts "検証完了後タスク数: #{Task.count}"
end

Playwrightのテストのデバッグ

it "デバッグ: 新規タスクがproject-tasks内に正しく追加されること", playwright: true do
  require 'benchmark'
      
  visit project_path(project1.id)
  initial_count = Task.count
  # デバッグ出力1
  puts "初期タスク数: #{initial_count}"
      
  click_on "タスクを追加する"
  expect(page).to have_selector('[data-modal-target="modal"]', visible: true)
      
  form_time = Benchmark.realtime do
    within('[data-modal-target="content"]') do
      fill_in "タスク名", with: "検証タスク"
      click_on "登録する"
      # デバッグ出力2
      puts "「登録する」をクリック直後のタスク数: #{Task.count}"
    end
  end

  # デバッグ出力3
  puts "タスク追加フォームの入力処理にかかった時間: #{(form_time * 1000).round(1)}ms"
      
  within("#project-tasks") do
    expect(page).to have_content("検証タスク")
  end

  # デバッグ出力4
  puts "検証完了後タスク数: #{Task.count}"
  expect(Task.count).to eq(initial_count + 1)
end

それぞれのデバッグの出力結果

それぞれの出力結果は以下の通りです。

Cuprite Playwright
初期タスク数 2 2
「登録する」をクリック直後のタスク数 3 2
タスク追加フォームの入力処理にかかった時間 160.9ms 80.3ms
検証完了後タスク数 3 3

Cupriteではモーダル内部のフォーム入力操作にPlaywrightの倍の時間がかかっています。
そして、「登録する」をクリックした直後のTaskレコード数については、Cupriteでは3になっていますが、playwrightでは2のままです。

この結果から、それぞれのドライバは次のように動作していると考えられます。

  • Cupriteドライバは、click_on "登録する"操作の後、データベースの更新が完了するまで同期的に待機している
    【※2025年9月16日修正】 Cupriteドライバには内部的にクリック操作後0.1秒待機するという固有の実装があるため、結果的にexpect{ }.to changeの段階でデータベースの更新が完了している
  • Playwrightドライバはclick_on "登録する"操作の後、即座にブロックを抜けている。そのため、直後のタスク数に変化がない

2つ目の「あるUI操作がUIとデータベースの両方に作用するケース」では、CupriteとPlaywrightのドライバ間での動作に差が出る結果となりました。
また、この検証をきっかけに、Cupriteドライバの方には内部的にクリック操作後に0.1秒待機するという実装があるということを知ることができました。今後はそれぞれのドライバの性質を踏まえつつ、検証するシナリオに応じた適切な使い分けを心がけていきたいと思います。

検証3: すべて完了にするボタンで一定時間待機後全タスクが完了状態になること

次が最後の検証となります。結論から申し上げると、この検証では「ドライバ間の差異」は見られませんでした。しかし興味深い結果が見られたので、本来の趣旨からは少し逸れるかもしれませんが、本記事に含めておきたいと思います。

ここでは以下の挙動をテストします。

追加したタスクはチェックボックスをクリックすることで完了状態にすることができます。そして、「すべて完了にする」ボタンをクリックすると、画面に表示されているすべてのタスクを一度に完了状態にすることができます。なお、この処理は完了までに3秒ほど時間がかかります。特に実装上の意味はありません(コントローラでsleep 3を入れています)。

Cupriteでのテスト

この動作を検証するCupriteドライバによるテスト例は以下のとおりです。

it "すべて完了にするボタンで一定時間待機後全タスクが完了状態になること、また全て完了にするボタンは非表示になること", js: true do
  visit project_path(project1.id)
  expect(page).to have_selector("button", text: "すべて完了にする")
      
  within("#all-complete") do
    click_on "すべて完了にする"
  end
      
  # ボタンが消えるまで待つ(処理完了の指標として使用)
  expect(page).not_to have_selector("button", text: "すべて完了にする", wait: 6)
      
  # id="project-tasks"要素にネストされた各タスク要素に"Done"が表示されることを確認
  within("#project-tasks") do
    expect(page).to have_content("Done", count: 2, wait: 1)
  end
      
  # データベースの状態確認
  expect(project1_task_a.reload.status).to be true
  expect(project1_task_b.reload.status).to be true
end

このテストでは以下2点の変化を検証しています。

  • 全タスクに"Done"が表示されること
  • DB上の対象レコードの状況もstatusカラムの値が全てtrueになり、画面の状態と一致していること

先ほども申し上げましたが、これらの動作結果は3秒間待った後に得られます。つまり、テストでも一定時間待つ必要があります。
ここでは「すべて完了にする」ボタンの表示状態の変化に注目しています。「すべて完了にする」ボタンはタスクが全て完了状態になっていると画面上には表示されません。
よって、 click_on "すべて完了にする"の後、「すべて完了にする」ボタンが画面上に表示されていないことを保証することで、「この非同期処理が完了するまで待つ」ことが再現できます。

Playwrightでのテスト

PlaywrightドライバでもCupriteと同じテストを試すことができました。
どちらも問題なくローカル環境ではテストがパスしました。しかし、このテストをCI環境で実行してみるとエラーになりました…。

そこで、CI環境でテストがパスするよう試行錯誤し、先ほどとは異なるアプローチで今回のシナリオのテストを書くことができました。以下がそのテスト例です。

it "すべて完了にするボタンで一定時間待機後全タスクが完了状態になること", playwright: true do
  visit project_path(project1.id)

  expect(page).to have_button("すべて完了にする")
  overlay = page.find('[data-complete-all-target="overlay"]', visible: false)
  expect(overlay[:class]).to include('hidden')

  click_button "すべて完了にする"

  expect(page).to have_selector('[data-complete-all-target="overlay"]', visible: true, wait: 3)

  expect(page).to have_selector('[data-complete-all-target="overlay"]', visible: false, wait: 15)
  expect(page).to have_selector('.bg-green-100', count: 2)
  expect(project1_task_a.reload.status).to be true
  expect(project1_task_b.reload.status).to be true
end

先ほどは「すべて完了にする」ボタンが非表示になっていることを確認することで時間のかかる処理が完了したことを保証していました。
これは、サーバーサイドの処理が完了すればボタンが消えているはずだ、というサーバー側の動きを意識した検証アプローチとなっています。
一方で、今回のテストでは、click_button "すべて完了にする"の後、「ローディング画面が表示される」、「ローディング画面が消える」、「タスクのカードの色が緑に変わる」と、サーバー側の動作には注目せず、画面上の状態変化を個別に検証していくという、クライアント側の動きに注目したアプローチといえるでしょう。

ちなみに、このテストはドライバをCupriteに切り替えても問題なく動作し、CIでもパスします。

最後の検証では、ドライバー間の差異は見られませんでした。しかし、テスト実行環境の違いによってテストの成否が変わるケースに遭遇しました。この問題に対して、検証アプローチを変更することで、同じシナリオをテストしつつCI環境でも安定して動作するテストを書けることが分かりました。
これは自分にとっても大きな学びになったかと思います!

比較の結果まとめ

初めてのplaywrightドライバということで、当初は導入のハードルは高いのかなと身構えていたのですが、意外と簡単にシステムスペックに取り入れることができたと感じています。

また、それぞれのドライバを試した感想としては、2つ目の検証で見たように、「あるUI操作がUIとデータベースの両方に作用するケース」では、ドライバ間の差異が顕れ、特に、Cupriteではサーバーサイドの処理が完了するまで待つ内部的な実装によりクリック操作後0.1秒待つ一方で、PlaywrightはUI変化に合わせて次へ次へと進むという違いがあるようでした。

用途によって明確に分けられるものではありませんが、今回の検証結果を踏まえると、データベース操作を含む検証ではCupriteが、UI/UXの動作検証が中心の場合はPlaywrightが適している可能性があります。それぞれのドライバの特性を理解した上で、テストするシナリオに応じて使い分けることが重要だと感じました。

以上、長くなりましたが、ここまでお読みいただきありがとうございました!
今回取り扱ったソースコードはこちらです。
https://github.com/atsumu22/hotwire_playwright_sample/tree/article

Discussion

Yusuke IwakiYusuke Iwaki

興味深い記事ありがとうございます。
capybara-playwright-driver の作者なのですが、Cupriteがサーバーの何かを待っているはずはないと思い、少しだけ調べてみました。

タイミングがわかるよう、ログ追加します。

spec/system/projects_spec.rb
      # expect { ... }.to change だとログが挟めないので愚直に書く
      count_before = Project.count
      logger.info "DEBUG: Before creating new Project, Project count is #{count_before}"
      within("#new_project") do
        fill_in "project_name", with: "新しいプロジェクト"
        logger.info "DEBUG: Perform clicking '登録する', Project count is #{Project.count}"
        click_on "登録する"
      end
      logger.info "DEBUG: After clicking '登録する', Project count is #{Project.count}"
      expect(Project.count).to eq(count_before + 1)

Playwrightドライバの場合

環境変数として DEBUG=1 をつけて DEBUG=1 bundle exec rspec spec/system/projects_spec.rb のようにテストを実行するとPlaywrightドライバはプロトコルログが出るのですが、

I, [2025-09-13T17:59:50.263057 #91850]  INFO -- : DEBUG: Perform clicking '登録する', Project count is 2
SEND>{:id=>63, :guid=>"handle@173ce92e80710d5b12794ab579742e61", :method=>"isEnabled", :params=>{}}
RECV>{"id"=>63, "result"=>{"value"=>true}}
SEND>{:id=>64, :guid=>"handle@173ce92e80710d5b12794ab579742e61", :method=>"querySelectorAll", :params=>{:selector=>"xpath=.//a[./@href][((((./@id = '登録する') or (normalize-space(string(.)) = '登録する')) or (./@title = '登録する')) or .//img[(./@alt = '登録する')])] | .//input[((((./@type = 'submit') or (./@type = 'reset')) or (./@type = 'image')) or (./@type = 'button'))][(((((./@id = '登録する') or (./@name = '登録する')) or (./@value = '登録する')) or (./@title = '登録する')) or (./@id = //label[(normalize-space(string(.)) = '登録する')]/@for))] | .//label[(normalize-space(string(.)) = '登録する')]//*[((./self::input and ((((./@type = 'submit') or (./@typ..."}}
RECV>{"guid"=>"frame@45ef963769024410e78b00e58fffd3e6", "method"=>"__create__", "params"=>{"type"=>"ElementHandle", "initializer"=>{"preview"=>"JSHandle@node"}, "guid"=>"handle@edd5cbd3e407149adce779ba3d5dd7b9"}}
RECV>{"id"=>64, "result"=>{"elements"=>[{"guid"=>"handle@edd5cbd3e407149adce779ba3d5dd7b9"}]}}
SEND>{:id=>65, :guid=>"handle@173ce92e80710d5b12794ab579742e61", :method=>"evaluateExpression", :params=>{:expression=>"(self, other) => self == other", :arg=>{:value=>{:h=>0}, :handles=>[{:guid=>"handle@edd5cbd3e407149adce779ba3d5dd7b9"}]}}}
RECV>{"guid"=>"handle@edd5cbd3e407149adce779ba3d5dd7b9", "method"=>"previewUpdated", "params"=>{"preview"=>"JSHandle@<input value=\"登録する\" type=\"submit\" name=\"commit\" class=\"btn btn-primary\" data-disable-with=\"登録する\"/>"}}
RECV>{"id"=>65, "result"=>{"value"=>{"b"=>false}}}
SEND>{:id=>66, :guid=>"handle@edd5cbd3e407149adce779ba3d5dd7b9", :method=>"isEnabled", :params=>{}}
RECV>{"id"=>66, "result"=>{"value"=>true}}
SEND>{:id=>67, :guid=>"handle@edd5cbd3e407149adce779ba3d5dd7b9", :method=>"evaluateExpression", :params=>{:expression=>"function(el) {\n  if (el.tagName == 'AREA'){\n    const map_name = document.evaluate('./ancestor::map/@name', el, null, XPathResult.STRING_TYPE, null).stringValue;\n    el = document.querySelector(`img[usemap='\#${map_name}']`);\n    if (!el){\n    return false;\n    }\n  }\n  var forced_visible = false;\n  while (el) {\n    const style = window.getComputedStyle(el);\n    if (style.visibility == 'visible')\n      forced_visible = true;\n    if ((style.display == 'none') ||\n        ((style.visibility == 'hi...", :arg=>{:value=>{:v=>"null"}, :handles=>[]}}}
RECV>{"id"=>67, "result"=>{"value"=>{"b"=>true}}}
SEND>{:id=>68, :guid=>"handle@edd5cbd3e407149adce779ba3d5dd7b9", :method=>"evaluateExpression", :params=>{:expression=>"function(el) {\n  const xpath = 'parent::optgroup[@disabled] |                 ancestor::select[@disabled] |                 parent::fieldset[@disabled] |                 ancestor::*[not(self::legend) or preceding-sibling::legend][parent::fieldset[@disabled]]';\n  return el.disabled || document.evaluate(xpath, el, null, XPathResult.BOOLEAN_TYPE, null).booleanValue\n}\n", :arg=>{:value=>{:v=>"null"}, :handles=>[]}}}
RECV>{"id"=>68, "result"=>{"value"=>{"b"=>false}}}
SEND>{:id=>69, :guid=>"handle@edd5cbd3e407149adce779ba3d5dd7b9", :method=>"click", :params=>{:timeout=>16500}}
RECV>{"id"=>69}
D, [2025-09-13T17:59:50.306119 #91850] DEBUG -- :   Project Count (0.1ms)  SELECT COUNT(*) FROM "projects"
I, [2025-09-13T17:59:50.306246 #91850]  INFO -- : DEBUG: After clicking '登録する', Project count is 2
D, [2025-09-13T17:59:50.306515 #91850] DEBUG -- :   Project Count (0.0ms)  SELECT COUNT(*) FROM "projects"
D, [2025-09-13T17:59:50.311906 #91850] DEBUG -- :   TRANSACTION (0.0ms)  SAVEPOINT active_record_1
D, [2025-09-13T17:59:50.312163 #91850] DEBUG -- :   Project Create (0.3ms)  INSERT INTO "projects" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"  [["name", "新しいプロジェクト"], ["created_at", "2025-09-13 08:59:50.311681"], ["updated_at", "2025-09-13 08:59:50.311681"]]

クリックして、間髪いれずに直後にProject.countしています。

Cupriteの場合

プロトコルログを出す方法が標準ではなさそうだったので、モンキーパッチあてます。

spec/support/capybara.rb
require 'capybara/playwright'

module Ferrum
  module ClientPatch
    def send_message(message, async:)
      if async
        Rails.logger.debug "[SEND>>] #{message}"
      else
        Rails.logger.debug "[SEND >] #{message}"
      end
      super.tap do |response|
        Rails.logger.debug "[RECV <] #{response}"
      end
    end
  end
end
Ferrum::Client.prepend(Ferrum::ClientPatch)

Capybara.default_max_wait_time = 15
I, [2025-09-13T18:01:11.125875 #92518]  INFO -- : DEBUG: Perform clicking '登録する', Project count is 2
D, [2025-09-13T18:01:11.126249 #92518] DEBUG -- : [SEND >] {:method=>"DOM.resolveNode", :params=>{:nodeId=>20}, :id=>107, :sessionId=>"428F3EF949C0C81C23334738F6A5931E"}
D, [2025-09-13T18:01:11.126488 #92518] DEBUG -- : [RECV <] {"object"=>{"type"=>"object", "subtype"=>"node", "className"=>"r", "description"=>"turbo-frame#new_project", "objectId"=>"6192739917901216764.2.42"}}

 (中略)

D, [2025-09-13T18:01:11.159144 #92518] DEBUG -- : [SEND >] {:method=>"Input.dispatchMouseEvent", :params=>{:type=>"mouseMoved", :x=>1155.93359375, :y=>267.0, :buttons=>0}, :id=>125, :sessionId=>"428F3EF949C0C81C23334738F6A5931E"}
D, [2025-09-13T18:01:11.168753 #92518] DEBUG -- : [RECV <] {}
D, [2025-09-13T18:01:11.168788 #92518] DEBUG -- : [SEND >] {:method=>"Input.dispatchMouseEvent", :params=>{:x=>1155.93359375, :y=>267, :type=>"mousePressed", :button=>"left", :buttons=>1, :clickCount=>1, :modifiers=>0}, :id=>126, :sessionId=>"428F3EF949C0C81C23334738F6A5931E"}
D, [2025-09-13T18:01:11.169470 #92518] DEBUG -- : [RECV <] {}
D, [2025-09-13T18:01:11.169515 #92518] DEBUG -- : [SEND >] {:method=>"Input.dispatchMouseEvent", :params=>{:x=>1155.93359375, :y=>267, :type=>"mouseReleased", :button=>"left", :buttons=>0, :clickCount=>1, :modifiers=>0}, :id=>127, :sessionId=>"428F3EF949C0C81C23334738F6A5931E"}
D, [2025-09-13T18:01:11.171387 #92518] DEBUG -- : [RECV <] {}
D, [2025-09-13T18:01:11.177628 #92518] DEBUG -- :   TRANSACTION (0.0ms)  SAVEPOINT active_record_1
D, [2025-09-13T18:01:11.177756 #92518] DEBUG -- :   Project Create (0.2ms)  INSERT INTO "projects" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"  [["name", "新しいプロジェクト"], ["created_at", "2025-09-13 09:01:11.177350"], ["updated_at", "2025-09-13 09:01:11.177350"]]
D, [2025-09-13T18:01:11.177843 #92518] DEBUG -- :   TRANSACTION (0.0ms)  RELEASE SAVEPOINT active_record_1
D, [2025-09-13T18:01:11.276879 #92518] DEBUG -- :   Project Count (0.1ms)  SELECT COUNT(*) FROM "projects"
I, [2025-09-13T18:01:11.276927 #92518]  INFO -- : DEBUG: After clicking '登録する', Project count is 3
D, [2025-09-13T18:01:11.277261 #92518] DEBUG -- :   Project Count (0.0ms)  SELECT COUNT(*) FROM "projects"
D, [2025-09-13T18:01:11.278100 #92518] DEBUG -- : [SEND >] {:method=>"Runtime.callFunctionOn", :params=>{:executionContextId=>2, :functionDeclaration=>"function() { return _cuprite.find(arguments[0], arguments[1]) }", :arguments=>[{:value=>:xpath}, {:value=>"/html"}]}, :id=>128, :sessionId=>"428F3EF949C0C81C23334738F6A5931E"}
D, [2025-09-13T18:01:11.278712 #92518] DEBUG -- : [RECV <] {"result"=>{"type"=>"object", "subtype"=>"array", "className"=>"Array", "description"=>"Array(1)", "objectId"=>"6192739917901216764.2.50"}}

click は内部的には mouseDown, mouseUpを連続して行うような実装なのですが、
mouseUpした時刻が D, [2025-09-13T18:01:11.171387
その後、Project.countした時刻が D, [2025-09-13T18:01:11.276879 と、妙にあいています。しかもきれいに0.1秒くらい。 偶然ではなく、何度やってもきれいに0.1秒くらいあいてます

これはなぜだと調べてみたら、Cupriteドライバは内部的に0.1秒待つような実装があるからのようです。
https://github.com/rubycdp/cuprite/blob/bc06cc0600e68d4a90c94b3cbd26665e1dd694e2/lib/capybara/cuprite/page.rb#L15

つまり、サーバーを待っていたわけではなく、ただclickしてから0.1秒待ってreturnしているだけなのでした。

Capybara標準のSeleniumドライバでは

2025-09-13 18:17:42 DEBUG Selenium [:command] -> POST session/1e3a9299efabcd3637f67b6e9566f43e/element/f.18D7AA0866E72DADD85734740B5FF3C4.d.0924CDEF658967AF917771CB79734FAB.e.24/click 
2025-09-13 18:17:42 DEBUG Selenium [:header]    <<<  {"content-length"=>["14"], "content-type"=>["application/json; charset=utf-8"], "cache-control"=>["no-cache"]} 
2025-09-13 18:17:42 DEBUG Selenium [:command] <- {"value":null} 
D, [2025-09-13T18:17:42.527915 #556] DEBUG -- :   Project Count (0.1ms)  SELECT COUNT(*) FROM "projects"
I, [2025-09-13T18:17:42.528055 #556]  INFO -- : DEBUG: After clicking '登録する', Project count is 2
D, [2025-09-13T18:17:42.528317 #556] DEBUG -- :   Project Count (0.0ms)  SELECT COUNT(*) FROM "projects"
D, [2025-09-13T18:17:42.533725 #556] DEBUG -- :   TRANSACTION (0.0ms)  SAVEPOINT active_record_1
D, [2025-09-13T18:17:42.533997 #556] DEBUG -- :   Project Create (0.3ms)  INSERT INTO "projects" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"  [["name", "新しいプロジェクト"], ["created_at", "2025-09-13 09:17:42.533474"], ["updated_at", "2025-09-13 09:17:42.533474"]]
D, [2025-09-13T18:17:42.534143 #556] DEBUG -- :   TRANSACTION (0.0ms)  RELEASE SAVEPOINT active_record_1

やはりclickの直後に謎の0.1秒の隙間はなく、これはCupriteが特殊なだけのようです。

2
あつむあつむ

Yusuke Iwaki さま

はじめまして。
このたびは拙文をお読みくださり、また貴重なコメントをお寄せいただき、誠にありがとうございます。
また、お返事が遅くなりましたこと、大変恐れ入ります。

記事中で私が推測に基づいて結論付けてしまっていた部分につきまして、丁寧にご検証いただき、重ねて御礼申し上げます。とても勉強になりました。
ご指摘を踏まえ、記事の該当箇所および、関連する部分を下記の方向性にて修正させていただく予定です。また、頂戴したコメントについても参照として追記させていただきたく存じます。

(修正前)

Cupriteドライバは、click_on "登録する"操作の後、データベースの更新が完了するまで同期的に待機している

(修正後)

Cupriteドライバには内部的にクリック操作後0.1秒待機するという実装があるため、結果的にexpect{ }.to changeの段階でデータベースの更新が完了している。

このたびは貴重なご教示を賜り、誠にありがとうございました。
今後ともどうぞよろしくお願い申し上げます。

1