Do you know Playwright?
Playwright is emerging as the new standard for front-end testing.
It is faster and more recent than Selenium, and its syntax is clearer than that of Capybara-DSL. See the discussion here: https://discuss.rubyonrails.org/t/rails-7-capybara-speedup-possible/87571.
Notice
Playwright can be used with the Capybara DSL, which is ideal for existing projects. Initially, I tried to keep the old tests in the Capybara DSL while writing the new ones in the Playwright DSL within the same project. However, this approach was unsuccessful, so ultimately I rewrote everything in Playwright.
As recommended, it is configured to create a new instance at the start of each run and open/close a new window for every test.
Playwright within Rails/RSpec
Derived from Docs
Setup
- Remove all Selenium-gems
- add gem
capybarawithin grouptest - add gem
playwright-ruby-clientwithin grouptest -
npm i --save-dev playwright(unlike docs do not install it bynpx i ...because it would not install dependencies)
a File like playwright_helper.rb
(for a project with devise authentication, it uses the log_in method)
require 'rails_helper'
require 'capybara'
require 'playwright'
require 'support/playwright_utils'
RSpec.configure do |config|
config.include Devise::Test::IntegrationHelpers, type: :system
config.include PlaywrightUtils
# Docs https://playwright-ruby-client.vercel.app/docs/article/guides/rails_integration_with_null_driver
video_dir = '/tmp/rails-system-test-videos'
config.before(:all, type: :system) do
FileUtils.rm_rf(video_dir)
FileUtils.mkdir_p(video_dir)
# Launch Playwright and browser once for all tests
@playwright = Playwright.create(playwright_cli_executable_path: './node_modules/.bin/playwright')
# Use browser_type to access Firefox
@browser = @playwright.playwright.firefox.launch(headless: !playwright_debug_mode?)
end
config.after(:all, type: :system) do
# Close browser and Playwright after all tests
@browser&.close
@playwright&.stop
end
class CapybaraNullDriver < Capybara::Driver::Base
def needs_server?
true
end
end
module PlaywrightPageScreenshotFix
def save_screenshot(*_args)
# No-op: videos already saved on failure.
end
end
Playwright::Page.include(PlaywrightPageScreenshotFix)
Capybara.register_driver(:null) { CapybaraNullDriver.new }
config.around(driver: :null) do |example|
driven_by :null
# Create a new context and page for each test
@browser.new_context(
record_video_dir: video_dir,
baseURL: Capybara.current_session.server.base_url
) do |context|
context.enable_debug_console! if playwright_debug_mode?
context.set_default_timeout(2000.0)
@page = context.new_page
# Capture JavaScript console logs
@browser_console_logs = []
@page.on('console', lambda do |message|
@browser_console_logs << { type: message.type, text: message.text }
end)
@page.on('pageerror', lambda do |exception|
@browser_console_logs << { type: 'exception', text: exception.message }
end)
# LOGIN PROCESS
sign_in @your_desired_user # => set your user there
example.run
# Save video only if the test fails
if example.exception
puts "file://#{@page.video.path}"
else
FileUtils.rm(@page.video.path) if @page.video&.path
end
end
end
end
support/playwright_helpers.rb
Playwright::Page.class_eval do
def path
URI(url).path
end
end
# Helper methods for Playwright in system-ssr-tests specs.
module PlaywrightHelpers
def playwright_debug_mode?
# detects if a test is running in debug mode
# works for rubymine
@playwright_debug ||= $LOADED_FEATURES.join.include?('ruby-debug-ide')
end
def playwright_breakpoint
@page.pause if playwright_debug?
end
def clear_js_logs
@browser_console_logs = []
end
def wait_for_js_log(matcher, timeout: 2000, type: nil)
wait_for_js_log_condition(
"browser log to #{matcher.is_a?(Regexp) ? 'match' : 'equal'} «#{matcher}»",
timeout: timeout
) do
js_logs(type: type).any? { |text| text.match?(matcher) || text == matcher }
end
end
def wait_for_js_debug(matcher, timeout: 2000)
wait_for_js_log_condition(
"browser debug to #{matcher.is_a?(Regexp) ? 'match' : 'equal'} «#{matcher}»",
timeout: timeout
) do
js_debugs.any? { |text| matcher === text || text == matcher }
end
end
def wait_for_js_info(matcher, timeout: 2000)
wait_for_js_log_condition(
"browser info to #{matcher.is_a?(Regexp) ? 'match' : 'equal'} «#{matcher}»",
timeout: timeout
) do
js_infos.any? { |text| matcher === text || text == matcher }
end
end
def wait_for_js_warning(matcher, timeout: 2000)
wait_for_js_log_condition(
"browser warning to #{matcher.is_a?(Regexp) ? 'match' : 'equal'} «#{matcher}»",
timeout: timeout
) do
js_warnings.any? { |text| text.match?(matcher) || text == matcher }
end
end
def wait_for_js_error(matcher, timeout: 2000)
wait_for_js_log_condition(
"browser error to #{matcher.is_a?(Regexp) ? 'match' : 'equal'} «#{matcher}»",
timeout: timeout
) do
js_errors.any? { |text| text.match?(matcher) || text == matcher }
end
end
def wait_for_js_exception(matcher, timeout: 2000)
wait_for_js_log_condition(
"browser exception to #{matcher.is_a?(Regexp) ? 'match' : 'equal'} «#{matcher}»",
timeout: timeout
) do
js_exceptions.any? { |text| text.match?(matcher) || text == matcher }
end
end
def js_logs(type: nil)
logs = if type.is_a?(Array)
invalid = type - ALLOWED_CONSOLE_TYPES
if invalid.any?
raise ArgumentError, "Invalid console type(s) in array: #{invalid.map(&:inspect).join(', ')}. " \
"Allowed: #{ALLOWED_CONSOLE_TYPES.map(&:inspect).join(', ')}"
end
@browser_console_logs.select { |log| type.include?(log[:type].to_sym) }
elsif type.present?
sym = type.to_sym
unless ALLOWED_CONSOLE_TYPES.include?(sym)
raise ArgumentError, "Invalid console type: #{type.inspect}. " \
"Allowed: #{ALLOWED_CONSOLE_TYPES.map(&:inspect).join(', ')} or nil"
end
@browser_console_logs.select { |log| log[:type] == type.to_s }
else
@browser_console_logs
end
logs.map { |l| l[:text] }
end
def js_debugs
js_logs(type: :debug)
end
def js_infos
js_logs(type: :info)
end
def js_errors
js_logs(type: :error)
end
def js_warnings
js_logs(type: :warning)
end
# Some implementations use "exception" type – keep if needed
def js_exceptions
js_logs(type: :exception)
end
def js_issues
js_logs(type: %i[error warning exception])
end
private
def wait_for_js_log_condition(notice, timeout: 2000, &condition)
start_ms = Time.now.to_f * 1000
loop do
return true if condition.call
elapsed_ms = (Time.now.to_f * 1000) - start_ms
if elapsed_ms > timeout
raise "Timeout: waited #{elapsed_ms.round} ms for #{notice}"
end
sleep 0.1
end
end
ALLOWED_CONSOLE_TYPES = %i[log debug info error warning exception].freeze
end
and a test:
require 'playwright_helper'
RSpec.describe "pw", type: :system do
it 'test' do
@page.goto(root_path)
@page.locator('body').wait_for(state: :visible)
# => without the .wait_for the test would not fail without the body-tag
end
end
Here is a Code Example
Mainly importand docs, for writing tests, may be the API Coverage and Page class
Top comments (0)