Rails System Tests: Capybara and Selenium End-to-End Testing
Rails system tests let you test your application through a real browser, exercising JavaScript, authentication flows, and multi-step user interactions that unit tests cannot reach. They use Capybara as the test DSL and Selenium WebDriver to control Chrome or Firefox.
This guide covers writing effective system tests, handling JavaScript interactions, testing authentication, configuring CI, and structuring tests for long-term maintainability.
How System Tests Work
Rails 5.1 integrated system tests directly into the framework. When you run bin/rails test:system, Rails:
- Boots the Rails app in test mode
- Starts a Capybara server on a random port
- Launches a Selenium-controlled browser
- Runs each test in a database transaction that rolls back after completion
The key difference from integration tests: system tests drive a real browser, so JavaScript executes, CSS animations play, and AJAX requests complete — or fail — just as they would for real users.
Setup
Rails generates the configuration automatically. Check test/application_system_test_case.rb:
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
endFor CI and local development, headless Chrome is the standard choice. To use headed Chrome for debugging:
driven_by :selenium, using: :chrome, screen_size: [1400, 1400]Install the required gems:
# Gemfile
group :test do
gem "capybara"
gem "selenium-webdriver"
gem "webdrivers" # auto-manages ChromeDriver/GeckoDriver versions
endWriting Your First System Test
Generate a system test:
bin/rails generate system_test usersThis creates test/system/users_test.rb:
require "application_system_test_case"
class UsersTest < ApplicationSystemTestCase
test "visiting the index" do
visit users_url
assert_selector "h1", text: "Users"
end
endA more complete example testing user registration:
class RegistrationTest < ApplicationSystemTestCase
test "user can register with valid credentials" do
visit new_user_registration_path
fill_in "Email", with: "alice@example.com"
fill_in "Password", with: "SecurePass123!"
fill_in "Password confirmation", with: "SecurePass123!"
click_button "Create Account"
assert_current_path dashboard_path
assert_selector ".flash-notice", text: "Welcome! You're now signed in."
assert_text "alice@example.com"
end
test "shows errors for invalid registration" do
visit new_user_registration_path
fill_in "Email", with: "not-an-email"
click_button "Create Account"
assert_selector ".error", text: "Email is invalid"
end
endCapybara DSL Essentials
Navigation
visit root_path
visit "https://example.com/page"
# Get current URL
assert_current_path "/dashboard"
assert_current_path dashboard_path, ignore_query: trueFinding Elements
# Find by text content
find("button", text: "Submit")
find(".modal h2", text: "Confirm")
# Find by CSS
find("#main-content")
find("table tr:first-child td.price")
# Find by label (accessible, preferred)
find_field("Email")
find_link("Sign in")
find_button("Create Account")
# All matching elements
all(".product-card")Interacting
click_link "Sign in"
click_button "Submit"
click_on "Settings" # matches links and buttons
fill_in "Email", with: "alice@example.com"
fill_in "Search", with: "capybara testing"
select "Admin", from: "Role"
check "Accept terms"
uncheck "Subscribe to newsletter"
attach_file "Avatar", Rails.root.join("test/fixtures/files/avatar.jpg")Assertions
assert_text "Welcome back"
assert_no_text "Error"
assert_selector "h1", text: "Dashboard"
assert_no_selector ".error-message"
assert_link "Sign out"
assert_button "Submit"
assert_field "Email", with: "alice@example.com"
assert_checked_field "Remember me"
assert_current_path "/dashboard"Handling JavaScript
System tests automatically wait for elements that appear asynchronously. Capybara polls until the element appears or a timeout is reached (default 2 seconds):
test "loads search results via AJAX" do
visit search_path
fill_in "Query", with: "ruby testing"
click_button "Search"
# Capybara waits for this to appear
assert_selector ".search-result", minimum: 1
assert_text "Minitest"
endFor operations that take longer:
# Increase timeout for slow operations
using_wait_time(10) do
assert_selector ".export-complete"
endWaiting for Specific Conditions
test "shows spinner then results" do
visit reports_path
click_button "Generate Report"
assert_selector ".spinner"
assert_no_selector ".spinner" # Capybara waits for it to disappear
assert_selector ".report-data"
endExecuting JavaScript
# Run arbitrary JavaScript
execute_script("window.scrollTo(0, document.body.scrollHeight)")
execute_script("document.querySelector('.cookie-banner').remove()")
# Get a value from JavaScript
scroll_position = evaluate_script("window.scrollY")
# Wait for JavaScript condition
assert evaluate_script("window.uploadComplete === true")Authentication in System Tests
Avoid logging in through the UI in every test — it's slow and fragile. Instead, sign in programmatically:
Option 1: Devise Test Helpers
If using Devise, include its test helpers:
# test/application_system_test_case.rb
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
include Warden::Test::Helpers
def sign_in_as(user)
login_as(user, scope: :user)
end
teardown do
Warden.test_reset!
end
endUse it in tests:
class DashboardTest < ApplicationSystemTestCase
setup do
@user = users(:alice)
sign_in_as(@user)
end
test "dashboard shows user stats" do
visit dashboard_path
assert_text "Welcome back, Alice"
assert_selector ".stat-card", count: 3
end
endOption 2: Session Fixture (cookie-based)
For custom auth systems, set the session directly:
def sign_in_as(user)
visit login_path
fill_in "Email", with: user.email
fill_in "Password", with: "test_password"
click_button "Sign in"
endCall this once per test class using setup to avoid repeating the login flow.
Testing Common Flows
Form Submission with Validation
test "creates post with valid data" do
sign_in_as users(:alice)
visit new_post_path
fill_in "Title", with: "My Test Post"
fill_in "Body", with: "This is the post body with enough content."
select "Technology", from: "Category"
click_button "Publish"
assert_current_path post_path(Post.last)
assert_selector "h1", text: "My Test Post"
assert_text "Post published successfully"
endPagination
test "paginates results" do
30.times { |i| Post.create!(title: "Post #{i}", user: users(:alice)) }
visit posts_path
assert_selector ".post-card", count: 20 # first page
click_link "Next"
assert_selector ".post-card", count: 10 # second page
endModals and Dialogs
test "confirms before deleting" do
sign_in_as users(:alice)
visit post_path(posts(:hello_world))
accept_confirm do
click_button "Delete Post"
end
assert_current_path posts_path
assert_text "Post deleted"
assert_no_text "Hello World"
endFile Uploads
test "uploads profile photo" do
sign_in_as users(:alice)
visit edit_profile_path
attach_file "Profile photo",
Rails.root.join("test/fixtures/files/avatar.jpg"),
make_visible: true # needed for hidden file inputs
click_button "Save Profile"
assert_selector "img.avatar[src*='avatar']"
endDebugging Failing Tests
Take a Screenshot
test "complex interaction" do
visit dashboard_path
take_screenshot # saves to tmp/screenshots/
# or
save_screenshot("tmp/screenshots/dashboard.png")
endRails automatically takes a screenshot when a system test fails. Check tmp/screenshots/ after failures.
Save the Page
save_page # saves HTML to tmp/capybara/Use Headed Chrome
Temporarily switch to headed mode to watch tests run:
# test/application_system_test_case.rb
driven_by :selenium, using: :chrome # remove "headless_"Pause Execution
binding.pry # with pry gem
# or
sleep 5 # crude but effective for watching the browserCI Configuration
System tests require Chrome in CI. For GitHub Actions:
# .github/workflows/test.yml
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
- name: Run system tests
env:
RAILS_ENV: test
DISPLAY: :99 # for headed mode on Linux
run: bin/rails test:systemFor parallel execution in CI:
# test/application_system_test_case.rb
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
parallelize(workers: 2) # system tests parallelize with threads
endNote: System tests cannot use process-based parallelism (Selenium connections are not fork-safe). Use workers: :number_of_processors for unit tests and a fixed low count for system tests.
Performance Tips
System tests are slow compared to unit tests — a full browser session costs 50-200ms per test even without heavy JavaScript. Keep your suite manageable:
- Test flows, not features. One system test covers the happy path. Edge cases belong in unit or integration tests.
- Avoid UI login in every test. Use
login_asor session fixtures. - Stub slow external services. Use VCR or WebMock to prevent real network calls.
- Run system tests separately. Separate CI jobs for
testandtest:systemrun in parallel.
Continuous Monitoring with HelpMeTest
System tests catch regressions in CI, but they don't tell you when production breaks. HelpMeTest runs your critical user flows 24/7 against your live application, alerting you the moment a real user would be blocked — without any code to maintain.
Combine Rails system tests for pre-deploy validation with HelpMeTest for continuous production monitoring, and you have full coverage of the user experience.
Summary
Rails system tests provide the confidence that your application works end-to-end through a real browser. Use Capybara's expressive DSL for interactions, leverage Devise or Warden helpers for fast authentication setup, and configure headless Chrome for reliable CI execution.
Focus system tests on critical user flows — registration, checkout, core features. Keep the count manageable and trust unit tests to cover edge cases. Screenshot on failure and headed-mode debugging make diagnosing failures straightforward.