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_in are very readable.
  • Headless Chrome for CI/CD — add --headless=new --no-sandbox --disable-dev-shm-usage so it runs in environments without a display server.
  • Explicit Waits, not sleepSelenium::WebDriver::Wait or Capybara.default_max_wait_time is more reliable than sleep; sleep makes 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_elements doesn’t raise errors — unlike find_element; use find_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: true only 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 webdrivers gem for driver management — automatically manages downloading and updating ChromeDriver/GeckoDriver; no manual installation needed.

← Previous: ORM Adapter   Next: Articles & Resources →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact