/ Tags: RAILS / Categories: RAILS

Rails System Tests With Capybara — Setup, Speed, and Staying Sane

System tests are the ones that catch the bugs nobody else does — the form that submits fine but whose success message never renders, the Turbo Frame that silently swallows a redirect. They’re also the ones the team disables after six months because they’re slow and they fail randomly on CI. Both problems have specific, fixable causes, and almost all of them come down to one thing: waiting for the wrong signal.

Setup That Works


Example:

# Gemfile
group :test do
  gem "capybara"
  gem "selenium-webdriver"
  gem "rspec-rails"
end
# spec/support/capybara.rb
Capybara.register_driver :headless_chrome do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.add_argument("--headless=new")
  options.add_argument("--no-sandbox")
  options.add_argument("--disable-dev-shm-usage")
  options.add_argument("--window-size=1400,1400")
  options.add_argument("--disable-search-engine-choice-screen")

  Capybara::Selenium::Driver.new(app, browser: :chrome, options:)
end

Capybara.default_driver = :rack_test
Capybara.javascript_driver = :headless_chrome
Capybara.default_max_wait_time = 5
Capybara.server = :puma, { Silent: true }
Capybara.disable_animation = true

RSpec.configure do |config|
  config.before(:each, type: :system) { driven_by :rack_test }
  config.before(:each, type: :system, js: true) { driven_by :headless_chrome }
end

Two details in there matter a lot.

--disable-dev-shm-usage — Chrome uses /dev/shm, which is 64MB in most Docker containers. Without this flag, Chrome crashes on CI with an error that looks nothing like the cause.

Capybara.disable_animation = true — CSS transitions are a major source of flake. An element that’s fading in is present in the DOM but not clickable, and Capybara’s error message won’t mention animation at all.

Default to rack_test


The most effective speed optimisation is not running a browser. rack_test drives your app directly, in-process, with no JavaScript — and it’s roughly fifty times faster.

Example:

# No JS needed — runs in milliseconds
RSpec.describe "Account settings", type: :system do
  it "updates the display name" do
    sign_in(user)
    visit edit_account_path

    fill_in "Display name", with: "Acme Corp"
    click_button "Save"

    expect(page).to have_content("Settings updated")
    expect(user.reload.display_name).to eq("Acme Corp")
  end
end
# JS genuinely needed — Turbo Stream updates the list without a reload
RSpec.describe "Order list", type: :system, js: true do
  it "prepends a new order without a page reload" do
    sign_in(user)
    visit orders_path

    click_link "New order"
    fill_in "SKU", with: "ABC-1"
    click_button "Create"

    within("#orders") do
      expect(page).to have_content("ABC-1")
    end
  end
end

Reach for js: true only when the behaviour under test is the JavaScript. A form that posts and redirects doesn’t need a browser, even in a Hotwire app — Turbo degrades to a normal form submission under rack_test, which means you’re testing the server behaviour without the browser cost.

The Real Cause of Flake


Nearly every intermittent system test failure is a race between the test and the browser. Capybara has waiting built in — the trick is not to defeat it.

Capybara’s matchers wait. Ruby’s do not.
# Waits up to default_max_wait_time for the text to appear
expect(page).to have_content("Order created")

# Does NOT wait — checks once, immediately
expect(page.has_content?("Order created")).to be true
expect(page.text).to include("Order created")

The second and third forms are the most common flake source in any suite. Anything that pulls a value out of the page and then asserts on it has already lost the waiting behaviour.

Negative assertions need the right form
# Correct — waits for the element to disappear
expect(page).to have_no_content("Loading…")

# Wrong — asserts immediately that it's absent, passes before it appears
expect(page).not_to have_content("Loading…")

have_no_* waits for absence. not_to have_* with RSpec can pass spuriously because it evaluates once against a page that hasn’t rendered yet. Use the have_no_ form for every negative assertion.

Never sleep
# Wrong — slow when unnecessary, insufficient when the machine is loaded
sleep 2
expect(page).to have_content("Done")

# Right — wait for the actual condition
expect(page).to have_content("Done", wait: 10)

A sleep is a bet that the operation takes less than N seconds. On a loaded CI runner it doesn’t, and you’ve made the suite both slower and still flaky.

Wait for a settled state before asserting on data
click_button "Create"

# Wrong — the assertion may run before the request completes
expect(Order.count).to eq(1)

# Right — wait for a UI signal first
expect(page).to have_content("Order created")
expect(Order.count).to eq(1)

Database assertions have no waiting behaviour at all. Always anchor them behind a Capybara matcher that proves the request finished.

Selectors That Don’t Break


Tests that select by CSS class break every time someone touches the stylesheet.

# Fragile
find(".btn.btn-primary.order-submit").click

# Better — how a user identifies it
click_button "Create order"
click_link "Edit"
fill_in "Email address", with: "[email protected]"

# For anything without a label, use a test id
find("[data-testid='order-row-#{order.id}']").click
# spec/support/capybara.rb
Capybara.test_id = "data-testid"

With test_id configured, find("order-row") matches the attribute directly. Test ids are a deliberate contract — visible in the markup, obviously not for styling, and safe to rely on.

Prefer accessible selectors first: click_button, click_link, fill_in with the label text. If those don’t work, your markup probably has an accessibility problem, and the test just found it.

Speed


Parallelise
# spec/rails_helper.rb — with parallel_tests
# or use Rails' built-in parallelization
config.before(:suite) do
  # ensure each process has its own database
end
bundle exec parallel_rspec spec/system/

System tests are IO-bound waiting on a browser, so they parallelise extremely well. Four processes is usually close to a 4× improvement.

Use transactional tests where you can

Rails’ system tests share a database connection between the test and the server thread, so transactional fixtures work with Capybara — no need for database_cleaner with truncation unless you’re doing something unusual. Truncation between every example is a large, often-unnecessary cost.

Sign in without the UI
module SystemAuthHelpers
  def sign_in_as(user)
    # For rack_test — set the session directly
    page.driver.browser.set_cookie("session_id=#{user.create_session!.id}")
  end
end

Logging in through the form in every one of two hundred tests adds minutes. Test the login form once, properly, and bypass it everywhere else.

Pro-Tip: Configure screenshots on failure and then actually look at them — Rails saves one automatically to tmp/screenshots/ when a system test fails, and most teams never open the directory. A screenshot answers in two seconds what twenty minutes of log-reading won’t: the element wasn’t there because a validation error rendered instead, or the modal was open over the button, or the page is showing your 500 error template. Add save_page alongside it for the raw HTML (Capybara.save_path = "tmp/capybara"), and upload both as CI artifacts — a flaky test that only fails on CI is nearly impossible to diagnose without them, and nearly trivial with them. This is a five-line CI config change that turns your worst debugging experience into a routine one.

Test the Right Things


System tests are expensive, so spend them on flows rather than on units.

  • Do test: the critical paths a user takes end to end — sign up, checkout, the main create-and-edit loop
  • Do test: anything where the integration is the risk — Turbo Streams updating a list, a Stimulus controller wired to a form
  • Don’t test: validation error messages for every field. That’s a model spec.
  • Don’t test: every permutation of a filter. Test the mechanism once, the logic in a unit test.

A suite of fifteen well-chosen system tests that runs in ninety seconds is worth far more than two hundred that take twenty minutes and get skipped.

Conclusion


Default to rack_test and use js: true only when the JavaScript is what you’re testing — that single decision is most of the speed. Let Capybara’s matchers do the waiting, use have_no_* for negative assertions, and never write sleep. Anchor every database assertion behind a UI matcher that proves the request finished. Select by label and role first, data-testid second, CSS classes never. Turn on failure screenshots and upload them from CI, because the flaky test you can’t reproduce locally is usually obvious the moment you see the page.

FAQs


Q1: System tests or request specs?
Request specs for API behaviour, status codes, and redirects — they’re much faster. System tests for anything involving rendered markup or JavaScript. Most suites need many more of the former than the latter.

Q2: Why does my test pass locally and fail on CI?
Usually timing — CI is slower, so a race that resolves in your favour locally doesn’t there. Sometimes it’s Chrome’s /dev/shm limit in a container. The failure screenshot distinguishes the two immediately.

Q3: Do I need database_cleaner?
Rarely, in modern Rails. Transactional tests work with system tests because the server shares the test’s connection. You need truncation only if something spawns a genuinely separate connection.

Q4: How do I test file uploads?
attach_file "Avatar", Rails.root.join("spec/fixtures/files/avatar.png"). For direct uploads to S3, stub the service in test with config.active_storage.service = :test.

Q5: Can I run headed instead of headless to debug?
Yes — drop the --headless=new argument, or register a second driver and switch per-example. Watching the browser do it is often the fastest way to understand a confusing failure.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More