Advanced Capybara: JS Drivers, Downloads, Iframes, and Sessions
Basic Capybara usage covers maybe 70% of test scenarios. The remaining 30% involves complex browser behaviors: embedded iframes, file downloads, multiple authenticated sessions, custom wait conditions, and browser-specific configuration. This guide covers those advanced patterns.
JavaScript Driver Configuration
Capybara supports multiple JavaScript drivers. Choosing the right one affects test speed, debugging ease, and feature support.
Selenium with Chrome
The standard choice for most Rails applications:
# test/application_system_test_case.rb
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 900] do |options|
options.add_argument("--disable-gpu")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage") # important in Docker
options.add_argument("--disable-extensions")
options.add_argument("--disable-popup-blocking")
end
endFor Docker environments (GitHub Actions, CircleCI), --no-sandbox and --disable-dev-shm-usage are required.
Switching Drivers Per Test
Some tests need a headed browser for debugging; others need a lightweight driver for speed:
# Use headless for most tests
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome
end
# Override for specific tests
class SlowAnimationTest < ApplicationSystemTestCase
driven_by :selenium, using: :chrome # headed, for visual debugging
test "animation completes" do
# ...
end
endCustom Capybara Configuration
# test/support/capybara.rb or test/test_helper.rb
Capybara.configure do |config|
config.default_max_wait_time = 5 # seconds to wait for elements
config.default_normalize_ws = true # normalize whitespace in text matching
config.ignore_hidden_elements = true # skip invisible elements in finders
config.save_path = "tmp/screenshots"
config.reuse_server = true # reuse server across tests (faster)
endIframes
Iframes require explicit scope switching in Capybara. By default, Capybara cannot see elements inside an iframe.
Switching into an Iframe
test "interacts with embedded payment form" do
visit checkout_path
# Switch into the iframe by CSS selector
within_frame("iframe.stripe-card") do
fill_in "Card number", with: "4242 4242 4242 4242"
fill_in "Expiry", with: "12/26"
fill_in "CVC", with: "123"
end
click_button "Pay $99.00"
assert_text "Payment successful"
endFinding Iframes by Index or Name
# By index (0-based)
within_frame(0) do
# interact with first iframe
end
# By name attribute
within_frame("payment-frame") do
# <iframe name="payment-frame">
end
# By id attribute
within_frame(find("iframe#embed-id")) do
# by Capybara element
endNested Iframes
For iframes within iframes, nest within_frame calls:
within_frame("outer-frame") do
within_frame("inner-frame") do
fill_in "Input", with: "value"
end
endStripe and Other Third-Party Iframes
Stripe embeds multiple iframes (one per field). Each must be scoped separately:
test "completes Stripe checkout" do
visit checkout_path
within_frame(find("iframe[name*='__privateStripeFrame']")) do
fill_in "Card number", with: "4242424242424242"
end
# Stripe splits fields into separate iframes
all("iframe[name*='__privateStripeFrame']").each_with_index do |iframe, i|
within_frame(iframe) do
case i
when 0 then fill_in "Card number", with: "4242424242424242"
when 1 then fill_in "MM / YY", with: "12/30"
when 2 then fill_in "CVC", with: "123"
end
end
end
endIn practice, use Stripe.js test tokens (tok_visa) and bypass the iframe entirely for most test scenarios.
File Downloads
Testing file downloads requires checking that the browser received the correct file. There are two approaches: intercepting headers or reading the downloaded file from disk.
Checking Response Headers
The simplest approach for format testing:
test "exports CSV report" do
sign_in_as users(:admin)
visit reports_path
click_link "Export CSV"
# For Rack::Test or ActionDispatch integration tests
assert_equal "text/csv", response.content_type
assert response.headers["Content-Disposition"].include?("attachment")
assert_match /report/, response.headers["Content-Disposition"]
endReading Downloaded Files (Selenium)
For system tests, configure a download directory and read from it:
# test/application_system_test_case.rb
DOWNLOAD_DIR = Rails.root.join("tmp/test_downloads").to_s
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome do |options|
prefs = {
"download.default_directory" => DOWNLOAD_DIR,
"download.prompt_for_download" => false,
"download.directory_upgrade" => true,
"safebrowsing.enabled" => false
}
options.add_preference(:download, prefs)
end
setup do
FileUtils.mkdir_p(DOWNLOAD_DIR)
FileUtils.rm_rf(Dir.glob("#{DOWNLOAD_DIR}/*")) # clean before test
end
endUse it in tests:
test "downloads invoice PDF" do
sign_in_as users(:alice)
visit invoice_path(invoices(:recent))
click_link "Download PDF"
# Wait for download to complete
Timeout.timeout(10) do
sleep 0.1 until Dir.glob("#{DOWNLOAD_DIR}/*.pdf").any?
end
pdf_file = Dir.glob("#{DOWNLOAD_DIR}/*.pdf").first
assert File.exist?(pdf_file)
assert_operator File.size(pdf_file), :>, 1000 # non-empty PDF
# Optionally check content with pdf-reader gem
# reader = PDF::Reader.new(pdf_file)
# assert reader.pages.first.text.include?("Invoice #")
endHeadless Chrome Download Workaround
In some Chrome versions, headless mode doesn't respect download preferences. Use the Chrome DevTools Protocol to enable downloads:
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome do |options|
options.add_argument("--headless=new")
end
setup do
# Enable downloads in headless mode via CDP
page.driver.browser.download_path = DOWNLOAD_DIR
end
endMultiple Sessions
Capybara supports multiple concurrent browser sessions. This is essential for testing real-time features, WebSocket interactions, or multi-user scenarios.
Basic Multi-Session Test
test "user receives notification when admin posts" do
using_session(:admin) do
sign_in_as users(:admin)
visit admin_dashboard_path
end
using_session(:subscriber) do
sign_in_as users(:alice)
visit notifications_path
end
# Admin posts announcement
using_session(:admin) do
click_button "Post Announcement"
fill_in "Message", with: "Maintenance at 3pm"
click_button "Send"
end
# Subscriber sees notification without refresh (WebSocket)
using_session(:subscriber) do
assert_text "Maintenance at 3pm"
end
endSessions are completely isolated — separate cookies, storage, and browser windows.
Cleanup
Sessions persist across tests unless explicitly closed. Reset between tests:
teardown do
Capybara.reset_sessions!
endOr configure this globally in your test helper:
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
teardown do
Capybara.reset_sessions!
end
endCustom Selectors
Capybara's built-in selectors (css, xpath, text, label) cover most cases. Define custom selectors for application-specific patterns:
# test/support/capybara_selectors.rb
Capybara.add_selector(:data_test) do
css { |value| "[data-test='#{value}']" }
end
Capybara.add_selector(:flash) do
css { |type| ".flash.flash-#{type}" }
endUse custom selectors just like built-in ones:
find(:data_test, "submit-order")
assert_selector :data_test, "product-card", count: 3
assert_selector :flash, :notice, text: "Saved successfully"data-test attributes are a good practice — they decouple test selectors from CSS class changes and make intent explicit.
Custom Wait Conditions
Capybara's default waiting retries assertions until they pass or the timeout expires. Sometimes you need custom waiting logic:
# Wait for a specific DOM state
def wait_for_turbo
assert_no_selector ".turbo-progress-bar"
end
def wait_for_ajax
Timeout.timeout(Capybara.default_max_wait_time) do
loop until page.evaluate_script("jQuery.active").zero?
end
end
# Wait for text with custom timeout
using_wait_time(15) do
assert_text "Report generated"
endHandling Flakiness
System tests are inherently more flaky than unit tests. Common causes and fixes:
Timing Issues
# Bad: may click before element is interactive
click_button "Submit"
assert_text "Saved"
# Better: assert first render, then interact
assert_selector "button", text: "Submit" # wait for it to appear
click_button "Submit"
assert_text "Saved"Animation Interference
Disable CSS animations in test mode:
/* app/assets/stylesheets/test.css */
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}Load it only in the test environment:
# config/environments/test.rb
config.action_view.stylesheet_pack_tag_include_node_modules = falseStale Element References
If an element disappears and reappears (React re-render, Turbo Frame reload), find it again:
# Bad: caches stale reference
card = find(".product-card")
click_button "Reload"
card.click # StaleElementReferenceError
# Good: re-find after DOM update
click_button "Reload"
find(".product-card").clickDebugging Tricks
Pause on Failure
# In teardown
teardown do
if failures.any? && ENV["PAUSE_ON_FAILURE"]
puts "Test failed — press Enter to continue"
$stdin.gets
end
endRun with PAUSE_ON_FAILURE=1 bin/rails test:system to inspect the browser state after failure.
Print Page Source
puts page.html # full HTML
puts page.body # body text
puts page.title # page titleAnnotate Screenshots
take_screenshot # saves with test name
# Check tmp/screenshots/ for all screenshotsRails automatically names screenshots by test class and method, making it easy to correlate failures with screenshots.
Continuous Monitoring Beyond Tests
System tests run pre-deploy. But users experience your application post-deploy, 24/7. HelpMeTest runs your critical user flows continuously against production — the same interactions you'd write in Capybara, but running around the clock with instant alerts when something breaks.
Summary
Advanced Capybara scenarios require understanding when to switch driver modes, how to scope into iframes, how to configure and read file downloads, and how to run multi-user sessions. Custom selectors using data-test attributes make tests resilient to CSS changes. Custom wait conditions handle async operations that default waiting can't cover.
Master these patterns and your system test suite will handle the complexity of real applications — JavaScript-heavy UIs, third-party integrations, and multi-user workflows.