DEV Community

Christian Sedlmair
Christian Sedlmair

Posted on • Edited on

System Testing

Overview

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 capybara within group test
  • add gem playwright-ruby-client within group test
  • npm i --save-dev playwright (unlike docs do not install it by npx 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


Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Here is a Code Example

Mainly importand docs, for writing tests, may be the API Coverage and Page class

Overview

Top comments (0)