webdriver_pump

Build Status

This shard is a Page Object Model lib, built on top of selenium.cr. It's a crystal port of ruby's watir_pump. Heavily inspired by SitePrism.

Installation

  1. Add the dependency to your shard.yml:
dependencies:
  webdriver_pump:
    github: bwilczek/webdriver_pump
  1. Run shards install

Basic usage

require "webdriver_pump"

# define the page model, for example:
class GreeterPage < WebdriverPump::Page
  url "https://bwilczek.github.io/watir_pump_tutorial/greeter.html"
  element :header, { action: :text, locator: -> { root.find_element(:xpath, "//h1") } }
  element :fill_name, { action: :send_keys, locator: {id: "name"} }
  element :submit, { action: :click, locator: {id: "set_name"} }
  element :greeting, { action: :text, locator: {id: "greeting"} }
end

# use it in specs, for example:
# where `session` is an instance of Selenium::Session
describe "Page without components" do
  it "operates on Selenium::Elements" do
    GreeterPage.new(session).open do |p|
      p.header.should eq "Greeter app"
      p.fill_name "Crystal"
      p.submit
      p.greeting.should eq "Hello Crystal!"
    end
  end
end

Documentation

Please refer to this chapter to learn how to define your Page Objects, and use it from your tests.

For complete code documentation please visit project's GitHub Pages.

Overview

WebdriverPump provides a DSL (implemented as macros) to describe the Page Object Model. It's a very close port of Ruby's WatirPump gem. There are some subtle differences in the implementation, but the core concepts remain the same:

Page

Describes the page under test. Inherits from Component, so please familiarize yourself with that class as well to fully understand what Page is capable of. This section covers only the differences from the base Component class.

url macro

Declares the full URL to the page under test.

class GitHubUserPage < WebdriverPump::Page
  url "https://github.com/bwilczek"
end

describe "Page's URL" do
  it "navigates to given URL" do
    GitHubUserPage.new(session).open do |p|
      session.url.should eq "https://github.com/bwilczek"
    end
  end
end

URL can be parameterized:

class GitHubUserPage < WebdriverPump::Page
  url "https://github.com/{user}"
end

describe "Page's URL" do
  it "navigates to given parameterized URL" do
    GitHubUserPage.new(session).open(params: {user: "bwilczek"}, query: {repo: "webdriver_pump""}) do |p|
      session.url.should eq "https://github.com/bwilczek?repo=webdriver_pump"
    end
  end
end

#open

Navigates to Page's URL and executes given block in the scope of the page.

class GitHubUserPage < WebdriverPump::Page
  url "https://github.com/bwilczek"
  element :user_nickname, { locator: { xpath:, "//span[@itemprop='additionalName']") } }
end

describe "Page's URL" do
  it "navigates to given URL" do
    GitHubUserPage.new(session).open do |page|
      page.class.should eq GitHubUserPage
      page.user_nickname.class.should eq Selenium::Element
      page.user_nickname.text.should eq "bwilczek"
    end
  end
end

#use

Similar to #open, but does not perform the navigation - assumes that the page is already open. Useful when the navigation is triggered by an action on a different page.

GitHubUserPage.new(session).open do { |p| p.navigate_to_repo("webdriver_pump") }

GitHubRepoPage.new(session).use do |p|
  p.class.should eq GitHubRepoPage
end

#loaded?

Predicate method denoting if page is ready to be interacted with.

In most cases creation of this method will not be required, since WebDriver itself checks if page's resources have been loaded. Only in case of more complex pages, that heavily rely on parts loaded dynamically over XHR providing of custom loaded? criteria might be necessary.

class GitHubUserPage < WebdriverPump::Page
  url "https://js-heavy.com"
  element :created_by_xhr, { locator: {id: "content"} }

  def loaded?
    created_by_xhr.displayed?
  end
end

Component

Components are the foundation of WebdriverPump models. They abstract out certain sub-trees of the page's DOM tree into crystal classes and hide the underlying HTML behind the business oriented API.

Pages are the top-level components, that abstract out the complete page (DOM sub-tree starting at //body).

Components can be nested, and grouped into collections.

They are declared inside their parent components using element macro, with a class parameter, that refers to crystal class, a child of WebdriverPump::Component (NOT a CSS class).

#initialize (constructor)

Usually invoked implicitly by the element(s) macro.

Accepts two parameters:

Example of explicit usage:

class OrderItemDetails < WebdriverPump::Component
  # omitted for brevity
end

class OrderPage < WebdriverPump::Page
  # omitted for brevity

  def [](name)
    node = root.find_element(:xpath, ".//div[@class='item' and contains(text(), '#{name}')]")
    OrderItemDetails.new(session, node)
  end
end

OrderPage.new(session).open do |order|
  order["Rubber hammer, 2kg"].class.should eq OrderItemDetails
end

#session

Reference to associated Selenium::Session instance.

#root

Mounting point of current component in the DOM tree. Type: Selenium::Element.

For Pages it points to //body.

#wait

Reference to WebdriverPump::Wait module. Usage:

# with default settings
wait.until { condition_is_met }

# with custom settings
wait.until(timeout: 19, interval: 0.3) { other_condition_is_met }

# global config (optional)
WebdriverPump::Wait.timeout = 10    # default = 15
WebdriverPump::Wait.interval = 0.5  # default = 0.2

element macro

A DSL macro to declare Elements located inside given component.

class MyPage < WebdriverPump::Page
  url "http://example.org"

  # synopsis:
  # element :name : Symbol, params : NamedTuple

  # examples
  # locate and return Selenium::Element
  element :title1, { locator: {xpath: ".//div[@role='title']"} }
  # equivalent of:
  def title1
    root.find_element(:xpath, ".//div[@role='title']")
  end

  # locate Selenium::Element and perform action (invoke method) on it at once
  element :title2, { locator: {xpath: ".//div[@role='title']"}, action: :text }
  # equivalent of:
  def title2
    root.find_element(:xpath, ".//div[@role='title']").text
  end

  # locate Selenium::Element and use it as a mounting point for another component
  element :title3, { locator: {xpath: ".//div[@class='user_details']"}, class: UserDetails }
  # equivalent of:
  def title3
    node = root.find_element(:xpath, ".//div[@role='title']")
    UserDetails.new(session, node)
  end
end
locator

Required parameter. Locator of the Element in the DOM tree. Allowed formats are:

action

Symbol, name of Elements method to be executed.

class

Component class. If provided the Element located using locator will be the mounting point for the component of given class.

elements macro

A DSL macro to declare a collection of Elements inside given component.

class CollectionIndexedByName(T) < WebdriverPump::ComponentCollection(T)
  def [](name)
    ret = find { |el| el.name == name }
    raise "Component with name='#{name}' not found" unless ret
    ret
  end
end

class OrderItem < WebdriverPump::Component
  element :name, { action: :text, locator: {css: ".name"} }
end

class OrderPage < WebdriverPump::Page
  elements :raw_order_items, { locator: {xpath: ".//li"} }

  elements :order_items, {
    locator: {xpath: ".//li"},
    class: OrderItem,
    collection_class: CollectionIndexedByName(OrderItem)
  }
end

OrderPage.new(session).open do |page|
  page.raw_order_items.class.should eq Array(Selenium::Element)
  page.raw_order_items[0].class.should eq Selenium::Element

  page.order_items.class.should eq CollectionIndexedByName(OrderItem)
  page.order_items["Rubber hammer, 2kg"].should eq OrderItem
end
locator

Required parameter. Same rules as for element macro, but returns Array(Element).

class

Optional Component class to wrap each of the collection's elements.

collection_class

Optional ComponentCollection class to wrap the whole collection. Useful to introduce more descriptive ways of accessing elements.

Form helper macros

WebDriver API itself does not provide methods to easily set and get values of HTML form elements. This is where WebdriverPump's form helper macros come handy.

form_element

A macro that generates getter and setter methods for common HTML form elements.

class LoginPage < WebdriverPump::Page
  form_element :username, { class: TextField, locator: {name: "username"} }

  # iis equivalent of:
  def username
    # some logic that gets the value of given TextField
  end

  def username=(val)
    # some logic that sets the value of given TextField to val
  end
end

LoginPage.new(session).open do |page|
  page.username = "Bob"
  page.username.should eq "Bob"
end

The supported form elements are:

| FormElement | expected locator | value type | value | |-----------------|------------------|---------------|---------------------------------------| | TextField | ElementLocator | String | content of the input field | | TextArea | ElementLocator | String | content of the text area |
| RadioGroup | ElementsLocator | String | label of the checked input element | | CheckboxGroup | ElementsLocator | Array(String) | labels of the checked input elements | | Checkbox | ElementLocator | Bool | label of the checked input element | | SelectList | ElementLocator | String | label of the selected option element | | MultiSelectList | ElementLocator | Array(String) | labels of the checked option elements |

Please bear in mind that some of them require a locator for a single DOM node (e.g. TextField for a <input type="text" ...>), while other ones require a locator for multiple DOM nodes, that consist the form element (e.g. CheckboxGroup for a collection of <input type="checkbox" name="countries[] ...">).

fill_form

This macro acts as a wrapper for calling multiple form_element setters at once.

Let's consider the following example:

class LoginPage < WebdriverPump::Page
  form_element :username, { class: TextField, locator: {name: "username"} }
  form_element :password, { class: TextField, locator: {name: "password"} }
  element :submit_form, { locator: {id: "submit"}, action: :click }

  fill_form :login, { submit: :submit_form, fields: [:username, :password] }
  # equivalent of:
  def login(params)
    self.username = params[:username]
    self.password = params[:password]
    submit_form
  end
end

# Usage:
LoginPage.new(session).open do |page|
  page.login(username: "bob", password: "secret")
end

fill_form macro expects the following parameters:

form_data

This macro acts as a wrapper for calling multiple form_element getters at once. It returns a NamedTuple with keys being the getter method names, and values the results that they return.

Let's consider the following example:

class SummaryPage < WebdriverPump::Page
  form_element :title, { class: TextField, locator: {name: "title"} }

  # form_data doesn't require `form_element`s - it will work with all instance methods that don't require arguments
  element :header { locator: {xpath: "../h1"}, action: :text }

  form_data :summary, { fields: [:title, :header] }
  # equivalent of:
  def summary
    {
      title: self.title,
      header: self.header
    }
  end
end

# Usage:
SummaryPage.new(session).open do |page|
  summary = page.summary
  summary[:title].should eq page.title
  summary[:header].should eq page.header
end

form_data macro expects the following parameters:

Development roadmap

Contributing

  1. Fork it (https://github.com/bwilczek/webdriver_pump/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Test your changes, add relevant specs
  4. Commit your changes (git commit -am 'Add some feature')
  5. Push to the branch (git push origin my-new-feature)
  6. Create a new Pull Request

Contributors