Matthew Lang avatar

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.

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.