Advanced Capybara: JS Drivers, Downloads, Iframes, and Sessions

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
end

For 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
end

Custom 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)
end

Iframes

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"
end

Finding 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
end

Nested 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
end

Stripe 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
end

In 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"]
end

Reading 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
end

Use 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 #")
end

Headless 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
end

Multiple 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
end

Sessions 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!
end

Or configure this globally in your test helper:

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  teardown do
    Capybara.reset_sessions!
  end
end

Custom 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}" }
end

Use 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"
end

Handling 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 = false

Stale 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").click

Debugging 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
end

Run with PAUSE_ON_FAILURE=1 bin/rails test:system to inspect the browser state after failure.

puts page.html      # full HTML
puts page.body      # body text
puts page.title     # page title

Annotate Screenshots

take_screenshot   # saves with test name
# Check tmp/screenshots/ for all screenshots

Rails 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.

Read more