Rails System Tests with Capybara: End-to-End Testing, CI Setup, and Eliminating Flaky Tests
Rails system tests with Capybara: headless Chrome setup, reliable end-to-end specs, fast CI runs, and proven tactics to eliminate flaky tests for good.
The engineer sent me a Slack message at 10:43 on a Friday evening. “Our system tests pass locally but fail on CI 30% of the time. Should we just delete them?” I have received this exact message, or a variant of it, at least a dozen times. The answer I give has never changed: no, do not delete them, but your Rails system tests setup is almost certainly wrong in at least three specific ways, and every one of those ways has a known fix.
System tests landed in Rails 5.1 in 2017, built on top of Capybara and driven by a real browser. They are the closest thing Rails ships natively to what your users actually experience. When configured correctly and written with discipline, they catch an entire class of bug that no unit test can see: the JavaScript that only fires on a real DOM interaction, the Turbo Stream that updates the wrong frame, the form that submits cleanly but the success message never appears because a before_action redirected the request elsewhere. When configured badly, they are the tests that destroy a team’s confidence in their test suite and eventually get deleted. This post is the setup guide I wish had existed in 2017.
What Rails System Tests Are (and What They Are Not)
A system test subclasses ActionDispatch::SystemTestCase, which wraps Capybara, which drives a real Chrome instance — headless by default. The full stack renders: routes, controllers, views, JavaScript, CSS, asset pipeline. The test runs against a real database. From the browser’s perspective, it cannot tell the difference between a system test and a human.
That scope is the point, and it is also the cost. A single system spec that logs in, fills a multi-field form, submits it, and asserts the success message typically takes between 0.5 and 2 seconds. A suite of 300 of them, naively configured on CI, can add twelve to fifteen minutes to a build. The discipline is: use system tests for the things only they can test.
A healthy Rails test suite looks like this in practice:
- Unit tests and model specs: validations, callbacks, service objects, POROs — fast, often in-memory, no HTTP.
- Request specs or controller tests: the HTTP layer. Does a
POST /invoicescreate a record and redirect correctly? Answers in milliseconds without a browser. - System tests: the critical user journeys that involve JavaScript, real form submission, and visible DOM outcomes.
The mistake I see most often is teams using system tests as the only kind of test, resulting in slow suites with 90% of coverage that could have been covered 20 times faster with request specs. The other mistake is not having system tests at all, and discovering that your JavaScript-heavy Turbo interface broke in production.
Project Setup: Gems and Driver Configuration
If you are on Minitest (the Rails default), system tests are wired in from rails new. The test/system/ directory exists and test/application_system_test_case.rb is the place to configure the driver. If you are on RSpec, add the following:
# Gemfile
group :test do
gem "capybara"
gem "selenium-webdriver"
gem "webdrivers", "~> 5.0"
end
Then create a support file:
# spec/support/capybara.rb
require "capybara/rspec"
Capybara.configure do |config|
config.default_max_wait_time = 5
config.ignore_hidden_elements = true
config.default_normalize_ws = true
config.server = :puma, { Silent: true }
config.save_path = Rails.root.join("tmp/capybara")
end
RSpec.configure do |config|
config.before(:each, type: :system) do
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 900]
end
end
For Minitest, configure in test/application_system_test_case.rb:
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 900]
end
Every one of those Capybara.configure options earns its place. default_max_wait_time = 5 gives slow CI machines enough time to render JavaScript before an assertion gives up. default_normalize_ws = true normalises whitespace so assert_text "Save document" (double space) matches assert_text "Save document". server = :puma runs the test server on Puma — the same server you use in production — rather than Webrick, which has subtle concurrency differences. save_path tells Capybara where to write screenshots on failure, which I will come back to.
Headless Chrome and the CI Driver Registration
On GitHub Actions in 2026, Chrome is pre-installed on ubuntu-latest. The webdrivers gem auto-resolves the matching chromedriver version, but there is a more explicit registration that gives you finer control over Chrome flags, which you need in containerised environments:
# spec/support/capybara.rb (add above the RSpec.configure block)
Capybara.register_driver :chrome_headless 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("--disable-gpu")
options.add_argument("--window-size=1400,900")
options.add_argument("--disable-extensions")
Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
end
Capybara.default_driver = :rack_test # fast, no browser, for non-JS specs
Capybara.javascript_driver = :chrome_headless # real browser, for JS specs
The --no-sandbox and --disable-dev-shm-usage flags are essential in Docker and CI containers. /dev/shm is Chrome’s shared memory channel for inter-process communication. On GitHub Actions runners and most Docker configurations it is limited to 64MB. Chrome silently crashes if it cannot allocate from /dev/shm, producing the baffling failure “unknown error: session deleted because of page crash” that shows up in flaky-test reports everywhere.
Your GitHub Actions workflow:
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Set up test database
env:
DATABASE_URL: postgres://postgres:postgres@localhost/myapp_test
RAILS_ENV: test
run: |
bin/rails db:create
bin/rails db:schema:load
- name: Run system tests
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost/myapp_test
run: bundle exec rspec spec/system
- name: Upload Capybara screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: capybara-screenshots
path: tmp/capybara/
The screenshot upload step is not optional. When a Rails system test fails on CI, the screenshot and the saved HTML are the first things you reach for. Without them, debugging a CI-only failure means running the test in a CI environment interactively, which is rarely fun.
Database Isolation: The Overlooked Source of Flakiness
This is the part that trips up almost every team that runs into intermittent system test failures. The reason is threading.
Capybara runs a real browser. That browser connects to your Rails app over HTTP. Your Rails app runs in a separate thread from the thread running your test assertions. By default, Rails wraps each test in a database transaction and rolls it back when the test finishes — use_transactional_tests = true. The problem: the app thread and the test thread use different database connections, so the app thread cannot see data created inside the test thread’s open transaction.
The symptom is a test that creates a user with User.create!(...), then visits a page expecting to see that user, and finds nothing because the app server queried on a different connection that sees an empty table.
The fix is switching Rails system tests to truncation rather than transactions:
# spec/support/database_cleaner.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do |example|
strategy = example.metadata[:type] == :system ? :truncation : :transaction
DatabaseCleaner.strategy = strategy
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
end
Truncation is slower than transactions — typically 100–300ms per test depending on how many tables you have. That is the honest cost of cross-thread data visibility. It is why system tests should not cover scenarios that a faster test type covers just as well. I talked through optimising test suite performance in more depth in the RSpec, Factory Bot, and VCR post, but the database isolation point is the most important one specifically for system tests.
Writing Tests That Survive Six Months of Development
The difference between a system test that stays green through a year of development and one that needs weekly patching is almost always in what it asserts against.
Bad:
it "saves the form" do
visit new_invoice_path
find("form#new_invoice .btn.btn-primary").click
expect(page).to have_css(".alert.alert-success")
end
This test breaks every time a CSS class is renamed, a form ID changes, or Bootstrap is upgraded. It asserts on implementation details instead of user-visible outcomes.
Good:
it "creates an invoice and confirms to the user" do
user = create(:user)
login_as(user, scope: :user)
visit new_invoice_path
fill_in "Invoice number", with: "INV-2026-001"
fill_in "Client name", with: "Acme Corporation"
fill_in "Amount", with: "3500.00"
select "Netherlands", from: "Country"
click_button "Create invoice"
expect(page).to have_text("Invoice INV-2026-001 was created successfully")
expect(page).to have_current_path(%r{/invoices/\d+})
expect(Invoice.last.client_name).to eq("Acme Corporation")
end
This version breaks only when user-visible behaviour changes — which is exactly when you want it to break.
Three Capybara methods I reach for in every project, because they all use Capybara’s built-in retry mechanism rather than one-shot queries:
# Waiting assertions — retry until timeout, then fail
expect(page).to have_text("Successfully saved")
expect(page).to have_selector("table tbody tr", count: 12)
expect(page).to have_current_path(%r{/orders/\d+/confirmation})
expect(page).not_to have_text("Error saving")
# Waiting finders — retry until the element is present
click_link "Edit details"
fill_in "Email address", with: "roger@example.com"
check "Subscribe to updates"
select "Pro plan", from: "Billing plan"
# Waiting for JavaScript-driven state
expect(page).to have_css("[data-status='complete']")
expect(page).to have_css(".modal", visible: true)
The critical thing to internalise: find(".some-class") does not retry. have_selector(".some-class") does. If you use find before an element is in the DOM, you get a race condition that is timing-dependent and flaky. Use expect(page).to have_selector(...) followed by find(...) if you need the element reference, or use find with an explicit wait: option.
Page Objects: Keeping Tests Readable as the Application Grows
Once a project has more than 30 or 40 system specs, raw Capybara selectors scattered across specs become a maintenance problem. When the “Create invoice” button label changes to “Save invoice,” you update 15 specs. The pattern I reach for is not a heavy DSL — it is just Ruby objects with descriptive methods:
# spec/support/pages/invoice_page.rb
module Pages
class NewInvoice
include Capybara::DSL
def visit_page
visit new_invoice_path
self
end
def fill_in_details(number:, client:, amount:, country: "Netherlands")
fill_in "Invoice number", with: number
fill_in "Client name", with: client
fill_in "Amount", with: amount
select country, from: "Country"
self
end
def submit
click_button "Create invoice"
self
end
def flash_message
find(".flash-notice").text
end
def current_invoice
Invoice.find(page.current_path.match(%r{/invoices/(\d+)})[1])
end
end
end
Usage:
it "creates an invoice" do
login_as create(:user), scope: :user
invoice_page = Pages::NewInvoice.new.visit_page
invoice_page.fill_in_details(number: "INV-001", client: "Acme", amount: "1500")
invoice_page.submit
expect(invoice_page.flash_message).to include("INV-001 was created")
expect(invoice_page.current_invoice.client_name).to eq("Acme")
end
The page object encapsulates every selector in one file. When the markup changes, you update the page object. The spec reads like a description of user behaviour. A new team member can understand it without knowing Capybara at all.
Parallelising System Tests for Fast CI
On suites larger than 50 system specs, parallelisation is the single biggest improvement available. For Minitest, Rails ships parallelize natively:
# test/test_helper.rb
parallelize(workers: :number_of_processors, with: :processes)
parallelize_setup { |worker| ActiveRecord::Base.establish_connection }
For RSpec, parallel_tests is the standard:
# Gemfile
group :test do
gem "parallel_tests"
end
bundle exec parallel_rspec spec/system -n 4
Two things to configure when running parallel system tests. First, each process needs its own database. parallel_tests sets TEST_ENV_NUMBER automatically; you need database.yml to respect it:
# config/database.yml
test:
database: myapp_test<%= ENV["TEST_ENV_NUMBER"] %>
Second, each process needs its own Capybara server port, or processes will fight over the same port and produce connection-refused errors on a random 30% of runs (there is your Friday-night Slack message):
# spec/support/capybara.rb
Capybara.server_port = 4000 + (ENV["TEST_ENV_NUMBER"].to_i)
With four parallel processes on a 2-vCPU GitHub Actions runner, a 60-spec system test suite that took eight minutes runs in just over two. It is worth the setup time.
Diagnosing the Four Common Failure Modes
When a Rails system test passes locally and fails on CI, the cause is almost always one of four things:
Timing. The assertion fires before the browser finishes rendering a JavaScript update. Solution: use have_text and have_selector (which retry) rather than find (which does not). Increase default_max_wait_time to 8 on CI if the environment is genuinely slow. Never add sleep — it is a symptom of the real timing fix.
Database isolation. A record created in the test thread is invisible to the app thread because you are using transactions rather than truncation for system specs. Solution: the DatabaseCleaner configuration above.
Viewport and visibility. A button is below the fold at the CI viewport size and Selenium cannot click it. Solution: set --window-size=1400,900 in Chrome options. If a button is hidden under a sticky header, scroll to it first: find("button", text: "Submit").scroll_into_view.
JavaScript controller not yet attached. In CI, asset delivery can be slightly slower and a Stimulus controller may not have booted before the test tries to interact with the element it manages. Solution: wait for the controller attribute before interacting:
# Wait for the Stimulus controller to initialise before interacting
expect(page).to have_css("[data-controller='invoice-form'][data-invoice-form-loaded-value='true']")
find("button", text: "Add line item").click
The last one only applies if you expose a data-*-loaded-value attribute from your Stimulus controller’s connect() callback. I have started doing this on every Stimulus controller that manages an element system tests interact with, and it has reduced Stimulus-related flakiness to zero.
Capturing Screenshots and HTML on Failure
I mentioned this in the CI workflow earlier, but it deserves more detail because it saves hours.
For RSpec:
# spec/support/capybara.rb
RSpec.configure do |config|
config.after(:each, type: :system) do |example|
if example.exception
screenshot_name = "#{Time.now.to_i}-#{example.description.parameterize(separator: '-')}"
save_screenshot("#{Capybara.save_path}/#{screenshot_name}.png")
File.write("#{Capybara.save_path}/#{screenshot_name}.html", page.html)
end
end
end
For Minitest, add to ApplicationSystemTestCase:
teardown do
if self.class.current_test_failed?
name = method_name.parameterize(separator: "-")
save_screenshot("#{Rails.root}/tmp/capybara/#{name}.png")
end
end
After nineteen years of debugging test failures, the screenshot is the first thing I look at, not the Ruby error. Nine times out of ten, the screenshot tells the whole story: there is an error message the test was not expecting, or the wrong page loaded, or the modal never opened.
Frequently Asked Questions
What is the difference between Rails system tests and request specs?
Request specs (and Rails controller tests) exercise the HTTP layer: they verify that POST /invoices creates a record and returns a redirect. They run against a Rack stack without a browser — fast, deterministic, no JavaScript. Rails system tests drive a real headless Chrome browser, which means they test JavaScript, Stimulus controllers, Turbo streams, and visible DOM outcomes. Use request specs to test the API contract; use system tests to test what a user actually sees and interacts with.
How do I sign in a user without going through the login form in every system test?
With Devise, include Warden::Test::Helpers (which Devise exposes) and call login_as before visiting the page:
# spec/support/devise.rb
RSpec.configure do |config|
config.include Warden::Test::Helpers
config.after(:each, type: :system) { Warden.test_reset! }
end
# In a system spec
it "shows the dashboard" do
user = create(:user)
login_as(user, scope: :user)
visit dashboard_path
expect(page).to have_text("Welcome back, #{user.first_name}")
end
login_as sets the Warden session directly without going through the login form. This isolates your authenticated-flow specs from login-form bugs and saves the 0.5–1 second that the login form interaction would add to every spec.
Should I use Cuprite instead of Selenium?
Cuprite drives Chrome via the Chrome DevTools Protocol rather than WebDriver, which is typically 20–40% faster and offers better JavaScript debugging (you can intercept network requests, inject console commands, etc.). It is a good choice for new projects. The tradeoff is a slightly different Capybara API — some methods like page.go_back behave differently, and iframe handling is less mature. Selenium with ChromeDriver is more battle-tested and matches what the Rails documentation describes. I start every project with Selenium and migrate to Cuprite only if the suite is genuinely slow and parallelisation alone has not been enough.
How do I know which user journeys deserve a system test?
Cover the flows that, if broken in production, would cause immediate revenue loss, data loss, or loud customer complaints: signup, login, the core feature happy path, billing checkout, any multi-step form that involves JavaScript. Beyond those, ask whether a request spec or unit test would catch the same bug. If the answer is yes, write the faster test. I have never seen a Rails application where more than 60–80 system specs were justified; most need 20–40 well-chosen ones.
If your Rails system tests are deleting themselves or living in a permanently-red state, TTB Software can audit the setup and get your CI running green reliably. We have been building and fixing Rails test infrastructure for nineteen years, and we have seen every flavour of flaky-test hell.
Related Articles
Rails LLM Structured Output: Reliable JSON from Claude and OpenAI with Schema Validation
Rails LLM structured output guide: get reliable JSON from Claude and OpenAI using schema mode, response validation, a...
Rails Postgres Partitioning: Managing Billion-Row Tables with pg_partman and Native Postgres Partitions
Rails Postgres partitioning guide: manage billion-row tables with native Postgres declarative partitioning, pg_partma...
Rails ActiveRecord Encryption: Encrypting PII at Rest with Deterministic and Non-Deterministic Modes
Rails ActiveRecord encryption guide: encrypt PII at rest with deterministic and non-deterministic modes, key manageme...