Rails System Tests with Capybara: End-to-End Testing

Rails System Tests with Capybara: End-to-End Testing

System tests are the closest thing to a real user clicking through your application. They drive a browser, fill in forms, click buttons, and assert what the user sees. Rails has built-in support for system tests via Capybara, and this guide covers how to use them effectively.

What System Tests Are (and Aren't)

System tests run your entire Rails application — server, database, browser, JavaScript. This makes them the most realistic type of test, and also the slowest. A model test runs in milliseconds. A system test that opens a browser page can take a second or more.

Use system tests for:

  • Critical user journeys (signup, checkout, core workflows)
  • JavaScript-heavy interactions that request specs can't cover
  • Multi-step flows where intermediate state matters

Don't use system tests for:

  • Validations (use model specs)
  • API responses (use request specs)
  • Every page of the application (combinatorial explosion of slow tests)

Setup

Rails generates system test infrastructure automatically. The relevant files:

# test/application_system_test_case.rb
require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :chrome, screen_size: [1400, 1400]
end

For RSpec, use capybara and selenium-webdriver:

# Gemfile
group :test do
  gem "capybara"
  gem "selenium-webdriver"
  gem "webdrivers"  # auto-downloads ChromeDriver
end

Configure in spec/support/capybara.rb:

require "capybara/rails"
require "capybara/rspec"

Capybara.configure do |config|
  config.default_max_wait_time = 5   # seconds to wait for async elements
  config.server = :puma, { Silent: true }
end

RSpec.configure do |config|
  config.before(:each, type: :system) do
    driven_by :selenium, using: :headless_chrome, screen_size: [1400, 900]
  end
end

driven_by

driven_by sets the browser driver for a test or test class:

class UserRegistrationTest < ApplicationSystemTestCase
  driven_by :selenium, using: :chrome   # real Chrome window

  test "user can register" do
    # ...
  end
end

Available drivers:

  • :rack_test — no JavaScript, fastest, no real browser
  • :selenium, using: :chrome — real Chrome
  • :selenium, using: :headless_chrome — Chrome without a window (for CI)
  • :selenium, using: :firefox — Firefox

In RSpec, set it per example or group:

RSpec.describe "Registration", type: :system do
  before do
    driven_by :selenium, using: :headless_chrome
  end

  it "allows new users to sign up" do
    # ...
  end
end

For most CI environments, headless Chrome is the right choice. Headed Chrome is useful when debugging a failing test locally.

Core Capybara API

visit root_path
visit "https://example.com/articles"
visit article_path(article)

Finding Elements

# find by text content
find("h1", text: "Welcome")

# find by CSS selector
find(".submit-button")
find("#user-email")

# find by ARIA role (recommended for accessibility)
find("button", text: "Submit")
find(:field, "Email")
find(:link, "Sign in")

Interacting

click_on "Sign up"           # button or link with this text
click_button "Submit"
click_link "Forgot password"

fill_in "Email", with: "alice@example.com"
fill_in "Password", with: "secret123"

select "Administrator", from: "Role"
check "Accept terms"
uncheck "Newsletter"
choose "Monthly"             # radio button

Assertions

expect(page).to have_text("Welcome, Alice")
expect(page).to have_content("Order confirmed")
expect(page).to have_selector("table.orders")
expect(page).to have_link("Sign out")
expect(page).to have_button("Submit")
expect(page).to have_field("Email", with: "alice@example.com")
expect(page).to have_current_path(dashboard_path)

expect(page).not_to have_text("Error")
expect(page).not_to have_selector(".alert-danger")

Capybara waits automatically. When you call have_text("Welcome"), Capybara retries for up to default_max_wait_time seconds. This handles async page updates without manual sleep calls.

A Complete System Test

RSpec.describe "Article management", type: :system do
  let(:editor) { create(:user, :editor) }

  before do
    driven_by :selenium, using: :headless_chrome
    sign_in_as editor
  end

  it "allows editors to publish an article" do
    visit new_article_path

    fill_in "Title", with: "My First Post"
    fill_in "Body", with: "Content goes here."
    select "Technology", from: "Category"
    click_on "Save Draft"

    expect(page).to have_text("Draft saved")
    expect(page).to have_current_path(article_path(Article.last))

    click_on "Publish"

    expect(page).to have_text("Article published")
    expect(page).to have_selector(".status-badge", text: "Published")
    expect(Article.last.status).to eq("published")
  end
end

Note that we assert both the UI state and the database state. Both matter — the UI might show "published" while the database still has "draft" due to a bug in the controller.

JavaScript Interactions

Rack::Test (the default non-JS driver) can't execute JavaScript. Use Selenium for any test that requires JS:

RSpec.describe "Dynamic search", type: :system do
  before { driven_by :selenium, using: :headless_chrome }

  it "filters results as the user types" do
    create(:product, name: "Wireless Headphones")
    create(:product, name: "Wired Keyboard")

    visit products_path
    fill_in "Search", with: "Wireless"

    # Capybara waits for the async update
    expect(page).to have_text("Wireless Headphones")
    expect(page).not_to have_text("Wired Keyboard")
  end
end

For interactions that trigger modals or confirmation dialogs:

it "asks for confirmation before deleting" do
  article = create(:article)
  visit article_path(article)

  # Accept the browser confirm dialog
  accept_confirm do
    click_on "Delete"
  end

  expect(page).to have_text("Article deleted")
  expect(Article.find_by(id: article.id)).to be_nil
end

For custom JavaScript modals:

it "confirms via custom modal" do
  visit articles_path
  click_on "Delete"

  # Wait for modal to appear
  within(".confirmation-modal") do
    expect(page).to have_text("Are you sure?")
    click_on "Yes, delete it"
  end

  expect(page).not_to have_selector(".confirmation-modal")
  expect(page).to have_text("Deleted successfully")
end

Screenshots

Capybara can take screenshots, which is invaluable for debugging CI failures:

# Take a screenshot at a specific point
take_screenshot

# RSpec: auto-screenshot on failure
RSpec.configure do |config|
  config.after(:each, type: :system) do |example|
    if example.exception
      take_screenshot
    end
  end
end

Rails system tests save screenshots to tmp/screenshots/. In RSpec with capybara, screenshots go to tmp/capybara/.

For verbose debugging, you can also save the page HTML:

save_page   # saves to tmp/capybara/capybara-*.html

Open the saved HTML in a browser to inspect the DOM state when a test failed.

within

within scopes Capybara actions to a part of the page:

it "shows different content for each order" do
  visit orders_path

  within("#order-#{order1.id}") do
    expect(page).to have_text("Pending")
    click_on "View details"
  end

  expect(page).to have_current_path(order_path(order1))
end

Without within, click_on "View details" would click the first matching element on the page. Use within anytime you have repeated components.

Filling in Rich Text Editors

Trix (Action Text) and other rich text editors don't use regular input fields. Access the contenteditable element directly:

it "saves rich text content" do
  visit new_article_path

  # Trix editor
  find(".trix-content").click
  find(".trix-content").send_keys("This is my *formatted* content")

  click_on "Save"

  expect(page).to have_selector(".article-body", text: "This is my")
end

For CodeMirror or other JS editors:

find(".CodeMirror").click
find(".CodeMirror textarea", visible: false).send_keys("const x = 1;")

Parallel System Tests

Rails 6+ supports parallel system tests. Each worker gets its own database and browser instance:

# test/application_system_test_case.rb
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  parallelize(workers: 4)
  driven_by :selenium, using: :headless_chrome
end

For RSpec, use parallel_tests:

bundle exec parallel_rspec spec/system/

Parallel system tests require database isolation. Each worker uses a separate database (myapp_test_1, myapp_test_2, etc.). Rails handles this automatically with parallelize.

One caveat: browser instances can be resource-intensive. 4 workers = 4 Chrome processes. On a low-memory CI machine, this can cause flakiness. Tune worker count based on available RAM.

Dealing with Flaky Tests

System tests are inherently more flaky than unit tests. Common causes:

Timing issues: An element isn't present yet. Solution: use Capybara's built-in waiting instead of sleep.

# Bad
click_on "Submit"
sleep 2
expect(page).to have_text("Success")

# Good — Capybara waits automatically
click_on "Submit"
expect(page).to have_text("Success")

Shared database state: Tests interfering with each other. Solution: use DatabaseCleaner with truncation for system tests (not transactions, since system tests run in a separate thread).

Animation and transitions: CSS transitions can prevent clicks from registering. Solution: disable animations in test environment.

/* app/assets/stylesheets/test.css (only loaded in test env) */
*, *::before, *::after {
  transition-duration: 0ms !important;
  animation-duration: 0ms !important;
}

Stale elements: A reference to a DOM element becomes invalid after a page update. Solution: don't store element references; re-query each time.

# Bad
button = find(".submit")
do_something_that_rerenders_the_dom
button.click   # StaleElementReferenceError

# Good
do_something_that_rerenders_the_dom
find(".submit").click   # fresh query

Performance Tips

System tests are slow. A few habits that help:

Share authentication state: Log in once and reuse. Calling sign_in via the UI for every test is expensive. Use a helper that directly sets the session cookie:

def sign_in_as(user)
  # Use the faster path: set session directly instead of UI login
  page.set_rack_session(user_id: user.id)
  visit root_path
end

Minimize database records: Create only what each test needs. Don't use before(:all) with system tests — it creates shared state between tests.

Use headless mode in CI: Always. Headed Chrome is for local debugging only.

Profile slow tests: Use --profile 10 to find the 10 slowest specs. Often a handful of tests account for most of the time.

System tests verify that your Rails app actually works for users. Pair them with HelpMeTest for continuous end-to-end monitoring in production — where real users, real network conditions, and real third-party services introduce failure modes your test suite can't anticipate.

Read more

Start now free