Skip to content

HOWTO dev selenium_vs_phantomjs

steveoro edited this page Jan 16, 2021 · 1 revision

HOWTO: Dev: choosing Selenium/Headless Chrome vs PhantomJS/Poltergeist

Quick recap / 'TLDR:

We are choosing Selenium/Headless Chrome as webdriver used for rendering Capybara feature specs over PhantomJS/Poltergeist, due to a more precise rendering and fidelity when the results are compared with an actual browser.

Also, testing with Headless Chrome on Selenium has been possible for a while now, and this allows even for multiple batches of tests to be run at once.

To overcome Selenium's webdriver current inability to edit or mess with the requests headers, in some occasions we use a custom Rack Middleware that once inserted in the Rack app-stack is able to patch-in any headers (such as Authorization) just before the driver takes in.

This allows us to emulate a more plenty range of browser behavior in all the feature specs.

References:

Issue:

PhantomJS with Poltergeist as Capybara Javascript webdriver has typically the advantage of being more versatile and offers, as example, the ability to edit/update/patch or retrieve easily the requests headers (which Selenium webdriver doesn't).

But, also, it has the drawback of being quite approximate during the rendering phase having several known bugs and issues that arise especially when using most of the current CSS/HTML/JavaScript techniques.

These rendering glitches may cause the inability for Capybara to find or click on elements due to the fact of being rendered as partially hidden or behind other DOM nodes.

PhantomJS used to be the de-facto way to go for automated testing because of it being the only way to do actual Headless browser testing. This has radically changed since Chrome version 65+, as well in all the other & latest Firefox versions.

Moreover, PhantomJS development has been suspended by its own author until further notice.

On the other side, Selenium offers excellent rendering but as a webdriver it doesn't allow request.headers editing or retrieval by design choice. (A questionable decision, but that is not going to change, according to it's authors.)

Although interacting with just "what is visible to the end-user" is a correct stand point, in several use-cases (*), having the ability "to mess with the request headers" is a plus even if it's just for a background operation preparing the actual use-case of the feature spec. So, it is sometimes indeed a limit from our point of view.

We've come around this issue by using a simple Rack Middleware to inject "what it is needed" into the request header, thus adopting Selenium for good.

(*) - (such as, for instance, injecting a valid JWT Authorization bearer)


Using Selenium as webdriver, part 1: configuration

To configure Selenium as webdriver for Capybara, do the following:

  • Add to the :test group of your Gemfile and update your bundle:
  gem 'capybara'
  gem 'selenium-webdriver' # support for Chrome
  gem 'geckodriver-helper' # support for Firefox
  gem 'webdrivers', '~> 4.0', require: false # tasks for drivers update
  • Edit your Capybara configuration file (or create one, for example as <APP_ROOT>/features/support/capybara.rb) and add any driver customization you may need, like the ones below:
Capybara.register_driver(:headless_firefox) do |app|
  browser_options = Selenium::WebDriver::Firefox::Options.new
  browser_options.args << '--headless'
  Capybara::Selenium::Driver.new( app, browser: :firefox, options: browser_options )
end

Capybara.register_driver(:headless_chrome) do |app|
  capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(
    chromeOptions: { args: %w[headless disable-gpu] }
  )
  Capybara::Selenium::Driver.new(app, browser: :chrome, desired_capabilities: capabilities)
end

# [Steve A.] (Selenium webdriver Firefox/headless currently has no real
# support for mobileEmulation, so no "headless_firefox_mobile" driver
# customization here.)

Capybara.register_driver(:headless_chrome_mobile) do |app|
  capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(
    chromeOptions: {
      args: %w[headless disable-gpu],
      mobileEmulation: { deviceName: "iPhone 6" }
    }
  )
  Capybara::Selenium::Driver.new( app, browser: :chrome, desired_capabilities: capabilities )
end

# Select one driver that has to be used for the tests.
# Chose a symbol either from the defaults
# (see: https://www.rubydoc.info/gems/capybara#Using_Capybara_with_RSpec)
# or use one of the registered custom versions above:

JS_DRIVER = :headless_chrome
# JS_DRIVER = :headless_chrome_mobile
# JS_DRIVER = :headless_firefox
# ...

Capybara.default_driver    = JS_DRIVER
Capybara.javascript_driver = JS_DRIVER
Capybara.current_driver    = JS_DRIVER

By changing the value of Capybara.current_driver it's possible to change the current driver even at runtime.

At design time, each defined symbol corresponds to a valid tag that can be used to change the current driver.

The example above has defined 3 tags: @headless_chrome, @headless_chrome_mobile & @headless_firefox.

By using one of these tags before any feature scenario you can force-change the current driver used accordingly.


Using Selenium as webdriver, part 2: editing headers

In order to be able to add/edit the request headers, do the following:

  1. Copy & save the rack middleware below as <APP_ROOT>/middleware/custom_header_patcher.rb

  2. Add the following initializer as <APP_ROOT>/config/initializers/custom_header_patcher.rb:

    if Rails.env.test?
      # Insert the CustomHeaderPatcher on top of the Rack stack:
      Rails.application.config.middleware.insert_before( 0, CustomHeaderPatcher )
    end

...And that is all.

Editing headers using the Middleware:

The CustomHeaderPatcher class below allows to add or replace any request.header by setting the desired value as an ENV variable just before or even during the feature spec execution.

The Middleware searches for any ENV entries that start with the constant text string stored in CustomHeaderPatcher::ENV_KEY_PREFIX.

If it finds any header with the prefix, it removes the prefix substituting it with HTTP_ while giving it as value the one stored in the ENV entry.

Example:

This will add a HTTP_AUTHORIZATION header to the request, using as value my_jwt_value:

ENV["#{ CustomHeaderPatcher::ENV_KEY_PREFIX }Authorization"] = my_jwt_value

The ENV variable once found is not consumed by the Middleware: this patched header value will remain stored inside the ENV binding as long as the context lives (typically just 1 scenario).

The CustomHeaderPatcher middleware:

=begin
= CustomHeaderPatcher
  - version:  1.0
  - author:   Steve A.

  Custom (& dumb) Rack middleware that allows to
  inject/patch any request header via ENV values.
=end
class CustomHeaderPatcher
  # Each desired ENV key will have to be prefixed by this text
  # in order to be considered as a processable Header to be
  # patched into the list of current request Heaxers.
  ENV_KEY_PREFIX = "HEADER_PATCH_".freeze unless defined?(ENV_KEY_PREFIX)


  # Init the Rack middleware app
  def initialize(app)
    @app = app
  end


  # Perform the call thread-safely on a +dup+ instance
  def call(env)
    Rails.logger.debug("-" * 80)
    Rails.logger.debug("***  CustomHeaderPatcher  ***".center(80))
    dup._call env
  end


  # Direct call (non-thread-safe)
  def _call(env)
    Rails.logger.debug("-" * 80)

# Check if there's any custom-patch header
    # set inside the ENV variable:
    if ENV && ENV.respond_to?(:keys)
      possible_keys    = ENV.keys.select { |key| key =~ /^#{ ENV_KEY_PREFIX }/ }
      possible_headers = ENV.select { |key, _value| possible_keys.include?(key) }
      # Extract actual header keys to be added
      # & log the resulting Hash:
      possible_headers.each do |key, value|
        actual_key = "HTTP_#{ key.split(ENV_KEY_PREFIX)[1].upcase }"
        Rails.logger.debug("Processing '#{key}' => '#{actual_key}'...")
        # Patch the extracted header:
        env[actual_key] = value
      end
    end
    Rails.logger.debug("-" * 80)

    # Pass-through call:
    @status, @headers, @response = @app.call(env)
    # Return the result:
    [@status, @headers, @response]
  end
end
Clone this wiki locally