Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 040 / 100

System tests with Capybara: patterns, pitfalls and fixes

Test the app like a user.

Expona Admin7 min read

System tests with Capybara: patterns, pitfalls and fixes

After 40 days of building Rails applications, you've written models, controllers, and views. But have you tested them the way your users actually interact with them? System tests with Capybara let you test the app like a user—clicking buttons, filling forms, waiting for animations—and catch integration bugs that unit tests miss. In this guide, we'll walk through the patterns that keep tests maintainable, the pitfalls that waste your time, and the fixes that make them reliable.

The foundation: visit, fill_in and click_on

Capybara gives us a fluent DSL that mirrors user actions. Let's start with a concrete example: an e-commerce checkout flow where a user adds an item to their cart and proceeds to payment.

ruby

# spec/system/checkout_spec.rb
require "rails_helper"

RSpec.describe "Checkout", type: :system do
  it "allows a user to purchase an item" do
    product = create(:product, name: "Widget", price: 29.99)
    
    visit product_path(product)
    expect(page).to have_content("Widget")
    
    click_on "Add to Cart"
    expect(page).to have_content("1 item in cart")
    
    visit cart_path
    click_on "Proceed to Checkout"
    
    fill_in "Email", with: "user@example.com"
    fill_in "Full Name", with: "Jane Doe"
    fill_in "Address", with: "123 Main St"
    
    click_on "Complete Purchase"
    
    expect(page).to have_content("Order confirmed")
    expect(Order.last.email).to eq("user@example.com")
  end
end

This test is readable because it mirrors the user's journey. A few patterns to lock in:

  • Use semantic matchers: have_content, have_button, have_field instead of DOM inspection. They're resilient to markup changes.
  • Visit pages from the top level: Use visit to navigate. Don't hardcode URLs or rely on redirect chains in tests.
  • Name your buttons and fields clearly: Capybara finds elements by visible text first, so click_on "Complete Purchase" is more stable than targeting a CSS class.
  • Assert state after actions: After click_on, always assert that the UI changed. This forces you to think about what success looks like.

Run with headless Chrome

By default, Rails system tests use Selenium with Chrome in headless mode. This is fast and reliable. But sometimes tests pass locally and fail in CI, or they're flaky because the browser moves faster than the page loads.

Configure your test suite in config/environments/test.rb and spec/support/capybara.rb:

ruby

# spec/support/capybara.rb
Capybara.server = :puma, { Silent: true }
Capybara.default_max_wait_time = 5

Capybara.register_driver :headless_chrome do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.add_argument("--headless")
  options.add_argument("--no-sandbox")
  options.add_argument("--disable-dev-shm-usage")
  options.add_argument("--disable-gpu")
  
  Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
end

Capybara.configure do |config|
  config.default_driver = :headless_chrome
  config.app_host = "http://localhost:3000"
end

A few tips for reliability:

  • Set a sensible default timeout: 5 seconds is a good starting point. Capybara will retry assertions until the timeout expires, so flaky tests often mean you need to wait for something.
  • Disable GPU and dev-shm: In CI environments, these settings prevent crashes and resource contention.
  • Run tests in parallel with care: Use database transactions or truncation, and ensure your test database is clean between runs.

To debug a failing test, add a screenshot:

ruby

# Inside a test
save_screenshot("debug.png")

The screenshot lands in tmp/capybara/ and shows exactly what the browser saw when the assertion failed.

Test Hotwire interactions

Hotwire—Turbo and Stimulus—has become the Rails default for interactivity. System tests must wait for these interactions to complete. Here's where many teams struggle.

Consider a modal that opens on button click:

ruby

# app/views/dashboard/index.html.erb
<%= button_to "Edit Settings", edit_settings_path, method: :get, remote: true %>

<div id="settings-modal" data-controller="modal" style="display: none;">
  <h2>Edit Settings</h2>
  <!-- form content -->
</div>

The naive test fails:

ruby

# BAD: doesn't wait for Turbo to render the modal
it "opens the settings modal" do
  visit dashboard_path
  click_on "Edit Settings"
  expect(page).to have_content("Edit Settings")
end

Capybara doesn't know Turbo ran. The fix: wait for an element that appears after the interaction:

ruby

# GOOD: wait for the modal to be visible
it "opens the settings modal" do
  visit dashboard_path
  click_on "Edit Settings"
  
  expect(page).to have_selector("#settings-modal", visible: :visible)
  expect(page).to have_content("Edit Settings")
end

Or, for Stimulus actions, wait for a CSS class change:

ruby

# GOOD: wait for Stimulus to toggle the class
it "toggles the menu" do
  visit dashboard_path
  
  click_on "Menu"
  expect(page).to have_selector("nav.is-open")
  
  click_on "Menu"
  expect(page).to have_selector("nav.is-closed")
end

For form submissions over Turbo, Capybara waits for the response, but only if you use standard submit buttons:

ruby

# GOOD: uses a real form, Capybara waits automatically
it "creates a new task" do
  visit tasks_path
  click_on "New Task"
  
  fill_in "Title", with: "Buy groceries"
  fill_in "Description", with: "Milk, eggs, bread"
  click_on "Create Task"
  
  expect(page).to have_content("Task created successfully")
  expect(Task.last.title).to eq("Buy groceries")
end

Keep them focused on key flows

System tests are slow—each one spins up a browser, loads the page, and interacts with it. A test suite with hundreds of system tests can take 10+ minutes. That's a productivity killer.

Instead, write system tests for user journeys, not for every code path. Your unit tests cover the logic; system tests verify the wiring works end-to-end.

For an SaaS app, focus on these flows:

  • Sign up and activation
  • Log in and log out
  • Create and edit core resources (e.g., a project, an invoice)
  • Search and filter
  • Payment or subscription change
  • Error cases that require user intervention (e.g., validation failures)

Don't write a system test for every validation message or every form field. Instead, test one happy path and one unhappy path per resource:

ruby

# spec/system/projects_spec.rb
RSpec.describe "Projects", type: :system do
  let(:user) { create(:user) }

  before { login_as user }

  it "creates a new project" do
    visit projects_path
    click_on "New Project"
    
    fill_in "Name", with: "Analytics Dashboard"
    fill_in "Description", with: "Real-time metrics"
    click_on "Create"
    
    expect(page).to have_content("Analytics Dashboard")
    expect(user.projects.last.name).to eq("Analytics Dashboard")
  end

  it "shows validation errors on blank name" do
    visit projects_path
    click_on "New Project"
    
    fill_in "Description", with: "A project without a name"
    click_on "Create"
    
    expect(page).to have_content("Name can't be blank")
  end
end

This approach keeps the suite lean and fast. Your model specs handle edge cases; your system specs verify the happy path works.

Common mistakes

  • Not waiting: Tests fail intermittently because they check for elements before the page updates. Use explicit waits or change assertions to trigger retries.
  • Brittle selectors: Avoid find(".btn-primary"). Use semantic matchers like have_button or add data-testid attributes to elements you need to target precisely.
  • Testing everything: Don't write a system test for every model method. Let unit tests handle the logic; system tests verify integration.
  • Ignoring database state: Clean your database between tests. Use DatabaseCleaner or Rails' built-in truncation, and ensure factories are correct.
  • Hard-coded URLs: Use named routes and helpers. visit product_path(product) is more maintainable than visit "/products/123".
  • Overlapping assertions: Each test should verify one user flow. If your test is checking 10 different things, split it into multiple tests.

Key takeaways

  • System tests mirror real user interactions; use visit, fill_in, and click_on to navigate and interact with your app.
  • Configure Capybara with headless Chrome, set a reasonable timeout (5 seconds), and disable GPU in CI to avoid flakiness.
  • When testing Hotwire interactions, wait for elements or classes to appear; Capybara doesn't know Turbo ran unless you tell it to wait.
  • Focus system tests on key user flows—sign up, log in, create and edit resources—not on every code path or validation message.
  • Keep tests isolated, maintainable, and fast by using semantic matchers, named routes, and a clean database between runs.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles