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 = 5To 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 = driverCuprite (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 = :cupriteCuprite 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
endWhen 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 appearTurbo/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")
endWaiting 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)
endScreenshot 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")
endScoping 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
endwithin 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
endSelecting 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" # fragileDatabase 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
endAlternatively, 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.connectionA 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
endKey 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=falsefor local debugging andINSPECTOR=truewith Cuprite to open DevTools - Use
data-testidattributes 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
withinto avoid false positives and make test intent explicit