Playwright is often recognized for its consistent test results and strong performance. Recent versions of Rails include Playwright as a first-class system test driver.

This means that switching a Rails application from Selenium to Playwright could be (BUT not necessary) as simple as changing the driver configuration rather than rewriting the test suite, with Capybara serving as the layer in between.

This article documents the steps, as well as some lessons learnt.


Step 0 - Measure complexity

1) Check if there are any Selenium-native calls in your test codes. These don't exists on the Playwright driver, so mark them for translation / replacement later. If there is no hit, then the effort to migrate would be minimum.

grep -rn "driver\.browser\|execute_cdp\|\.native\|Selenium::\|switch_to\|manage\.window" test/system/

2) Find codes that continue to run, but could mean someting different. Nothing here is Selenium-native, these are ordinary Capybara calls that work on both drivers. What changes is what they hand back.

grep -rn "execute_script\|evaluate_script\|\[:class\]" test/system/
  • An absent attribute returns nil instead of "", and a JavaScript null comes back as {} instead of nil so [:class] and evaluate_script hits fail quietly, or pass for the wrong reason. 
  • execute_script is in that list for a different reason: it returns nothing useful and behaves identically on both drivers, but it is usually where Selenium workarounds hide. Each hit is worth re-reading to ask whether it is still needed at all.

Step 1: Swap the gem

 group :test do
   gem "capybara"
+  gem "capybara-playwright-driver"
   gem "mocha"
-  gem "selenium-webdriver"
   gem "simplecov", require: false
 end

Then runs bundle install


Step 2: Add Node, and pin it to the gem

1) Check the playwright version required by the gem.

bundle exec ruby -e 'require "playwright"; puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION'
# => 1.62.1

2) Pin the exact version in package.json

{
  "name": "your-app",
  "private": true,
  "comment": "Node is here only to supply the Playwright CLI that playwright-ruby-client drives over a pipe. Assets are still importmap no bundler. The version must match Playwright::COMPATIBLE_PLAYWRIGHT_VERSION in the playwright-ruby-client gem.",
  "devDependencies": {
    "playwright": "1.62.1"
  }
}

Run npm install and add /node_modules/ to git ignore if it hasn't been added.


Step 3: Install the browser

npx playwright install chromium

This downloads a Playwright-managed Chromium.


Step 4: Swap the driver

In your system test case class, replace the driven_by :selenium line with:

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :playwright, screen_size: [ 1400, 1400 ], options: {
    browser_type: :chromium,
    headless: true
  }
end

Step 5: The "translation" work

This is the whole of the real work. Some samples:

page.driver.browser.manage.window.resize_to(w, h) page.current_window.resize_to(w, h)
page.driver.browser.switch_to.active_element.attribute("id") page.evaluate_script("document.activeElement.id")
page.execute_script("arguments[0].click()", el.native) el.click
find("html")[:class].include?("dark") assert_selector "html.dark"
execute_cdp("Page.addScriptToEvaluateOnNewDocument", source:) playwright_page.add_init_script(script: source)
execute_cdp("Network.setBlockedURLs", urls: [...]) playwright_page.route(/pattern/, handler)

 


Step 6: Decide whether Chrome still belongs in your CI image

The google-chrome-stable often exists in the CI image as a dependency for Selenium driver. It can be removed if the only consumer is Selenium driver. In the sample below, google-chrome-stable is also used by Ferrum, so it must be kept.

- name: Install packages
  # google-chrome-stable is for Ferrum (the html_to_pdf tool renders with it
  # in a unit test); Playwright brings its own Chromium for system tests.
  run: sudo apt-get update && sudo apt-get install ... google-chrome-stable ...

Step 7: Update CI

Four steps, inserted after Ruby setup and before the test run:

- name: Set up Node
  uses: actions/setup-node@v7
  with:
    node-version: 24
    cache: npm

- name: Install the Playwright CLI
  run: npm ci

- name: Cache Playwright browsers
  uses: actions/cache@v6
  with:
    path: ~/.cache/ms-playwright
    key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}

- name: Install Chromium for Playwright
  run: npx playwright install --with-deps chromium

Step 8: Verify

Run the full suite, then run the system tests again on fixed seeds:

bin/rails test
bin/rails test test/system --seed 1234
bin/rails test test/system --seed 4242

Additional Information

1) Two failure modes that are completely silent

# page.route takes its handler positionally, with two arguments.

# Wrong — the block is ignored, and so is a one-argument lambda
playwright_page.route(/example\.com/) { |route| route.abort }

# Right
playwright_page.route(/example\.com/, ->(route, _request) { route.abort })
# evaluate_script returns {}, not nil, for a JavaScript null

# Fails — the value is {}
assert_nil page.evaluate_script("localStorage.getItem('darkMode')")

# Works — assert the null in the page, not in Ruby
assert page.evaluate_script("localStorage.getItem('darkMode') === null")

2) Attribute semantics changed

Selenium returned "" for an absent attribute; Playwright returns nil. Anything shaped like find("html")[:class].include?("dark") is affected.

You should convert these to selector assertions:

# Before
html = find("html")
assert html[:class].include?("dark")

# After
assert_selector "html.dark"