Capybara System Tests in Rails: Drivers, Async JS, and Production-Grade Patterns

Capybara System Tests in Rails: Drivers, Async JS, and Production-Grade Patterns

Capybara system tests exercise your Rails app through a real browser, catching integration bugs that unit and controller tests miss entirely. This post covers driver selection (Selenium vs Cuprite), async JavaScript patterns, screenshot-on-failure setup, and scoping techniques for complex UIs.

System tests are the most expensive tests to write and maintain—and the most valuable. A passing model spec doesn't tell you whether clicking "Submit" on a multi-step form actually works end-to-end. Capybara does.

This post skips the introductory material and focuses on the decisions and patterns that matter in production-grade Rails apps: which driver to use and when, how to deal reliably with async JS, how to capture screenshots automatically when tests fail, and how to write maintainable selectors that survive UI refactors.

Choosing a Driver

Capybara uses a driver to control a real or headless browser. Rails ships with Selenium/Chrome by default. Cuprite (backed by Ferrum/CDP) is a serious alternative worth evaluating.

Selenium with Chrome

The Rails default. Mature, well-documented, and handles edge cases in complex apps:

# Gemfile
gem "selenium-webdriver"
gem "webdrivers"  # manages chromedriver automatically

# spec/support/capybara.rb
Capybara.register_driver :chrome_headless do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.add_argument("--headless=new")
  options.add_argument("--no-sandbox")
  options.add_argument("--disable-dev-shm-usage")
  options.add_argument("--window-size=1400,900")
  options.add_argument("--disable-gpu")

  Capybara::Selenium::Driver.new(
    app,
    browser: :chrome,
    options: options
  )
end

Capybara.javascript_driver = :chrome_headless
Capybara.default_max_wait_time = 5

To run tests with a visible browser for debugging:

Capybara.register_driver :chrome_visible do |app|
  Capybara::Selenium::Driver.new(app, browser: :chrome)
end

# Run with: DRIVER=chrome_visible bundle exec rspec
driver = ENV["DRIVER"]&.to_sym || :chrome_headless
Capybara.javascript_driver = driver

Cuprite (Ferrum/CDP)

Cuprite drives Chrome directly via the Chrome DevTools Protocol, skipping WebDriver entirely. It's faster, has no chromedriver version mismatch problems, and gives you lower-level access to network events and console logs:

# Gemfile
gem "cuprite"

# spec/support/capybara.rb
require "capybara/cuprite"

Capybara.register_driver :cuprite do |app|
  Capybara::Cuprite::Driver.new(
    app,
    window_size: [1400, 900],
    browser_options: {
      "no-sandbox": nil,
      "disable-dev-shm-usage": nil
    },
    process_timeout: 15,
    inspector: ENV["INSPECTOR"].present?,
    headless: !ENV["HEADLESS"].eql?("false")
  )
end

Capybara.javascript_driver = :cuprite

Cuprite exposes the browser object for advanced operations:

# Intercept and stub network requests (no VCR needed)
page.driver.browser.network.intercept do |request|
  if request.url.include?("stripe.com")
    request.respond(body: { id: "tok_test" }.to_json, headers: { "Content-Type" => "application/json" })
  else
    request.continue
  end
end

When to Use Each

Scenario Recommendation
Standard Rails app Cuprite (faster, no driver version issues)
Complex drag-and-drop or file inputs Selenium (more mature action support)
Need to inspect JS console errors Cuprite (direct CDP access)
Legacy compatibility concerns Selenium
CI with Docker Either (both work headless)

Handling Async JavaScript

The most common source of flaky system tests is JavaScript that updates the DOM asynchronously. Capybara's find, have_text, and have_selector matchers retry automatically within default_max_wait_time—but only if you use them correctly.

What Waits and What Doesn't

# WRONG: has_selector? bypasses retry—returns immediately
expect(page.has_selector?(".flash")).to be(true)

# RIGHT: have_selector retries until timeout
expect(page).to have_selector(".flash")

# WRONG: checking presence before action completes
click_button "Save"
expect(page.find(".result").text).to eq("Done")  # may find stale element

# RIGHT: assert the expected text directly—Capybara waits for it
click_button "Save"
expect(page).to have_text("Done")

Waiting for AJAX Requests

When a button triggers an AJAX call, you need to wait for the network activity to settle before asserting:

# For Cuprite, wait for network idle
def wait_for_network_idle
  page.driver.browser.network.wait_for_idle(timeout: 5)
end

# For Selenium, a common approach using Capybara's built-in waiter
def wait_for_ajax
  Timeout.timeout(Capybara.default_max_wait_time) do
    loop until page.evaluate_script("typeof jQuery !== 'undefined' && jQuery.active === 0")
  end
end

# Better: assert the outcome, not the network state
click_button "Submit Order"
expect(page).to have_text("Order #")  # Capybara waits for this to appear

Turbo/Hotwire Patterns

With Turbo (Hotwire), form submissions replace DOM sections. Assert on the content that should appear after the Turbo stream update:

scenario "user creates a comment" do
  visit post_path(post)
  fill_in "Comment", with: "Great article!"
  click_button "Post Comment"

  # Turbo replaces the comments section asynchronously
  within "#comments" do
    expect(page).to have_text("Great article!")
  end

  # Ensure no full page reload occurred (comment count updated in-place)
  expect(page).to have_css("#comment-count", text: "1")
end

Waiting for Specific Conditions

For custom async behavior that doesn't map to DOM text:

def wait_for_animation
  # Wait until CSS transition class is removed
  expect(page).to have_no_css(".is-animating", wait: 3)
end

def wait_for_turbo_navigation
  # Wait for Turbo progress bar to disappear
  expect(page).to have_no_css(".turbo-progress-bar", wait: 5)
end

def wait_for_upload_complete
  expect(page).to have_css("[data-upload-status='complete']", wait: 30)
end

Screenshot on Failure

Nothing speeds up debugging a flaky system test like seeing a screenshot of what the browser looked like when it failed.

Automatic Screenshots with capybara-screenshot

# Gemfile
gem "capybara-screenshot", group: :test

# spec/support/capybara_screenshot.rb
require "capybara-screenshot/rspec"

Capybara::Screenshot.autosave_on_failure = true
Capybara::Screenshot.prune_strategy = :keep_last_run
Capybara::Screenshot.s3_configuration = {
  bucket_name: ENV["S3_SCREENSHOTS_BUCKET"],
  s3_client_credentials: { region: "us-east-1" }
} if ENV["S3_SCREENSHOTS_BUCKET"].present?

Screenshots land in tmp/capybara/ by default. In CI, upload the directory as an artifact:

# .github/workflows/test.yml
- name: Upload screenshots
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: capybara-screenshots
    path: tmp/capybara/

Manual Screenshot with Annotation

For capturing state at specific test steps (useful for debugging without waiting for a failure):

def screenshot_step(name)
  path = Rails.root.join("tmp/capybara/step_#{name}_#{Time.now.to_i}.png")
  page.save_screenshot(path)
  puts "Screenshot: #{path}"
end

scenario "multi-step checkout" do
  add_item_to_cart
  screenshot_step("cart_filled")

  proceed_to_checkout
  screenshot_step("checkout_form")

  fill_in_payment_details
  click_button "Complete Purchase"

  screenshot_step("after_submit")
  expect(page).to have_text("Order confirmed")
end

Scoping and Selectors

Brittle selectors are the primary reason system tests become expensive to maintain. Use scoping and data attributes to decouple tests from CSS class names.

Prefer data-testid Over CSS Classes

CSS classes change with redesigns. data-testid attributes are stable contracts:

<!-- In your view -->
<div data-testid="user-profile-card">
  <h2 data-testid="user-name"><%= user.name %></h2>
  <button data-testid="follow-button">Follow</button>
</div>
# In tests
within "[data-testid='user-profile-card']" do
  expect(page).to have_css("[data-testid='user-name']", text: user.name)
  click_on_testid("follow-button")
end

# Helper
def click_on_testid(id)
  find("[data-testid='#{id}']").click
end

within Scoping

Always scope assertions to the relevant section. This prevents false positives when the same text appears elsewhere on the page:

scenario "shows different prices in cart and order summary" do
  visit cart_path

  within "#cart-items" do
    expect(page).to have_text("$29.99")
  end

  within "#order-summary" do
    expect(page).to have_text("$29.99")  # Same text, different context
    expect(page).to have_text("Total: $35.98")
  end
end

Selecting by Label (Accessibility-Friendly)

Prefer selectors that match what a user actually sees and what screen readers use:

# GOOD: matches by label—works with any input type
fill_in "Email address", with: "user@example.com"
select "Canada", from: "Country"
check "I agree to the terms"
attach_file "Profile photo", Rails.root.join("spec/fixtures/avatar.jpg")

# Avoid selecting by placeholder—it changes, labels don't
fill_in placeholder: "Enter email"  # fragile

Database Cleaner with System Tests

System tests run in a separate thread from the test suite. Without configuration, factory-created records in the test won't be visible to the browser request. Use truncation strategy for JS-enabled specs:

# spec/support/database_cleaner.rb
RSpec.configure do |config|
  config.before(:suite) do
    DatabaseCleaner.clean_with(:truncation)
  end

  config.before(:each) do |example|
    if example.metadata[:js] || example.metadata[:type] == :system
      DatabaseCleaner.strategy = :truncation
    else
      DatabaseCleaner.strategy = :transaction
    end
    DatabaseCleaner.start
  end

  config.after(:each) do
    DatabaseCleaner.clean
  end
end

Alternatively, configure your app to share the database connection between threads (requires Rails 5.1+):

# spec/rails_helper.rb
config.use_transactional_fixtures = false

# Allow threads to share the database connection
class ActiveRecord::Base
  mattr_accessor :shared_connection
  @@shared_connection = nil

  def self.connection
    @@shared_connection || retrieve_connection
  end
end

ActiveRecord::Base.shared_connection = ActiveRecord::Base.connection

A Complete System Test Example

# spec/system/article_publishing_spec.rb
require "rails_helper"

RSpec.describe "Article publishing workflow", type: :system do
  let(:editor) { create(:user, :editor) }
  let(:draft)  { create(:article, :draft, author: editor) }

  before do
    driven_by(:cuprite)
    sign_in_as(editor)
  end

  scenario "editor publishes a draft article", :js do
    visit edit_article_path(draft)

    within "[data-testid='article-form']" do
      fill_in "Title", with: "Updated Title"
      select "Published", from: "Status"
    end

    click_button "Save Changes"

    expect(page).to have_text("Article saved")

    within "[data-testid='article-header']" do
      expect(page).to have_text("Updated Title")
      expect(page).to have_css("[data-testid='published-badge']")
    end

    # Verify it's accessible without auth
    using_session("anonymous") do
      visit article_path(draft)
      expect(page).to have_text("Updated Title")
    end
  end

  scenario "editor sees validation errors inline", :js do
    visit edit_article_path(draft)
    fill_in "Title", with: ""
    click_button "Save Changes"

    within "[data-testid='title-field']" do
      expect(page).to have_css(".field-error", text: "can't be blank")
    end

    expect(page).to have_current_path(edit_article_path(draft))
  end
end

Key Takeaways

  • Use Cuprite for speed and CDP access; fall back to Selenium for complex drag-and-drop or legacy compatibility
  • Always assert on DOM outcomes, not on network or animation state—let Capybara's retry mechanism do the work
  • Set HEADLESS=false for local debugging and INSPECTOR=true with Cuprite to open DevTools
  • Use data-testid attributes as stable test contracts rather than CSS classes
  • Configure screenshot-on-failure from day one and upload artifacts in CI—flaky tests are 10x faster to debug with a screenshot
  • Scope assertions with within to avoid false positives and make test intent explicit

Read more

Start now free