Selenium #
Selenium WebDriver is the industry standard for browser automation — it lets Ruby code control Chrome, Firefox, Safari, or Edge like a real user: opening pages, clicking buttons, filling forms, pressing Enter, and verifying displayed content. In Ruby, Selenium is used for two main purposes: integration testing (ensuring web application flows work from the user’s perspective) and web scraping (extracting data from pages that need JavaScript to render content). With the selenium-webdriver and capybara gems, Ruby has a mature browser automation ecosystem widely used in production Rails applications.
Selenium vs Capybara vs Ferrum #
selenium-webdriver (direct):
✓ Full control over the browser
✓ Suitable for complex web scraping
✗ Verbose API, needs lots of boilerplate code
✗ Less idiomatic for Rails testing
Capybara (on top of Selenium):
✓ Very expressive, readable DSL
✓ Perfectly integrated with RSpec and Rails
✓ Can switch drivers (Rack::Test, Selenium, Cuprite) without changing tests
✓ Automatic waiting — no explicit sleeps needed
✓ The de-facto standard for Rails integration testing
✗ The abstraction reduces direct control
Ferrum (Chrome DevTools Protocol):
✓ No ChromeDriver needed — talks directly to Chrome via CDP
✓ Faster than Selenium
✓ Screenshots, PDFs, network interception
✗ Chrome/Chromium only, not multi-browser
Installation #
# Install ChromeDriver (make sure the version matches your installed Chrome)
# macOS
brew install chromedriver
# Ubuntu
sudo apt install chromium-chromedriver
# Or let the webdrivers gem manage it automatically
gem install selenium-webdriver webdrivers
gem install capybara # for Rails integration testing
# Gemfile
gem 'selenium-webdriver', '~> 4.15'
gem 'webdrivers', '~> 5.3' # auto-download and manage drivers
# For Rails testing
group :test do
gem 'capybara', '~> 3.39'
gem 'selenium-webdriver', '~> 4.15'
gem 'webdrivers', '~> 5.3'
end
Getting Started with Selenium WebDriver #
require 'selenium-webdriver'
require 'webdrivers' # auto-manage ChromeDriver
# Create a Chrome driver instance
driver = Selenium::WebDriver.for(:chrome)
# Or headless (without showing the browser) — for CI/CD
options = Selenium::WebDriver::Chrome::Options.new
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1920,1080")
driver = Selenium::WebDriver.for(:chrome, options: options)
# Firefox
driver = Selenium::WebDriver.for(:firefox)
# Always close the browser when done
begin
driver.navigate.to("https://example.com")
puts driver.title
ensure
driver.quit
end
Browser Navigation #
driver = Selenium::WebDriver.for(:chrome, options: headless_options)
# Navigate to a URL
driver.navigate.to("https://ruby-lang.org")
driver.get("https://ruby-lang.org") # a shorter alias
# History navigation
driver.navigate.back
driver.navigate.forward
driver.navigate.refresh
# Page information
puts driver.current_url
puts driver.title
# Window size and position
driver.manage.window.maximize
driver.manage.window.resize_to(1440, 900)
driver.manage.window.position = Selenium::WebDriver::Point.new(0, 0)
# Fullscreen
driver.manage.window.full_screen
# Page screenshots
driver.save_screenshot("screenshot.png")
screenshot_base64 = driver.screenshot_as(:base64)
# Execute JavaScript
driver.execute_script("window.scrollTo(0, document.body.scrollHeight)")
title = driver.execute_script("return document.title")
puts title
# Wait for the page to finish loading
driver.manage.timeouts.page_load = 30 # max 30 seconds to wait for load
Finding Elements #
Selenium provides various strategies for finding elements on a page:
# find_element — find one element (raises if not found)
# find_elements — find all elements (returns an empty array if none)
# By ID — fastest and recommended
element = driver.find_element(id: "submit-button")
# By Name
element = driver.find_element(name: "email")
# By Class Name
element = driver.find_element(class: "btn-primary")
# By Tag Name
element = driver.find_element(tag_name: "h1")
# By CSS Selector — most flexible
element = driver.find_element(css: "#login-form .btn-submit")
element = driver.find_element(css: "input[type='email']")
element = driver.find_element(css: "ul.product-list > li:first-child")
# By XPath — the most powerful but most verbose
element = driver.find_element(xpath: "//button[@type='submit']")
element = driver.find_element(xpath: "//h1[contains(text(), 'Welcome')]")
element = driver.find_element(xpath: "//table//tr[3]/td[2]")
# By Link Text — for <a> elements
element = driver.find_element(link_text: "Login")
element = driver.find_element(partial_link_text: "Reg") # partial match
# Find all matching elements
all_products = driver.find_elements(css: ".product-card")
puts "Number of products: #{all_products.length}"
all_products.each do |card|
name = card.find_element(css: ".product-name").text
price = card.find_element(css: ".price").text
puts "#{name}: #{price}"
end
# Check whether an element exists without raising
def element_exists?(driver, css)
driver.find_elements(css: css).any?
rescue Selenium::WebDriver::Error::NoSuchElementError
false
end
Interacting with Elements #
# Element information
puts element.text # visible text
puts element.tag_name # "input", "button", "a", etc.
puts element.attribute("href") # HTML attribute value
puts element.attribute("class")
puts element.css_value("color") # CSS property value
puts element.displayed? # whether it's visible to the user
puts element.enabled? # whether it's active (not disabled)
puts element.selected? # for checkboxes/radios
# Click
button = driver.find_element(css: "#submit-button")
button.click
# Text input
email_input = driver.find_element(css: "input[name='email']")
email_input.send_keys("[email protected]") # type text
email_input.clear # clear the content
email_input.send_keys("[email protected]")
# Keyboard shortcuts
search_input = driver.find_element(css: "#search")
search_input.send_keys("ruby programming")
search_input.send_keys(:return) # press Enter
search_input.send_keys(:tab) # press Tab
# Key combinations
search_input.send_keys([:control, "a"]) # Ctrl+A (select all)
search_input.send_keys([:control, "c"]) # Ctrl+C (copy)
# Dropdowns / Selects
require 'selenium-webdriver'
city_select = Selenium::WebDriver::Support::Select.new(
driver.find_element(id: "city-picker")
)
city_select.select_by(:text, "Bandung") # select by visible text
city_select.select_by(:value, "bdg") # select by value
city_select.select_by(:index, 2) # select by index
puts city_select.first_selected_option.text # currently selected option
puts city_select.options.map(&:text).inspect # all options
# Checkboxes
checkbox = driver.find_element(id: "agree-terms")
checkbox.click unless checkbox.selected? # check if not already
# File uploads
file_input = driver.find_element(css: "input[type='file']")
file_input.send_keys(File.expand_path("~/documents/cv.pdf"))
The Actions API — Advanced Interactions #
# The Actions API for complex interactions: hover, drag-drop, right-click
action = driver.action
# Hover / Mouse over
element = driver.find_element(css: ".dropdown-menu")
action.move_to(element).perform
# Wait for the dropdown to appear
sleep 0.5
item = driver.find_element(css: ".dropdown-menu .first-item")
item.click
# Right-click (context menu)
action.context_click(element).perform
# Double click
action.double_click(element).perform
# Drag and Drop
source = driver.find_element(id: "draggable-item")
target = driver.find_element(id: "drop-zone")
action.drag_and_drop(source, target).perform
# Click at specific coordinates
action.move_to_location(500, 300).click.perform
# Scroll to an element
driver.execute_script("arguments[0].scrollIntoView(true)", element)
# or:
action.scroll_to_element(element).perform
# Hold Shift while clicking (for multi-select)
action.key_down(:shift).click(element2).key_up(:shift).perform
Waiting — Waiting for Elements to Be Ready #
This is the most important part of writing reliable Selenium code — always use waits instead of sleep:
require 'selenium-webdriver'
driver = Selenium::WebDriver.for(:chrome, options: options)
# Implicit Wait — wait up to N seconds for all element lookups
driver.manage.timeouts.implicit_wait = 10 # 10 seconds
# After this, every find_element automatically waits up to 10 seconds
# Explicit Wait — wait for a specific condition to be met
wait = Selenium::WebDriver::Wait.new(timeout: 15, interval: 0.5)
# Wait until an element exists in the DOM
element = wait.until { driver.find_element(css: "#search-results") }
# Wait until an element is visible
wait.until { driver.find_element(css: ".loading-spinner").displayed? == false }
# Wait for a custom condition
wait.until do
count = driver.find_elements(css: ".product-card").length
count > 0
end
# Wait for the URL to change (after a redirect)
wait.until { driver.current_url.include?("/dashboard") }
# Wait for the title to change
wait.until { driver.title.include?("Login Successful") }
# With an informative error message
wait = Selenium::WebDriver::Wait.new(
timeout: 15,
message: "The submit button didn't appear within 15 seconds"
)
button = wait.until { driver.find_element(css: "#btn-submit") }
# Expected Conditions (more expressive)
conditions = Selenium::WebDriver::Support::ExpectedConditions
wait.until { conditions.element_to_be_clickable(driver.find_element(id: "button")) }
Frames and Windows #
# FRAMES / IFRAMES — switch to a frame before interacting with its content
iframe = driver.find_element(tag_name: "iframe")
driver.switch_to.frame(iframe)
# Now you can interact with elements inside the iframe
driver.find_element(css: "#iframe-content").text
# Return to the main page from a frame
driver.switch_to.default_content
# Switch to a frame by index
driver.switch_to.frame(0) # the first frame
# WINDOWS / TABS — switch between windows
main_window = driver.window_handle
# Click a link that opens a new tab
driver.find_element(css: "a[target='_blank']").click
# Get all window handles
all_windows = driver.window_handles
puts "Total windows: #{all_windows.length}"
# Switch to the new window (the last tab)
driver.switch_to.window(all_windows.last)
puts "New tab URL: #{driver.current_url}"
# Close the current tab and return to the main window
driver.close
driver.switch_to.window(main_window)
# Alerts / Confirms / Prompts
driver.find_element(css: "#btn-delete").click
alert = driver.switch_to.alert
puts alert.text # "Are you sure you want to delete?"
alert.accept # click OK
# alert.dismiss # click Cancel
# Prompt with input
prompt = driver.switch_to.alert
prompt.send_keys("input_for_prompt")
prompt.accept
Web Scraping with Selenium #
Selenium is very useful for scraping pages that render content with JavaScript:
require 'selenium-webdriver'
require 'webdrivers'
require 'json'
options = Selenium::WebDriver::Chrome::Options.new
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--user-agent=Mozilla/5.0 (compatible; RubyBot/1.0)")
driver = Selenium::WebDriver.for(:chrome, options: options)
wait = Selenium::WebDriver::Wait.new(timeout: 15)
begin
driver.get("https://store.example.com/products")
# Wait for products to appear after JS rendering
wait.until { driver.find_elements(css: ".product-card").any? }
# Scroll down to load more (infinite scroll)
3.times do
driver.execute_script("window.scrollTo(0, document.body.scrollHeight)")
sleep 1.5
end
# Extract the data
product_list = driver.find_elements(css: ".product-card").map do |card|
{
name: card.find_element(css: ".name").text,
price: card.find_element(css: ".price").text.gsub(/[Rp.,\s]/, "").to_i,
rating: card.find_element(css: ".rating").text.to_f,
url: card.find_element(css: "a").attribute("href")
}
rescue Selenium::WebDriver::Error::NoSuchElementError
nil
end.compact
# Save to JSON
File.write("products.json", JSON.pretty_generate(product_list))
puts "Successfully scraped #{product_list.length} products"
ensure
driver.quit
end
Capybara — Idiomatic Integration Testing #
Capybara provides a much more comfortable DSL for Rails integration testing:
# spec/rails_helper.rb or spec/spec_helper.rb
require 'capybara/rails'
require 'capybara/rspec'
require 'selenium-webdriver'
require 'webdrivers'
# Headless Chrome driver configuration
Capybara.register_driver(:chrome_headless) do |app|
options = Selenium::WebDriver::Chrome::Options.new
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1440,900")
Capybara::Selenium::Driver.new(
app,
browser: :chrome,
options: options
)
end
Capybara.default_driver = :rack_test # for non-JS tests
Capybara.javascript_driver = :chrome_headless # for JS tests
Capybara.default_max_wait_time = 10 # auto-wait 10 seconds
Capybara.server = :puma, { Silent: true }
# spec/features/login_spec.rb
require 'rails_helper'
RSpec.describe "Login", type: :feature do
let(:user) { create(:user, email: "[email protected]", password: "password123") }
it "logs in successfully with correct credentials" do
visit login_path
fill_in "Email", with: user.email
fill_in "Password", with: "password123"
click_button "Sign In"
expect(page).to have_content("Welcome, #{user.name}!")
expect(page).to have_current_path(dashboard_path)
end
it "fails to log in with an incorrect password" do
visit login_path
fill_in "Email", with: user.email
fill_in "Password", with: "wrong_password"
click_button "Sign In"
expect(page).to have_content("Invalid email or password")
expect(page).to have_current_path(login_path)
end
it "redirects to the login page when not authenticated" do
visit dashboard_path
expect(page).to have_current_path(login_path)
end
end
# spec/features/checkout_spec.rb
RSpec.describe "Checkout Process", type: :feature, js: true do
# js: true → uses the chrome_headless driver
let(:user) { create(:user) }
let(:product) { create(:product, name: "Gaming Laptop", price: 15_000_000, stock: 5) }
before do
login_as(user, scope: :user) # with devise helpers
end
it "successfully checks out a product" do
visit product_path(product)
click_button "Add to Cart"
expect(page).to have_content("Product added successfully")
visit cart_path
click_button "Proceed to Payment"
expect(page).to have_content("Order Summary")
expect(page).to have_content("Gaming Laptop")
expect(page).to have_content("Rp 15.000.000")
fill_in "Recipient Name", with: "Rina Wijaya"
fill_in "Address", with: "Jl. Sudirman No. 1, Bandung"
select "Bank Transfer", from: "Payment Method"
click_button "Confirm Order"
# Wait for the redirect and confirmation
expect(page).to have_content("Order Created Successfully", wait: 10)
expect(Order.last.user).to eq(user)
end
end
Frequently Used Capybara Matchers #
# Content checks
expect(page).to have_content("The searched text")
expect(page).to have_text("This text")
# CSS selector checks
expect(page).to have_css(".element-class")
expect(page).to have_css("#element-id")
expect(page).to have_css("button", text: "Submit")
expect(page).to have_no_css(".error-message")
# Link checks
expect(page).to have_link("Click here")
expect(page).to have_link("Click", href: "/page")
# Button checks
expect(page).to have_button("Submit")
expect(page).to have_button("Submit", disabled: true)
# Field checks
expect(page).to have_field("Email")
expect(page).to have_field("Email", with: "[email protected]")
expect(page).to have_select("City", selected: "Bandung")
expect(page).to have_checked_field("Agree to terms")
# URL checks
expect(page).to have_current_path("/dashboard")
expect(page).to have_current_path(%r{/products/\d+})
# Capybara actions
visit "/page"
click_link "Register"
click_button "Submit"
fill_in "Email", with: "[email protected]"
select "Bandung", from: "City"
check "Agree"
uncheck "Newsletter"
choose "Pay by Transfer" # radio button
attach_file "ID Photo", Rails.root.join("spec/fixtures/photo.jpg")
The Page Object Model — Clean Test Structure #
The Page Object Model (POM) separates page details from test logic:
# spec/support/pages/login_page.rb
class LoginPage
include Capybara::DSL
def visit_page
visit login_path
self
end
def fill_email(email)
fill_in "Email", with: email
self
end
def fill_password(password)
fill_in "Password", with: password
self
end
def click_login
click_button "Sign In"
self
end
def login_with(email:, password:)
fill_email(email).fill_password(password).click_login
end
def error_message
find(".error-message").text
end
def successful?
page.has_content?("Welcome")
end
end
# spec/features/login_spec.rb
RSpec.describe "Login", type: :feature do
let(:login_page) { LoginPage.new }
let(:user) { create(:user, email: "[email protected]", password: "password123") }
it "logs in successfully" do
login_page
.visit_page
.login_with(email: user.email, password: "password123")
expect(login_page).to be_successful
end
it "fails with an incorrect password" do
login_page
.visit_page
.login_with(email: user.email, password: "wrong")
expect(login_page.error_message).to include("invalid")
end
end
Running in CI/CD #
# .github/workflows/test.yml
name: Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Install Chrome
uses: browser-actions/setup-chrome@latest
- name: Setup Database
run: bundle exec rails db:create db:schema:load
- name: Run System Tests
run: bundle exec rspec spec/features/
env:
RAILS_ENV: test
DATABASE_URL: postgresql://postgres:***@localhost/test_db
Summary #
- Use Capybara for Rails integration testing — its API is far more expressive than raw selenium-webdriver;
have_content,click_button,fill_inare very readable.- Headless Chrome for CI/CD — add
--headless=new --no-sandbox --disable-dev-shm-usageso it runs in environments without a display server.- Explicit Waits, not
sleep—Selenium::WebDriver::WaitorCapybara.default_max_wait_timeis more reliable thansleep;sleepmakes tests slow and still flaky.- CSS Selectors over XPath — easier to read and faster; use XPath only when CSS selectors can’t express the needed condition (e.g. “text containing”).
find_elementsdoesn’t raise errors — unlikefind_element; usefind_elements(...).any?to check element existence without exception risk.- The Page Object Model for growing test suites — separate DOM details (CSS selectors, navigation) from test logic; when a page changes, only the Page Object needs updating.
js: trueonly when JavaScript is needed — JS driver tests (Selenium) are far slower than Rack::Test; use them only for features that truly need JS.- Handle
StaleElementReferenceError— elements can become “stale” if the DOM updates after the element was found; re-find the element after DOM-changing actions.- Screenshots on test failures — configure Capybara to automatically screenshot when a test fails; very helpful for debugging flaky tests in CI.
- The
webdriversgem for driver management — automatically manages downloading and updating ChromeDriver/GeckoDriver; no manual installation needed.