Skip to main content
Back to Blog
Tutorial
2026-03-24

Capybara Testing in Ruby: Complete Integration Guide

Master Capybara testing in Ruby with its DSL, visit/fill_in/click methods, finders, matchers, Selenium and Cuprite drivers, RSpec integration, and async handling.

Introduction to Capybara

Capybara is a Ruby library for integration testing web applications. It simulates how a real user interacts with your app by providing an expressive DSL for navigating pages, filling in forms, clicking buttons, and verifying content. Originally created by Jonas Nicklas, Capybara has become the standard tool for acceptance testing in the Ruby ecosystem, particularly with Rails applications.

What makes Capybara stand out is its driver-agnostic architecture. The same test code works across multiple drivers: a fast headless driver for quick feedback, Selenium for cross-browser testing, or modern alternatives like Cuprite for Chrome DevTools Protocol integration. Capybara also handles asynchronous JavaScript gracefully through its built-in waiting mechanism, which automatically retries assertions until they pass or a timeout is reached.

This guide covers everything from Capybara basics through advanced patterns, including driver configuration, RSpec integration, and strategies for handling dynamic content.


Setting Up Capybara

Installation

Add Capybara to your Gemfile:

# Gemfile
group :test do
  gem 'capybara'
  gem 'rspec-rails'
  gem 'selenium-webdriver'
  gem 'cuprite'  # modern headless Chrome driver
end
bundle install

Basic Configuration

Configure Capybara in your spec helper:

# spec/support/capybara.rb
require 'capybara/rspec'
require 'capybara/cuprite'

Capybara.configure do |config|
  config.default_driver = :cuprite
  config.javascript_driver = :cuprite
  config.app_host = 'http://localhost:3000'
  config.default_max_wait_time = 5
  config.default_normalize_ws = true
  config.save_path = 'tmp/capybara'
  config.automatic_label_click = true
end

Capybara.register_driver :cuprite do |app|
  Capybara::Cuprite::Driver.new(
    app,
    window_size: [1440, 900],
    browser_options: {
      'no-sandbox': nil,
      'disable-gpu': nil,
    },
    process_timeout: 15,
    inspector: true,
    headless: !ENV['HEADLESS'].nil?
  )
end

Capybara.register_driver :selenium_chrome do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.add_argument('--headless=new')
  options.add_argument('--window-size=1440,900')
  options.add_argument('--no-sandbox')

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

Rails Integration

For Rails applications, configure the test server:

# spec/rails_helper.rb
require 'capybara/rails'

RSpec.configure do |config|
  config.before(:each, type: :system) do
    driven_by :cuprite
  end
end

The Capybara DSL

Navigation

# Visit a URL
visit '/login'
visit root_path
visit user_profile_path(user)

# Relative to app_host
visit '/products?category=electronics'

Interacting with Forms

# Fill in text fields (by label, name, or id)
fill_in 'Email', with: 'alice@example.com'
fill_in 'user[password]', with: 'SecurePass123!'
fill_in 'search-input', with: 'mechanical keyboard'

# Select from dropdowns
select 'California', from: 'State'
select 'Express Shipping', from: 'shipping_method'

# Check and uncheck boxes
check 'I agree to the terms'
uncheck 'Subscribe to newsletter'

# Choose radio buttons
choose 'Credit Card'
choose 'payment_method_paypal'

# Attach files
attach_file 'Avatar', '/path/to/avatar.png'
attach_file 'Documents', ['/path/to/doc1.pdf', '/path/to/doc2.pdf']

# Submit forms
click_button 'Sign In'
click_button 'Submit Order'

Clicking Elements

# Click links
click_link 'View Profile'
click_link 'Sign Out'

# Click buttons
click_button 'Add to Cart'
click_button 'Confirm'

# Click either links or buttons
click_on 'Continue'
click_on 'Next Step'

Working with Scopes

Scope actions and assertions to specific parts of the page:

# Within a specific section
within '#shopping-cart' do
  expect(page).to have_content 'Wireless Mouse'
  click_button 'Remove'
end

# Within a form
within_form 'checkout-form' do
  fill_in 'Card Number', with: '4111111111111111'
  click_button 'Pay Now'
end

# Within a table row
within(:xpath, "//tr[contains(., 'Alice')]") do
  click_link 'Edit'
end

# Within a fieldset
within_fieldset 'Shipping Address' do
  fill_in 'Street', with: '123 Main St'
  fill_in 'City', with: 'Springfield'
end

Finders

Capybara provides multiple ways to locate elements on the page.

Basic Finders

# Find by CSS selector
find('#user-menu')
find('.product-card', text: 'Wireless Mouse')
find('[data-testid="checkout-button"]')

# Find by XPath
find(:xpath, '//div[@class="alert"]')

# Find links and buttons
find_link('View Details')
find_button('Submit')
find_field('Email')

# Find with additional filters
find('.product', text: 'Mouse', visible: true)
find('input', id: 'email', disabled: false)

Finding Multiple Elements

# Return all matching elements
all('.product-card')
all('tr.order-row')

# Iterate over found elements
all('.product-card').each do |card|
  name = card.find('.product-name').text
  price = card.find('.product-price').text
  puts "Product: #{name} - Price: #{price}"
end

# Count elements
all('.notification').count

# Access specific elements
all('.step')[2].click  # click the third step
all('.product-card').first
all('.product-card').last

Advanced Finding

# Find with exact text matching
find('h1', exact_text: 'Order Confirmation')

# Find ancestors and siblings
find('.error-message').ancestor('.form-group')
find('#current-item').sibling('.next-item')

# Find within found elements
card = find('.product-card', text: 'Mouse')
card.find('.add-to-cart').click
price = card.find('.price').text

Matchers and Assertions

Capybara matchers integrate with RSpec to provide expressive assertions that automatically wait for conditions to be met.

Content Matchers

# Text content
expect(page).to have_content 'Welcome, Alice'
expect(page).to have_text 'Order confirmed'
expect(page).not_to have_content 'Error'

# Exact text matching
expect(page).to have_text('Total: $29.99', exact: true)

# Case-insensitive matching
expect(page).to have_text(/welcome/i)

Element Matchers

# CSS selectors
expect(page).to have_css('.success-banner')
expect(page).to have_css('.product-card', count: 5)
expect(page).to have_css('.product-card', minimum: 1)
expect(page).to have_css('.product-card', maximum: 10)
expect(page).to have_css('.product-card', between: 1..10)

# XPath
expect(page).to have_xpath('//table/tbody/tr')

# Specific element types
expect(page).to have_link('View Profile')
expect(page).to have_link('Dashboard', href: '/dashboard')
expect(page).to have_button('Submit', disabled: false)
expect(page).to have_field('Email', with: 'alice@example.com')
expect(page).to have_field('Password', type: 'password')
expect(page).to have_select('Country', selected: 'United States')
expect(page).to have_checked_field('Remember me')
expect(page).to have_unchecked_field('Newsletter')

Page State Matchers

# Current path
expect(page).to have_current_path('/dashboard')
expect(page).to have_current_path(
  /\/orders\/\d+/
)

# Title
expect(page).to have_title('Dashboard - MyApp')
expect(page).to have_title(/Dashboard/)

# Tables
expect(page).to have_table('orders-table')
expect(page).to have_table(
  'orders-table',
  with_rows: [
    { 'Product' => 'Mouse', 'Qty' => '2' },
    { 'Product' => 'Keyboard', 'Qty' => '1' },
  ]
)

Negated Matchers

# Verify absence (waits for element to disappear)
expect(page).not_to have_content 'Loading...'
expect(page).not_to have_css '.spinner'
expect(page).to have_no_css '.error-message'
expect(page).to have_no_content 'Access denied'

Drivers: Selenium, Cuprite, and Others

Cuprite (Recommended for Modern Projects)

Cuprite uses Chrome DevTools Protocol directly, without requiring ChromeDriver. It is fast, reliable, and supports modern Chrome features:

Capybara.register_driver :cuprite do |app|
  Capybara::Cuprite::Driver.new(
    app,
    window_size: [1440, 900],
    headless: true,
    inspector: true,
    js_errors: true,  # raise on JS errors
    slowmo: ENV['SLOWMO']&.to_f,
    process_timeout: 30,
    timeout: 10,
  )
end

Cuprite-specific features:

# Access browser console logs
page.driver.browser.manage.logs.get(:browser)

# Evaluate JavaScript directly
page.driver.evaluate_script('document.title')

# Network interception
page.driver.intercept_request do |request|
  if request.url.include?('/api/analytics')
    request.abort
  else
    request.continue
  end
end

# Set custom headers
page.driver.headers = {
  'Accept-Language' => 'en-US',
  'X-Custom-Header' => 'test-value',
}

# Basic HTTP authentication
page.driver.basic_authorize('username', 'password')

Selenium WebDriver

For cross-browser testing, use Selenium:

Capybara.register_driver :selenium_firefox do |app|
  options = Selenium::WebDriver::Firefox::Options.new
  options.add_argument('-headless')

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

# Switch drivers per test
RSpec.describe 'Cross-browser tests', type: :system do
  context 'in Chrome' do
    before { driven_by :selenium_chrome }
    it 'works in Chrome' do
      visit '/'
      expect(page).to have_content 'Welcome'
    end
  end

  context 'in Firefox' do
    before { driven_by :selenium_firefox }
    it 'works in Firefox' do
      visit '/'
      expect(page).to have_content 'Welcome'
    end
  end
end

Rack Test (Fast, No JavaScript)

For tests that do not need JavaScript, Rack Test is the fastest option:

Capybara.register_driver :rack_test do |app|
  Capybara::RackTest::Driver.new(app)
end

# Use for simple integration tests
RSpec.describe 'Static pages', type: :feature do
  before { Capybara.current_driver = :rack_test }

  it 'renders the about page' do
    visit '/about'
    expect(page).to have_content 'About Us'
  end
end

RSpec Integration

System Specs (Rails 5.1+)

# spec/system/user_authentication_spec.rb
require 'rails_helper'

RSpec.describe 'User Authentication', type: :system do
  before do
    driven_by :cuprite
  end

  let(:user) do
    User.create!(
      email: 'alice@example.com',
      password: 'SecurePass123!',
      name: 'Alice'
    )
  end

  describe 'login' do
    it 'allows a user to log in with valid credentials' do
      visit new_session_path

      fill_in 'Email', with: user.email
      fill_in 'Password', with: 'SecurePass123!'
      click_button 'Sign In'

      expect(page).to have_current_path(dashboard_path)
      expect(page).to have_content "Welcome, #{user.name}"
    end

    it 'shows an error with invalid credentials' do
      visit new_session_path

      fill_in 'Email', with: user.email
      fill_in 'Password', with: 'wrong-password'
      click_button 'Sign In'

      expect(page).to have_content 'Invalid email or password'
      expect(page).to have_current_path(new_session_path)
    end
  end

  describe 'logout' do
    before do
      login_as(user)  # helper method
      visit dashboard_path
    end

    it 'logs the user out and redirects to login' do
      click_link 'Sign Out'

      expect(page).to have_current_path(new_session_path)
      expect(page).not_to have_content 'Welcome'
    end
  end
end

Feature Specs (Non-Rails or Legacy)

# spec/features/shopping_cart_spec.rb
require 'spec_helper'

RSpec.feature 'Shopping Cart', type: :feature do
  scenario 'adding a product to the cart' do
    visit '/products'

    within('.product-card', text: 'Wireless Mouse') do
      click_button 'Add to Cart'
    end

    visit '/cart'
    expect(page).to have_content 'Wireless Mouse'
    expect(page).to have_content '$29.99'
    expect(page).to have_css('.cart-item', count: 1)
  end

  scenario 'removing a product from the cart' do
    # Setup: add item first
    visit '/products'
    within('.product-card', text: 'Wireless Mouse') do
      click_button 'Add to Cart'
    end

    visit '/cart'
    within('.cart-item', text: 'Wireless Mouse') do
      click_button 'Remove'
    end

    expect(page).to have_content 'Your cart is empty'
    expect(page).to have_no_css('.cart-item')
  end
end

Shared Contexts and Helpers

# spec/support/authentication_helpers.rb
module AuthenticationHelpers
  def login_as(user)
    visit new_session_path
    fill_in 'Email', with: user.email
    fill_in 'Password', with: user.password
    click_button 'Sign In'
    expect(page).to have_current_path(dashboard_path)
  end

  def logout
    click_link 'Sign Out'
    expect(page).to have_current_path(new_session_path)
  end
end

RSpec.configure do |config|
  config.include AuthenticationHelpers, type: :system
  config.include AuthenticationHelpers, type: :feature
end

Handling Asynchronous Content

Capybara's built-in waiting mechanism is one of its most important features. When you use matchers like have_content or finders like find, Capybara automatically retries until the condition is met or the timeout expires.

How Waiting Works

# This will wait up to Capybara.default_max_wait_time
# for the element to appear
expect(page).to have_content 'Order confirmed'

# Find also waits
find('.success-message')

# Custom wait time for specific assertions
expect(page).to have_content('Processing complete',
  wait: 15)

# Explicit wait (use sparingly)
using_wait_time(10) do
  expect(page).to have_css('.loaded')
end

Common Async Patterns

# Wait for loading indicator to disappear
expect(page).to have_no_css('.loading-spinner')

# Wait for AJAX request to complete
click_button 'Save'
expect(page).to have_content 'Saved successfully'

# Wait for page navigation
click_link 'Dashboard'
expect(page).to have_current_path('/dashboard')

# Wait for element count to change
expect(page).to have_css('.notification', count: 3)

Dealing with Animations

# Disable CSS animations in test environment
Capybara.disable_animation = true

# Or in the test setup
before do
  page.execute_script(<<~JS)
    document.body.style.setProperty(
      '--transition-duration', '0s', 'important'
    );
  JS
end

JavaScript Execution

# Execute JavaScript
page.execute_script("window.scrollTo(0, document.body.scrollHeight)")

# Evaluate JavaScript and return result
result = page.evaluate_script("document.title")
count = page.evaluate_script("document.querySelectorAll('.item').length")

# Async script execution
page.evaluate_async_script(<<~JS)
  const callback = arguments[arguments.length - 1];
  fetch('/api/status')
    .then(r => r.json())
    .then(data => callback(data));
JS

Advanced Patterns

Page Objects with Capybara

# spec/support/pages/login_page.rb
class LoginPage
  include Capybara::DSL

  def visit_page
    visit '/login'
    self
  end

  def login(email:, password:)
    fill_in 'Email', with: email
    fill_in 'Password', with: password
    click_button 'Sign In'
  end

  def error_message
    find('.error-message').text
  end

  def logged_in?
    has_css?('.user-menu')
  end
end

# In tests
let(:login_page) { LoginPage.new }

it 'authenticates the user' do
  login_page.visit_page
  login_page.login(
    email: 'alice@example.com',
    password: 'SecurePass123!'
  )
  expect(login_page).to be_logged_in
end

Custom Selectors

# Register custom selectors
Capybara.add_selector(:test_id) do
  css { |id| "[data-testid='#{id}']" }
end

# Usage
find(:test_id, 'checkout-button').click
expect(page).to have_selector(:test_id, 'success-message')

Screenshots on Failure

RSpec.configure do |config|
  config.after(:each, type: :system) do |example|
    if example.exception
      timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
      name = example.description.parameterize
      path = "tmp/screenshots/#{name}-#{timestamp}.png"
      page.save_screenshot(path, full: true)
      puts "Screenshot saved: #{path}"
    end
  end
end

Handling Modals and Alerts

# Accept a JavaScript confirm dialog
accept_confirm do
  click_button 'Delete Account'
end

# Dismiss a confirm dialog
dismiss_confirm do
  click_button 'Delete Account'
end

# Accept an alert
accept_alert do
  click_button 'Trigger Alert'
end

# Accept a prompt and enter text
accept_prompt(with: 'My new name') do
  click_button 'Rename'
end

Working with Windows and Frames

# Switch to a new window
new_window = window_opened_by do
  click_link 'Open in new tab'
end
within_window(new_window) do
  expect(page).to have_content 'New Page Content'
end

# Work within iframes
within_frame('payment-iframe') do
  fill_in 'Card Number', with: '4111111111111111'
  click_button 'Pay'
end

# Frame by index
within_frame(0) do
  expect(page).to have_content 'Frame Content'
end

CI/CD Integration

GitHub Actions

name: System Tests

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

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: password
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

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

      - name: Setup Chrome
        uses: browser-actions/setup-chrome@v1

      - name: Setup database
        run: |
          bin/rails db:create db:schema:load
        env:
          DATABASE_URL: postgres://postgres:password@localhost/test

      - name: Run system tests
        run: bundle exec rspec spec/system --format documentation
        env:
          DATABASE_URL: postgres://postgres:password@localhost/test
          HEADLESS: 'true'

      - name: Upload screenshots
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: screenshots
          path: tmp/screenshots/

Debugging Tips

# Save and open the current page in a browser
save_and_open_page

# Save a screenshot
save_screenshot('debug.png', full: true)

# Print page HTML
puts page.html

# Print current URL
puts current_url

# Print page text (no HTML)
puts page.text

# Use Cuprite's inspector (pauses test, opens DevTools)
page.driver.debug

Conclusion

Capybara provides an elegant, expressive DSL for integration testing Ruby web applications. Its driver-agnostic architecture means you can switch between fast headless execution and full browser testing without changing your test code. The built-in waiting mechanism handles asynchronous content gracefully, eliminating most timing-related flakiness.

Combined with RSpec's readable syntax, page object patterns for maintainability, and modern drivers like Cuprite for speed and reliability, Capybara remains the gold standard for acceptance testing in the Ruby ecosystem. Whether you are testing a Rails application or any Rack-based web app, Capybara's approach of simulating real user interactions ensures your tests validate what actually matters: that your application works correctly from the user's perspective.

Capybara Testing in Ruby: Complete Integration Guide | QASkills.sh