Run Web Automation Tests at Scale with Agentic AI
Generate tests with AI agents or automate test suites with Selenium, Playwright and other frameworks. Run tests in parallel on TestMu AI scalable cloud grid.
Capybara Flaky Tests: How to Fix Rails System Tests in CI
Capybara flaky tests come from one-time reads, leaked state, browser drift and parallel workers. Reproduce them, then fix Rails system tests in CI with code.
Published on:
A checkout system test passes on your laptop every time, fails on the CI runner with Capybara::ElementNotFound, then passes again when someone clicks Re-run. Capybara flaky tests usually come from a check that ran before the browser, the app server or the database finished its work, and a CI runner widens each of those gaps.
This guide takes a flaky Rails system test from the failure log to a fix that holds, one cause at a time: waits, database state, driver and browser versions, animations and Turbo, and parallel workers. Every API below was checked on November 11, 2026 against the documentation and source of Capybara 3.40.0, still the current release on RubyGems, Rails 8.1.4.1 and selenium-webdriver 4.52.0. The last sections cover retries and running the same tests on TestMu AI with a record of every run.
For Capybara setup itself, start with Selenium automation testing with Capybara. For flakiness beyond Ruby, the flaky tests guide covers detection and team policy.
Overview
Capybara flaky tests in Rails CI happen when a check reads the page before the browser, the app server or the database has finished updating. The fix is to use Capybara's waiting matchers such as assert_selector, keep each test's state inside its transaction, and pin the CI runner's browser version, window size and OS image.
What Causes Capybara Flaky Tests in Rails?
- One-time reads: In a Capybara test, all(".order").count, find("#status").text and current_path read the page once, so they fail when the page is a moment behind. The fix is a waiting matcher: assert_selector with count:, assert_text or assert_current_path.
- Leaked state: Rails system tests roll back their database changes, but state outside that transaction leaks into the next test. The usual sources are before(:all) records, Active Storage files and background jobs that finish after the check.
- Browser drift: Capybara tests on GitHub-hosted runners run on images whose software GitHub typically updates weekly. Pin the runner label, the Chrome browserVersion and the window size so the browser under the suite changes only when you change it.
- Animations: A Capybara click on an element that an overlay or animation still covers raises ElementClickInterceptedError, which Capybara retries only until its wait runs out. Capybara.disable_animation = true turns off CSS transitions and jQuery effects in pages its server returns.
- Parallel workers: Rails gives each parallel test worker its own database, but Active Storage files, caches and search indexes stay shared. A parallelize_setup block gives each worker its own copy.
Do Rails System Tests Need DatabaseCleaner?
No, not on Rails 5.1 or later. The test thread and the app server share one database connection, so each system test runs in a transaction that Rails rolls back. Clean up by hand only for uploaded files, jobs and tests that turn transactional tests off.
Should I Retry Flaky Capybara Tests in CI?
One retry keeps a build moving, but log every test that passes only on retry and fix its cause. For suites that run on TestMu AI, Test Intelligence checks each test's last 10 runs and marks it flaky when its flake rate goes above 20%, so a test that keeps flaking after a fix stays visible.
Why Do Capybara Tests Pass Locally and Fail in CI?
Capybara tests pass locally and fail in CI because the runner is a different machine: less CPU, other browser versions and a different number of parallel workers change how long each step takes, while Capybara gives each check a fixed time. Finders and matchers retry until the page matches, for up to 2 seconds by default per the Capybara README, so a step that runs slower on CI can run out of time.
The runner's hardware is enough to change a result. In The Effects of Computational Resources on Flaky Tests, a 2023 study of 52 Java, JavaScript and Python projects run under 27 resource configurations, 46.5% of the flaky tests were resource-affected: whether they failed depended on the CPU and memory the run had. The authors add that reducing the resources available when running tests is a cost-effective way to detect more flaky failures.
New Rails apps also run tests in parallel by default: the generated test_helper.rb calls parallelize(workers: :number_of_processors), and the Rails testing guide notes that Rails won't parallelize a run of fewer than 50 tests. A test file with fewer than 50 tests, run on its own locally, therefore runs in a single process, while the full CI run forks as many workers as the runner has cores. The same guide warns that system tests "can be brittle and prone to failures from timing issues or UI changes."
How a Slower CI Runner Breaks the 2-Second Wait
Chrome can simulate a slower machine on demand. A local run on November 11, 2026 timed how long a client-rendered list took to appear after a click while Chrome's CPU was slowed down. The setup:
- Page - a local test page that builds and sorts 300,000 rows in JavaScript after a click, then renders 2,000 list items.
- Browser - headless Chrome 156.0.8078.80 on a Windows 11 laptop with an AMD Ryzen 7 7735HS (16 logical cores).
- Timing - a Node script clicks, then polls for all 2,000 items every 10 milliseconds, the same interval as Capybara's default_retry_interval, with 9 runs per rate.
- Slowdown - the Chrome DevTools Protocol command Emulation.setCPUThrottlingRate at rates 1, 2, 3 and 4.
The script logged each run's time in seconds, from the click to the full list:
rate 1x runs: 0.73, 0.73, 0.54, 0.59, 0.59, 0.61, 0.53, 0.53, 0.64
rate 2x runs: 1.75, 1.07, 1.69, 0.91, 0.79, 1.65, 0.94, 1.04, 1.19
rate 3x runs: 1.58, 1.51, 1.61, 2.38, 1.70, 2.47, 1.93, 2.19, 1.87
rate 4x runs: 2.36, 2.30, 2.19, 2.10, 1.90, 1.69, 1.93, 2.45, 2.28
At half speed, every run stayed under Capybara's 2-second default. At a third of normal speed, 3 of the 9 runs took longer than 2 seconds, and at a quarter speed 6 of the 9 did, with no change to the page or the check between runs. A repeat run about 20 minutes later, with other applications using the same laptop's CPU, was noisier still: the slowest run at half speed took 8.40 seconds.
Your app's timings will differ, and the throttle in Step 2 shows where its own threshold sits.
Step 1: Read the Failed System Test Before You Rerun It
Rails saves evidence when a system test fails. Its take_failed_screenshot helper runs in before_teardown and writes a PNG whose name starts with failures_ to tmp/screenshots, or to Capybara.save_path when you set it, and RAILS_SYSTEM_TESTING_SCREENSHOT_HTML=1 saves the page HTML beside it. RSpec system specs call the same Rails helper.
The GitHub Actions workflow that rails new generates already uploads tmp/screenshots as an artifact when the system test job fails. Download it before you click Re-run: a screenshot of a spinner, a half-rendered list or an open modal points at the cause sooner than the stack trace does. Then match the error in the log to its usual cause:
| What the log shows | What usually happened | Fix |
|---|---|---|
| Capybara::ElementNotFound from a find or click_on | The element appeared after the wait ran out, or the browser was still on another page. | Step 3, then the throttle in Step 2 |
| A count, text or path assertion that saw an older value | The test read the page once, before the update landed. | Step 3 |
| Selenium::WebDriver::Error::ElementClickInterceptedError | An overlay, a toast or an element still animating covered the target when the click landed. | Step 6 |
| Selenium::WebDriver::Error::StaleElementReferenceError | The element Capybara found was replaced, for example by a Turbo page change, before the test used it. | Step 6 |
| Your app's own exception, caused by "Your application server raised an error" | A request from the browser failed in the app server thread, often one the test never waited for. | Step 4 |
| A missing record, a duplicate key or extra rows in a list | State left behind by another test or another parallel worker. | Steps 4 and 7 |
| Selenium::WebDriver::Error::SessionNotCreatedError | The driver could not start the browser, often because the driver and browser versions do not match. | Step 5 |
Step 2: Reproduce Capybara Flaky Tests on Your Machine
Make the flake fail on your machine before you change anything, so you can tell whether a fix worked. Match CI's test order, parallelism and speed:
- Order - Minitest prints the seed at the top of a run, as in Run options: --seed 31252; pass it back with --seed. On RSpec (see the RSpec Ruby tutorial), rerun a random-order suite with the seed it printed, then add --bisect, which repeatedly runs subsets of the suite to isolate the minimal set of examples that reproduce the failure.
- Parallelism - set PARALLEL_WORKERS to the worker count CI used and run at least 50 tests, or lower config.active_support.test_parallelization_threshold, so the run forks the way CI does.
- Speed - slow Chrome down with the snippet below until the failure shows up.
- Repetition - with retries off, run the one test in a loop. A single green run proves little.
# Minitest: replay the order from the CI log
bin/rails test test/system --seed 31252
# RSpec: replay the order, then shrink it to the smallest failing set
bundle exec rspec spec/system --seed 31252
bundle exec rspec spec/system --seed 31252 --bisect
# Run one test 20 times and stop at the first failure
for i in $(seq 1 20); do bin/rails test test/system/checkout_test.rb:14 || break; done
On a local Chrome driver, Selenium's execute_cdp sends the same DevTools command the measurement above used, and network_conditions adds latency to every request. Both are methods of the Chromium drivers in selenium-webdriver, so this works with a local Chrome, not a remote grid session. With RSpec, put the same block in a config.before(type: :system) hook.
# test/application_system_test_case.rb
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
# Local reproduction only: SLOW_CPU=3 bin/rails test test/system
setup do
if ENV["SLOW_CPU"]
browser = page.driver.browser
browser.execute_cdp("Emulation.setCPUThrottlingRate", rate: ENV["SLOW_CPU"].to_f)
browser.network_conditions = { latency: 200 }
end
end
end
Step 3: Replace One-Time Reads With Capybara's Waiting Matchers
Capybara's finders and matchers run inside a retry loop: find, click_on, fill_in, assert_selector, assert_text, assert_current_path and their RSpec forms keep checking until they pass or the wait runs out, and return as soon as the condition holds. A value you read and hand to a plain assertion is checked once. The fix is a line-for-line swap:
# Before: each check reads the page once
click_on "Load orders"
assert_equal 3, all(".order").count # all waits for one match, then returns
assert_equal "Paid", find("#status").text # find waits, .text reads once
assert_equal orders_path, current_path # reads the URL once
refute page.has_css?(".spinner") # fails while the spinner is still there
# After: each check waits for its condition
click_on "Load orders"
assert_selector ".order", count: 3
assert_selector "#status", text: "Paid"
assert_current_path orders_path
assert_no_selector ".spinner"
# RSpec system specs: the same checks as matchers
expect(page).to have_css(".order", count: 3)
expect(page).to have_css("#status", text: "Paid")
expect(page).to have_current_path(orders_path)
expect(page).to have_no_css(".spinner")
The count case appears in GitLab's unhealthy tests guide for its own Rails test suite: reading a count "returns as soon as one match exists, racing the expected count," and the fix it gives is a waiting matcher with count:. Other calls skip the wait the same way:
- Negated predicates - refute page.has_css? and !page.has_xpath? wait for the element to appear before they can return false. The Capybara README calls the two forms "not equivalent" and says to always use the has_no form; RSpec's not_to have_css handles either.
- Scripts - evaluate_script and execute_script run once and return. Follow them with a matcher on the result you expect on the page.
- Fixed sleeps - sleep guesses how long the page takes: too short on a slow runner, wasted time on a fast one. Replace it with a matcher on the end state.
- Raw Selenium calls - code that drops to page.driver.browser leaves Capybara's retry loop and needs Selenium's own waits, covered in waits in Selenium Ruby.
When one step is slow by design, give that step more time instead of raising Capybara.default_max_wait_time for the whole suite, which makes every real failure slower to report:
# One slow query gets 10 seconds; everything else keeps the 2-second default
assert_selector "#monthly-report", wait: 10
using_wait_time(10) do
click_on "Export CSV"
assert_text "Export ready"
end
A system test that never needs JavaScript does not need a browser either. With driven_by :rack_test, Capybara drives the Rack app directly; the README notes that RackTest has no JavaScript support and does not start a server, so nothing in the test runs on another thread.
class ReceiptsTest < ApplicationSystemTestCase
driven_by :rack_test
end
Step 4: Keep Database State Inside Each System Test
Since Rails 5.1 the test thread and the app server thread share one database connection, which is why the Capybara README says Rails users "SHOULD be able to ignore" its section on transactions and database setup. Each system test runs in a transaction that Rails rolls back afterwards, and the rspec-rails docs say system specs run in a transaction too, so they do not need DatabaseCleaner.
Flaky failures in this layer come from state that lives outside that transaction:
- Old DatabaseCleaner setups - specs written before Rails 5.1 often turn use_transactional_fixtures off and pick a cleaning strategy by metadata. A system spec that lands on the transaction strategy keeps its records on a connection the app server does not use, which the Capybara README describes as data that is "invisible to Capybara." For system specs, keep use_transactional_fixtures = true and drop the cleaner.
- Data created before the transaction - the rspec-rails docs warn that before(:context) hooks "are invoked before the transaction is opened," so records created there are not rolled back and the next example sees them.
- Tests that opt out - a test case with self.use_transactional_tests = false has to clean up whatever it creates, as the Rails testing guide notes.
- Uploaded files - the Rails Active Storage guide points out that system tests roll back records without calling destroy, so attached files are never removed. Delete them in after_teardown.
- Background jobs - the same guide recommends the inline queue adapter in the test environment, so a purge job runs immediately instead of at an unknown time. A job that runs on a background thread or in another process can finish after the check that expects its result.
- Requests still in flight - after each test, Rails calls Capybara.reset_sessions!, which waits for the app server's pending requests, then raises any error the server hit with the cause "Your application server raised an error." A request the test never waited for, such as a poller, fails there; assert on what that request changes before the test ends.
# test/application_system_test_case.rb
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
def after_teardown
super
FileUtils.rm_rf(ActiveStorage::Blob.service.root)
end
end
# config/environments/test.rb
Rails.application.configure do
# Run jobs immediately so system tests can check their effects
config.active_job.queue_adapter = :inline
end
Step 5: Pin the Driver, Browser and Window Size in CI
The runner-images README says GitHub typically deploys software updates to its hosted runner images weekly, and its ubuntu-latest migration notice moves that label to Ubuntu 26.04 over several weeks from October 19, 2026, with completion planned by November 19, 2026. The workflow rails new generates runs its system-test job on ubuntu-latest, so an unchanged suite can start running on a new OS image mid-month.
Selenium Manager, part of selenium-webdriver, handles the driver and the browser for you. Per the Selenium Manager documentation, it has managed drivers since Selenium 4.6 and browsers since 4.11, and when you set browserVersion it uses that Chrome if it is installed or downloads that Chrome for Testing build if not. The older webdrivers gem asks anyone on Selenium 4.11 or later to stop requiring it, so take it out of the Gemfile.
- Runner label - pin runs-on: ubuntu-24.04 while you trial ubuntu-26.04 on a branch. The pin also protects jobs that start databases with Testcontainers, since the Docker daemon they reach comes with the runner image, as the Testcontainers CI guide explains.
- Browser version - set browserVersion from an environment variable, so CI names the Chrome major version and a browser update becomes a one-line change you can revert.
- Window size - Rails' driven_by defaults to a 1400 by 1400 screen, but rspec-rails does not read ApplicationSystemTestCase. Unless a spec calls driven_by, it runs on Capybara's :selenium_chrome_headless driver, which sets no window size, and a layout that collapses at a smaller width can hide the element a test clicks.
- Version in the log - print page.driver.browser.capabilities.browser_version once per run, so each failure can be matched to the browser that produced it.
# test/application_system_test_case.rb
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400] do |options|
options.browser_version = ENV["CHROME_VERSION"] if ENV["CHROME_VERSION"]
end
end
# spec/rails_helper.rb: give system specs the same driver and window size
RSpec.configure do |config|
config.before(type: :system) do
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
end
end
# .github/workflows/ci.yml (system-test job, trimmed)
system-test:
runs-on: ubuntu-24.04
env:
CHROME_VERSION: "156"
steps:
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bin/rails db:test:prepare test:system
Step 6: Turn Off Animations and Wait Out Turbo Page Changes
Selenium raises ElementClickInterceptedError when "the element receiving the events is obscuring the element that was requested clicked," in the words of its own error class: an overlay fading out, a toast, a sticky header or a drawer still sliding in. Capybara's Selenium driver treats that error as retryable, alongside StaleElementReferenceError and ElementNotInteractableError, so it retries the click until the wait runs out. An animation that overlaps a slow response can use up the whole wait on a slower runner.
Turn animations off for the test run. Capybara.disable_animation = true adds middleware to the app server Capybara starts: it injects CSS that sets transitions to none and animation durations and delays to zero into every HTML response, and sets jQuery.fx.off. Pass a CSS selector string instead of true to limit it to some elements, and set it in your test helper so it is in place before the first test runs.
# test/application_system_test_case.rb or spec/rails_helper.rb
Capybara.disable_animation = true
# Only some elements: Capybara.disable_animation = ".modal, .toast"
Turbo, which new Rails apps include through the turbo-rails gem, changes pages differently. The Turbo handbook describes Drive as performing link clicks and form submissions in the background, then replacing the page body when the response arrives, so until then the old page is still on screen and a find that matches an element on both pages can act on the old one. Assert something only the new page has before the next action:
click_on "Place order"
assert_text "Order was successfully created" # waits for Turbo to render the new page
click_on "View receipt"
Capybara waits for what appears on the page; it does not track network requests. For a page that fires many requests without a visible change, the capybara-lockstep gem (2.4.0, released October 2026) makes Capybara wait for pending AJAX requests, scripts and images before each command. Its README lists what it does not cover, including animations, WebSocket connections and work scheduled with setTimeout or setInterval.
Step 7: Give Each Parallel Test Worker Its Own Resources
Rails forks one worker per CPU core by default and creates a database for each, suffixed with the worker number (test-database-0, test-database-1), per the Rails testing guide. Everything else the app touches is shared unless you split it in parallelize_setup, which runs in each worker right after the fork:
- Active Storage files - with the Disk service, give each worker its own root folder, as the Rails Active Storage guide does, so one worker's cleanup does not delete another worker's uploads.
- Caches - the generated config/environments/test.rb uses :null_store, and a suite that switched to a real cache store needs a per-worker namespace.
- Redis keys and search indexes - prefix them with the worker number for the same reason.
- App server ports - leave Capybara.server_port unset, and Capybara picks a random free port for each worker's server.
- Driver path - with driven_by :selenium, Rails resolves the driver path before the tests start; its source comment says this avoids race conditions when using parallel tests.
# test/test_helper.rb
class ActiveSupport::TestCase
parallelize(workers: :number_of_processors)
parallelize_setup do |worker|
ActiveStorage::Blob.service.root = "#{ActiveStorage::Blob.service.root}-#{worker}"
# Give caches, Redis keys and search indexes a per-worker name here too
end
end
RSpec suites that run in parallel with the parallel_tests gem get one database per process through TEST_ENV_NUMBER, which is empty for the first process and then 2, 3 and so on. The gem's README suggests the same variable for separating caches and other services:
# config/database.yml
test:
database: myapp_test<%= ENV['TEST_ENV_NUMBER'] %>
The RSpec vs Minitest comparison covers how the two frameworks run tests in parallel, along with their syntax, mocks and Rails defaults.
Retries, Quarantine and Flaky Test Tracking Across Runs
Use retries to find flaky tests, and treat every pass on retry as a flaky test that still needs a fix. GitLab's guide describes the same policy for its suite: failing tests "are automatically retried once in a separate RSpec process," and a flaky test that blocks development on master gets quarantined.
- One retry, logged - rspec-retry (last release 0.6.2, from November 2019, on RubyGems) and minitest-retry rerun failed tests. Keep it to one extra attempt and record every test that needed it.
- An owner and a date - quarantine a test only with an issue that names who fixes it and by when, and review the quarantine list on a schedule so it shrinks.
- History across runs - one build cannot show which tests flake, because a flake rate needs many runs of the same test.
For suites that run on TestMu AI, Test Intelligence keeps that history. Per the flaky test analytics docs, it checks each test's last 10 runs, marks a test flaky when its flake rate goes above 20%, groups flaky tests into high, medium and low severity and can post a Slack alert the first time it detects one. It surfaces candidates for a person to fix, and the fix is still one of the steps above.
Running Rails System Tests on TestMu AI
A cloud grid gives each run a pinned browser and a record of what happened, while the fixes above stay in the test code. TestMu AI's Automation Cloud runs Selenium tests written in Ruby across 3,000+ browser and OS combinations and captures network logs, console logs, video, screenshots and command logs for every session.
- Remote driver - Rails' driven_by accepts browser: :remote and a url in its options, as the Rails testing guide shows, and the Capabilities Generator builds the browser, version and OS block for TestMu AI.
- LT Tunnel - the app server Capybara starts runs on the CI machine, and LT Tunnel lets a cloud browser reach locally hosted apps without a public URL, as described in testing locally hosted pages.
- Evidence per attempt - the video and the network and console logs of a failed attempt show what the page was doing when the check gave up, next to the screenshot Rails saved.
Note: Run your Capybara suites on pinned browser versions, with video, network and console logs for every session. Try TestMu AI free
Next Steps for Fixing Capybara Flaky Tests
Fixing Capybara flaky tests starts with the most recent failure: download its screenshot, replay its seed, and run the test in a loop at a third of Chrome's normal speed until it fails on your machine. Fix the first one-time read or leaked record you find, then rerun the loop with retries off before you trust the fix.
Once the suite holds, follow how to run Selenium tests with Ruby on TestMu AI to run it on pinned browsers with a record of every session. The Playwright flaky tests and Cypress flaky tests guides cover the same causes for teams that run more than one framework. For native Android and iOS apps, the Appium flaky tests guide matches each failure to the layer behind it, from waits to the Appium session and the device.
Author
Himanshu Sheth is the Director of Marketing (Technical Content) at TestMu AI, with over 8 years of hands-on experience in Selenium, Cypress, and other test automation frameworks. He has authored more than 130 technical blogs for TestMu AI, covering software testing, automation strategy, and CI/CD. At TestMu AI, he leads the technical content efforts across blogs, YouTube, and social media, while closely collaborating with contributors to enhance content quality and product feedback loops. He has done his graduation with a B.E. in Computer Engineering from Mumbai University. Before TestMu AI, Himanshu led engineering teams in embedded software domains at companies like Samsung Research, Motorola, and NXP Semiconductors. He is a core member of DZone and has been a speaker at several unconferences focused on technical writing and software quality.
Reviewer
Srinivasan Sekar is Director of Engineering at TestMu AI, where he leads engineering and open-source initiatives behind the Selenium and Appium automation grid and owns TestMu AI's MCP Server. A committer to Appium and a contributor to Selenium, WebdriverIO, Taiko, and AppiumTestDistribution, he brings over 15 years of experience in quality engineering and open-source technologies. He is the author of the Apress book 'The MCP Standard: A Developer's Guide to Building Universal AI Tools with the Model Context Protocol,' a Certified Kubernetes and Cloud Native Associate, and an international conference speaker. Before TestMu AI he spent over eight years at Thoughtworks as a Principal Consultant and Quality Architect. Srinivasan holds a B.Tech in Information Technology from Anna University.
Capybara Flaky Tests FAQs
Did you find this page helpful?
More Related Blogs
TestMu AI forEnterprise
Get access to solutions built on Enterprise
grade security, privacy, & compliance
- Advanced access controls
- Advanced data retention rules
- Advanced Local Testing
- Premium Support options
- Early access to beta features
- Private Slack Channel
- Unlimited Manual Accessibility DevTools Tests





