Skip to main content

Smartest vs Capybara

Capybara is a brilliant pillar of Rails browser testing. One DSL — visit, click_button, assert_selector — covers a huge range of edge-case browser scenarios, and you can swap the underlying browser by changing the driver (Selenium, capybara-playwright-driver, and more).

The friction shows up on async-heavy modern pages: SPAs, Turbo Streams, modal animations, "wait until the loading spinner disappears." Tests get flaky, and tuning Capybara waits to fix them becomes ongoing maintenance.

The recommended escape hatch is capybara-playwright-driver's with_playwright_page, which exposes a real Playwright Page and Playwright web-first assertions inside a block. That block solves the stability problem, but the resulting nested-callback style feels disproportionate when all you wanted was a slightly more stable browser test.

Smartest replaces the Capybara-based stack entirely. Thanks to pytest-style fixtures, writing |page:| is enough to get a Playwright Page already wired for browser testing — web-first assertions just work, with no wrapper to opt into. Because Smartest does not load spec/ or test/, it coexists with your existing Capybara suite, so migration can be gradual rather than a rewrite.

At a Glance

ConcernCapybaraSmartest
Browser APIDSL: visit, fill_in, click_button, assert_selectorPlaywright: page.goto, page.get_by_role, expect(page).to have_text
Wait strategyImplicit Capybara waits, tunable with using_wait_timePlaywright web-first assertions retry until match
Browser driversSwap drivers (Selenium, capybara-playwright-driver, …)Direct Playwright via playwright-ruby-client
Access to raw Playwrightpage.driver.with_playwright_page do |p| ... end blockJust page: keyword fixture, always a Playwright Page
Test setupRSpec let / before, Minitest setupClass-based fixtures requested by keyword
Best fitSimple synchronous flowsAsync-heavy pages where stability and customization matter

When Capybara still wins

For a synchronous flow with no async surprises — a login form, a checkout that posts and redirects, a settings page that re-renders the whole document — Capybara is excellent and Smartest does not give you enough leverage to justify a rewrite.

Capybara
scenario "a user logs in" do
visit new_session_path
fill_in "Email", with: "alice@example.com"
fill_in "Password", with: "secret"
click_button "Log in"

expect(page).to have_content("Welcome, Alice")
end

This test is short, the DSL reads naturally, and there is little async timing for waits to get wrong. Keep it.

When Smartest pays off

The case for migrating shows up when stability matters beyond Capybara's defaults — modal animations, network-driven content, Turbo Streams, SPA routing, push notifications, retried polls. Inside capybara-playwright-driver, the recommended response is to drop into a Playwright Page with with_playwright_page:

Capybara + capybara-playwright-driver
scenario "the user sees the new comment after submission" do
visit post_path(post)

page.driver.with_playwright_page do |playwright_page|
playwright_page.locator("textarea[name='comment']").fill("Looks good")
playwright_page.locator("button[type='submit']").click
playwright_page.locator(".toast--success").wait_for(state: "visible")

expect(playwright_page.get_by_text("Looks good")).to be_visible
end
end

This works, but the test now lives inside a nested callback. Some lines speak Capybara (visit, page.driver), others speak Playwright (playwright_page....), and you have to remember which side you are on. Window resize becomes Capybara.current_session.driver.resize_window_to(...). Methods don't always map cleanly (click_button becomes click_on). Every test that needs stability acquires another layer of nesting.

In Smartest the same scenario is written directly against a Playwright Page, with no with_playwright_page wrapper:

Smartest
test("the user sees the new comment after submission") do |page:, post:|
page.goto(post_path(post))

page.locator("textarea[name='comment']").fill("Looks good")
page.locator("button[type='submit']").click

expect(page.locator(".toast--success")).to be_visible
expect(page.get_by_text("Looks good")).to be_visible
end

page is a Playwright Page, supplied by the generated RailsSystemTestFixture (see Test a Rails app locally). Web-first assertions like have_text and be_visible retry until the page reaches the asserted state — which is exactly what auto-waiting gave you inside with_playwright_page, without the wrapper.

DSL Reference

The browser API translates one-to-one between the two stacks:

CapybaraSmartest + Playwright
visit root_pathpage.goto("/")
fill_in "Email", with: user.emailpage.get_by_label("Email").fill(user.email)
click_button "Log in"page.get_by_role("button", name: "Log in").click
click_link "Profile"page.get_by_role("link", name: "Profile").click
assert_text "Dashboard"expect(page.get_by_text("Dashboard")).to be_visible
assert_selector "h1", text: "Users"expect(page.locator("h1")).to have_text("Users")
assert_no_text "Error"expect(page.get_by_text("Error")).not_to be_visible
assert_current_path "/dashboard"expect(page).to have_url(/\/dashboard\z/)
using_wait_time(10) { ... }Web-first assertions auto-retry; tune the Playwright expect timeout
Capybara.current_session.driver.resize_window_to(1280, 800)page.set_viewport_size(width: 1280, height: 800)

Test setup and stubbing

Capybara tests typically share state through RSpec let / before:

Capybara + RSpec
let(:suspended_user) { create(:user, :suspended) }

before do
allow_any_instance_of(ApplicationController)
.to receive(:current_user)
.and_return(suspended_user)
end

scenario "suspended user sees account restriction page" do
visit dashboard_path
expect(page).to have_content("Your account is suspended")
end

In Smartest the setup is a fixture and a stub, and the test declares the fixture it depends on:

Smartest
class ApplicationFixture < Smartest::Fixture
fixture :suspended_user do
create(:user, :suspended)
end

fixture :suspended_user_page do |page:, suspended_user:|
simple_stub_any_instance_of(ApplicationController, :current_user) do
suspended_user
end
page
end
end

test("suspended user sees account restriction page") do |suspended_user_page:|
suspended_user_page.goto("/dashboard")
expect(suspended_user_page.get_by_text("Your account is suspended")).to be_visible
end

The dependency is visible at the test boundary, and Smartest resets the stub during fixture teardown.

When Smartest is a good fit

Smartest is worth considering when:

  • existing system tests are flaky on async-heavy pages and Capybara waits are hard to tune
  • you already reach for with_playwright_page in many tests
  • you want Playwright web-first assertions to be the default, not a wrapper
  • setup dependencies should be visible at the test boundary

Capybara may stay the better fit when:

  • tests are short, synchronous flows where the DSL reads naturally
  • the team prefers visit / fill_in / assert_selector and is not hitting flakiness
  • the suite leans on Capybara-specific helpers, drivers, or matchers you do not want to replace

Moving One Test at a Time

Smartest does not load spec/ or test/, so Capybara-based system tests stay where they are. Add Smartest tests under smartest/, and start by moving just the tests that hurt:

spec/system/
login_spec.rb ← stays in Capybara
dashboard_spec.rb ← stays in Capybara
smartest/
test_helper.rb
fixtures/
rails_system_fixture.rb
application_fixture.rb
comment_submission_test.rb ← rewritten in Smartest
push_failure_test.rb ← rewritten in Smartest

Move the most fragile tests first. The login flow can stay in Capybara indefinitely.