Page Object Model library for Crystal. A port (kind of) of Ruby's WatirPump
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
spec
src
.editorconfig
.gitignore
.travis.yml
LICENSE
README.md
shard.yml

README.md

webdriver_pump

Build Status

This shard is a Page Object Model lib, built on top of selenium-webdriver-crystal. It's a crystal port of ruby's watir_pump.

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::WebElements" 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:

  • Nestable, reusable components, to build elegant APIs
  • Element actions, to automatically generate simple, one liner methods (like wrappers for click)
  • Page scoping, so that it's immediately known what page is currently being tested
  • Form helpers, to operate HTML form elements with ease (WebDriver doesn't deliver here)
  • Decorated collections, to access element/component collections with descriptive keys

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::WebElement
      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:

  • @session : Selenium::Session
  • @root : Selenium::WebElement

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::WebElement.

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 WebElements located inside given component.

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

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

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

  # locate Selenium::WebElement 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::WebElement 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 WebElement in the DOM tree. Allowed formats are:

  • 1 element NamedTuple with key in (:id, :name, :tag_name, :class_name, :css, :link_text, :partial_link_text, :xpath), and a respective value.
  • a Proc returning WebElement, e.g. -> { root.find_element(:id, "user") }, -> { some_wrapper_element.find_element(:id, "user") }
action

Symbol, name of WebElements method to be executed.

class

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

elements macro

A DSL macro to declare a collection of WebElements 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::WebElement)
  page.raw_order_items[0].class.should eq Selenium::WebElement

  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(WebElement).

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:

  • Symbol name of the method to be generated
  • NamedTuple with the following parameters
    • fields - (required) Array(Symbol) - list of setters to be invoked
    • submit - (optional) Symbol - name of the method to be executed after all setters
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:

  • Symbol name of the method to be generated
  • NamedTuple with the following parameters
    • fields - (required) Array(Symbol) - list of methods to be invoked and their results returned

Development roadmap

  • Page without Components
  • Declare raw WebDriver elements with webdriver locators
  • Declare raw WebDriver elements with lambdas
  • Declare actions on WebDriver elements
  • Declare reusable Components
  • Collections of elements
  • Collections of Components
  • Nest Components
  • Wait for AJAX-driven Components to be ready to interact
  • ComponentCollection class
  • Fill in complex forms: RadioGroups, SelectLists
  • Parametrize Page url
  • Support loaded? predicate for Pages
  • Port WatirPump's form helpers
  • Form helper for file upload (?)
  • Introduce Exception classes
  • Update README
  • Update code documentation

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