Rails System Tests: Capybara and Selenium End-to-End Testing

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:

  1. Boots the Rails app in test mode
  2. Starts a Capybara server on a random port
  3. Launches a Selenium-controlled browser
  4. 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]
end

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

Writing Your First System Test

Generate a system test:

bin/rails generate system_test users

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

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

Capybara DSL Essentials

visit root_path
visit "https://example.com/page"

# Get current URL
assert_current_path "/dashboard"
assert_current_path dashboard_path, ignore_query: true

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

For operations that take longer:

# Increase timeout for slow operations
using_wait_time(10) do
  assert_selector ".export-complete"
end

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

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

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

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

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

Pagination

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
end

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

File 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']"
end

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

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

CI 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:system

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

Note: 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:

  1. Test flows, not features. One system test covers the happy path. Edge cases belong in unit or integration tests.
  2. Avoid UI login in every test. Use login_as or session fixtures.
  3. Stub slow external services. Use VCR or WebMock to prevent real network calls.
  4. Run system tests separately. Separate CI jobs for test and test:system run 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.

Read more