Skip to content
Rémy Duthu Rémy Duthu
May 15, 2026 · 6 min read

RSpec system specs see an empty database. database_cleaner is why.

How Capybara's separate browser connection breaks transactional fixtures, the per-spec-type strategy that fixes the leak, and the modern Rails 7.1 alternative that makes database_cleaner optional.

A Rails app has 200 unit specs and 30 system specs. The unit specs all pass. The system specs all fail with Capybara::ElementNotFound: invalid credentials on the login form. The user fixture clearly created the user — User.count returns 1 right before the visit. The browser does not see the user. The browser is not lying. It is reading the database from a different connection that does not see the test’s transaction.

We see this pattern often enough on Mergify Test Insights that it earned its own slot in our flaky RSpec catalog. The cause is database_cleaner’s transactional strategy interacting with Capybara’s separate connection. The fix is a strategy decision per spec type.

What you see

# rails_helper.rb
RSpec.configure do |config|
  config.before(:suite) { DatabaseCleaner.strategy = :transaction }
  config.before(:each)  { DatabaseCleaner.start }
  config.append_after(:each) { DatabaseCleaner.clean }
end

# spec/features/checkout_spec.rb
feature "checkout", js: true do
  scenario "user signs in" do
    user = create(:user, email: "user@example.com", password: "secret")
    visit "/login"
    fill_in "Email", with: user.email
    fill_in "Password", with: "secret"
    click_button "Sign in"
    expect(page).to have_text("Welcome back") # fails
  end
end

User.count is 1 inside the spec. The browser submits the form. Rails’ login controller queries User.find_by(email: "user@example.com") and gets nil. The browser sees “invalid credentials.” The spec fails on a missing element.

The frustrating part: every part of the test is correct in isolation. The factory creates a user. The visit loads the login page. The form submission posts the credentials. The database has one user. None of those facts conflict — they each run on a different database connection.

Why transactional fixtures break under JS

The transactional strategy wraps every spec in a database transaction and rolls it back at the end. The transaction is held on the test process’s connection. Anything Rails-side that reads the database inside the spec sees the inserted user because it shares the connection.

A Capybara JS driver (Selenium, Cuprite, Playwright-Ruby) launches a real browser. The browser hits the Rails server over HTTP. The Rails server handles the request on a different process’s connection. That connection cannot see uncommitted data from the test’s connection. The user the spec created is invisible to the request the browser made.

Sequential database transactions are not visible across connections until they commit. The transactional strategy never commits — that is its whole purpose. So JS specs always see a database empty of whatever the test set up.

Pre-Rails-5.1, this was the canonical reason teams used database_cleaner with the truncation strategy for system specs. Rails 5.1+ ships use_transactional_tests with shared connection which makes the test connection visible to the server connection — but only if you opt in.

The fix that holds (database_cleaner version)

Pick a strategy per spec type. Transaction for everything except JS specs, where you switch to truncation:

RSpec.configure do |config|
  config.before(:suite) { DatabaseCleaner.clean_with(:truncation) }
  config.before(:each) { DatabaseCleaner.strategy = :transaction }
  config.before(:each, type: :feature) { DatabaseCleaner.strategy = :truncation }
  config.before(:each, js: true)       { DatabaseCleaner.strategy = :truncation }
  config.before(:each) { DatabaseCleaner.start }
  config.append_after(:each) { DatabaseCleaner.clean }
end

Truncation actually deletes rows after the spec, so the JS-driven request and the test process see the same data via committed transactions. Slower than transaction (deletes are linear in row count), faster than truncation everywhere (most specs do not need the full reset).

The metadata-driven approach above keeps unit specs on transaction, JS feature specs on truncation. Most teams tune the categories per their suite shape.

The Rails 7.1+ alternative

If you are on Rails 7.1 or newer, the framework ships a connection-shared transaction mode that makes JS-driven specs see the test transaction directly. You can drop database_cleaner entirely:

# spec/rails_helper.rb
RSpec.configure do |config|
  config.use_transactional_fixtures = true
end

Rails handles the cross-connection visibility automatically. Faster than truncation, no third-party gem. The catch: it does not work with system specs that hit a separate Rails server (multi-app setups, JavaScript that talks to a different service). For a single Rails app with Capybara, it is the right answer.

For older Rails, the database_cleaner pattern above is the standard.

Cleanup ordering still matters

append_after (not after) ensures DatabaseCleaner.clean fires after every other after hook. If you have an after(:each) that creates audit log rows after a transaction commits, normal after ordering can run them after the cleanup, leaving orphan data. append_after puts the cleanup at the end of the chain.

The lazy version:

config.after(:each) { DatabaseCleaner.clean }

works for most suites and breaks for the few that have audit-log-style after hooks. Use append_after and you stop worrying about the edge case.

How Mergify catches this before you ship

The signature of database_cleaner mismatches is consistent: feature specs with js: true fail with empty-database errors; the same specs pass when the JS driver is swapped for the rack-test driver. Test Insights tags failures by their spec metadata (type: :feature, js: true) and surfaces the cluster: “8 of 12 JS feature specs failed with ElementNotFound, 0 of 200 unit specs failed.” The pattern is unmistakable on the dashboard even when individual specs look like ordinary login flakes.

Quarantine kicks in once the pattern is clear, so the merge queue keeps moving while you flip the strategy per spec type (or upgrade to Rails 7.1’s shared connection).

Mergify spots cleanup-strategy mismatches by their metadata signature. Point it at your Rails suite — drop the native rspec-mergify gem in your Gemfile and you’re set.

More patterns like this

database_cleaner mismatches are one of the eight patterns in the flaky-tests-in-RSpec guide. The others are variants of the same theme: state that crosses specs because the cleanup did not run when expected. Order-dependent specs under random seed, lazy let versus let! surprises, Mocha stubs that forgot to verify, travel_to without travel_back. Different APIs, same shared-state trap.

The silver lining: cleanup-strategy choices are usually one config block, not a test rewrite.

Test Insights

Tired of flaky tests blocking your pipeline?

Test Insights detects flaky tests, quarantines them automatically, and tracks test health across your suite.

Try Test Insights

Recommended posts

Testing

Playwright route handlers fire only on requests they were registered before

May 13, 2026 · 5 min read

Playwright route handlers fire only on requests they were registered before

Why a `page.route()` call placed after `page.goto()` silently misses the request you wanted to intercept, and the registration-order rule that makes network mocks deterministic.

Rémy Duthu Rémy Duthu
Testing

regex101: A Practical Guide to the Regex Tester Engineers Actually Use

May 13, 2026 · 6 min read

regex101: A Practical Guide to the Regex Tester Engineers Actually Use

regex101 is the online regex tester most engineers reach for. Live matching, explained groups, multi-flavor support, and a permalink for every pattern. Full guide to the features that matter and a short cheat sheet.

Julien Danjou Julien Danjou
Testing

vi.mock hoists. Your closure variables do not.

May 11, 2026 · 5 min read

vi.mock hoists. Your closure variables do not.

Why a Vitest mock factory that references a stub object returns undefined, the vi.hoisted() escape hatch, and the import-order rule that keeps your mocks predictable.

Rémy Duthu Rémy Duthu