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]
endFor RSpec, use capybara and selenium-webdriver:
# Gemfile
group :test do
gem "capybara"
gem "selenium-webdriver"
gem "webdrivers" # auto-downloads ChromeDriver
endConfigure 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
enddriven_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
endAvailable 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
endFor most CI environments, headless Chrome is the right choice. Headed Chrome is useful when debugging a failing test locally.
Core Capybara API
Navigation
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 buttonAssertions
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
endNote 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
endFor 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
endFor 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")
endScreenshots
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
endRails 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-*.htmlOpen 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))
endWithout 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")
endFor 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
endFor 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 queryPerformance 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
endMinimize 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.