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
nilinstead of"", and a JavaScriptnullcomes back as{}instead ofnilso[:class]andevaluate_scripthits fail quietly, or pass for the wrong reason. execute_scriptis 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"