Capybara
Capybara drives a browser (real or simulated) through your app the way a user would — clicking links, filling in forms, asserting on visible content. It plugs into RSpec, Minitest, or Cucumber, and powers Rails’ built-in system tests.
Drivers
| Driver | Speed | JavaScript | Notes |
|---|---|---|---|
rack_test |
fast | no | default; parses HTML directly, no real browser |
selenium_chrome_headless |
slower | yes | real headless Chrome |
cuprite |
fast-ish | yes | headless Chrome via CDP, no Selenium server needed |
# Rails system tests, config/environments or test_helper.rb
Capybara.javascript_driver = :selenium_chrome_headless
Use rack_test (the default) whenever the page doesn’t need JS — it’s an order of magnitude faster. Switch to a JS-capable driver only for the specific tests that need it.
Navigating
visit "/posts"
visit post_path(post)
click_link "Edit"
click_button "Save"
click_on "Save" # matches either a link or a button
Filling in forms
fill_in "Title", with: "Hello world"
fill_in "post[title]", with: "Hello world" # by name attribute
select "Published", from: "Status"
choose "Public" # radio button
check "Subscribe to newsletter" # checkbox
uncheck "Subscribe to newsletter"
attach_file "Avatar", "spec/fixtures/avatar.png"
Capybara matches form fields by label text, name, id, or placeholder — label text is the most resilient since it also verifies accessibility (a field with no associated label will fail to match).
Finders & scoping
find("h1").text
find("#post_123")
find(".post", text: "Hello")
all(".post").count
within ".sidebar" do
click_link "Archive"
end
within_fieldset "Address" do
fill_in "City", with: "London"
end
within scopes all subsequent finders/actions to inside that element — essential when the same text/label appears more than once on a page.
Matchers / assertions
expect(page).to have_content("Post created")
expect(page).to have_css("h1", text: "Hello")
expect(page).to have_selector(".post", count: 3)
expect(page).to have_link("Edit")
expect(page).to have_button("Save", disabled: true)
expect(page).to have_current_path(post_path(post))
expect(page).not_to have_content("Error")
These are assertions that wait (see below) — always prefer them over page.text.include?(...), which checks the DOM once, immediately, with no retry.
Waiting for asynchronous content
Capybara automatically retries matchers for Capybara.default_max_wait_time (default 2s) before failing — this is what makes it usable with JS-driven pages without manual sleep:
Capybara.default_max_wait_time = 5
expect(page).to have_content("Loaded") # polls up to the wait time
Never reach for sleep 1 to “wait for JS” — it’s both slower and flakier than letting Capybara’s built-in waiting matchers do it.
Rails system tests
Rails wires Capybara in for you via ActionDispatch::SystemTestCase:
require "application_system_test_case"
class PostsTest < ApplicationSystemTestCase
test "creating a post" do
visit new_post_path
fill_in "Title", with: "Hello"
click_button "Create Post"
assert_text "Post was successfully created"
end
end
Minitest-flavored assertions (assert_text, assert_selector) work the same way as the RSpec matchers above — same waiting behavior underneath.
Debugging a failing test
save_and_open_page # dumps current HTML to a file and opens it in a browser
save_and_open_screenshot # JS drivers only — actual screenshot
page.text # raw visible text, useful in a debugger/pry session
System test failures also save a screenshot automatically to tmp/screenshots/ in Rails by default.