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 "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"
}
}
3) Run npm install and add /node_modules/ to git ignore if it hasn't been added.
4) Bumping playwright-ruby-client requires bumping package.json, a test that guard this would be nice to have.
test "the pinned Playwright CLI matches what the gem speaks" do
pinned = JSON.parse(Rails.root.join("package.json").read)
.dig("devDependencies", "playwright")
assert_equal Playwright::COMPATIBLE_PLAYWRIGHT_VERSION, pinned
end
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,
default_timeout: 8, # seconds; matches Capybara.default_max_wait_time
default_navigation_timeout: 8
}
end
Step 5: The "translation" work
1) 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) |
2) The repos might also include codes which are no longer necessary. For example, helper method that retries clicking for several times to reduce the flackiness in Selenium.
def click_until(element, attempts: 3)
attempts.times do
element.click
return if yield
sleep 0.25
end
page.execute_script("arguments[0].click()", element) # JS fallback
return if yield
raise Capybara::ExpectationNotMet, "#{element.tag_name} never responded to a click"
end
3) While removing unecessary codes, do evaluate the true objectives. There are two kinds and they look identical:
- "…until the element responds." Playwright covers this. Delete it.
- "…until the application finished something." Playwright does not cover this.
An example of the second kind - "wait until a request the page made has been answered". This can be translated by using Playwright raw API:
def await_response(url_pattern, body: nil)
matched = lambda do |response|
next false unless response.url.match?(url_pattern)
next true unless body
response.request.post_data.to_s.match?(body)
end
page.driver.with_playwright_page do |playwright_page|
playwright_page.expect_response(matched) { yield }
end
end
...
apply = find("button", text: "Apply")
await_response(%r{/payments/validate}, body: /SAVE20/) { apply.click }
apply.click # now guaranteed to be after the first round trip
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
Note: Repos that already run Node in CI for something else (For example, lints ERB with herb), worth considering reuse the version already in the workflow.
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"
3) Keeping Capybara.disable_animation
- Under Selenium it was a correctness measure. Clicks do not land on an element still animating.
- Under Playwright it is a speed measure. Playwright refuses to act on an element that is not stable, so it waits the transition out and gets it right. Disabling animations improve test speed.