PageEz
PageEz is a tool to define page objects with Capybara.
Installation
Add the gem to your Gemfile
:
gem "page_ez"
Usage
Define a page object:
class TodosIndex < PageEz::Page
has_one :active_list, "section.active ul" do
has_many :items do
has_one :name, "span[data-role=todo-name]"
has_one :checkbox, "input[type=checkbox]"
def mark_complete
checkbox.click
end
end
end
def active_todo_names
items.map { _1.name.text }
end
has_one :completed_list, "section.complete ul" do
has_many :items do
has_one :name, "span[data-role=todo-name]"
has_one :checkbox, "input[type=checkbox]"
def mark_incomplete
checkbox.click
end
end
end
end
Use your page object:
it "manages todos state when completing" do
user = create(:user)
create(:todo, name: "Buy milk", user:)
create(:todo, name: "Buy eggs", user:)
sign_in_as user
todos_index = TodosIndex.new
expect(todos_index.active_todo_names).to eq(["Buy milk", "Buy eggs"])
todos_index.active_list.item_matching(text: "Buy milk").mark_complete
expect(todos_index.active_todo_names).to eq(["Buy eggs"])
todos_index.active_list.item_matching(text: "Buy eggs").mark_complete
expect(todos_index.active_todo_names).to be_empty
todos_index.completed_list.item_matching(text: "Buy milk").mark_incomplete
expect(todos_index.active_todo_names).to eq(["Buy milk"])
todos_index.completed_list.item_matching(text: "Buy eggs").mark_incomplete
expect(todos_index.active_todo_names).to eq(["Buy milk", "Buy eggs"])
end
has_one
You can define accessors to individual elements (matched with Capybara's find
):
class BlogPost < PageEz::Page
has_one :post_title, "header h2"
has_one :body, "section[data-role=post-body]"
has_one :published_date, "time[data-role=published-date]"
end
# generates the following methods:
blog_post = BlogPost.new
blog_post.post_title # => find("header h2")
blog_post.has_post_title? # => has_css?("header h2")
blog_post.has_no_post_title? # => has_no_css?("header h2")
blog_post.body # => find("section[data-role=post-body]")
blog_post.has_body? # => has_css?("section[data-role=post-body]")
blog_post.has_no_body? # => has_no_css?("section[data-role=post-body]")
blog_post.published_date # => find("time[data-role=published-date]")
blog_post.has_published_date? # => has_css?("time[data-role=published-date]")
blog_post.has_no_published_date? # => has_no_css?("time[data-role=published-date]")
blog_post.post_title(text: "Writing Ruby is Fun!") # => find("header h2", text: "Writing Ruby is Fun!")
blog_post.has_post_title?(text: "Writing Ruby is Fun!") # => has_css?("header h2", text: "Writing Ruby is Fun!")
blog_post.has_no_post_title?(text: "Writing Ruby is Boring") # => has_no_css?("header h2", text: "Writing Ruby is Boring")
The methods defined by PageEz can be passed additional options from Capybara. Refer to documentation for the following methods:
has_many
You can define accessors to multiple elements (matched with Capybara's all
):
class TodosIndex < PageEz::Page
has_many :todos, "ul li span[data-role=todo-name]"
end
# generates the following methods:
todos_index = TodosIndex.new
todos_index.todos # => all("ul li span[data-role=todo-name]")
todos_index.has_todos? # => has_css?("ul li span[data-role=todo-name]")
todos_index.has_no_todos? # => has_no_css?("ul li span[data-role=todo-name]")
todos_index.todo_matching(text: "Buy milk") # => find("ul li span[data-role=todo-name]", text: "Buy milk")
todos_index.has_todo_matching?(text: "Buy milk") # => has_css?("ul li span[data-role=todo-name]", text: "Buy milk")
todos_index.has_no_todo_matching?(text: "Buy milk") # => has_no_css?("ul li span[data-role=todo-name]", text: "Buy milk")
todos_index.todos.has_count_of?(number) # => has_css?("ul li span[data-role=todo-name]", count: number)
todos_index.has_todos_count?(number) # => has_css?("ul li span[data-role=todo-name]", count: number)
todos_index.todos.has_any_elements? # => has_css?("ul li span[data-role=todo-name]")
todos_index.todos.has_no_elements? # => has_no_css?("ul li span[data-role=todo-name]")
The methods defined by PageEz can be passed additional options from Capybara. Refer to documentation for the following methods:
has_many_ordered
This mirrors the has_many
macro but adds additional methods for accessing
elements at a specific index.
class TodosIndex < PageEz::Page
has_many_ordered :todos, "ul[data-role=todo-list] li"
end
# generates the base has_many methods (see above)
# in addition, it generates the ability to access at an index. The index passed
# to Ruby will be translated to the appropriate `:nth-of-child` (which is a
# 1-based index rather than 0-based)
todos_index.todo_at(0) # => find("ul[data-role=todo-list] li:nth-of-type(1)")
todos_index.has_todo_at?(0) # => has_css?("ul[data-role=todo-list] li:nth-of-type(1)")
todos_index.has_no_todo_at?(0) # => has_no_css?("ul[data-role=todo-list] li:nth-of-type(1)")
todos_index.todo_at(0, text: "Buy milk") # => find("ul[data-role=todo-list] li:nth-of-type(1)", text: "Buy milk")
The methods defined by PageEz can be passed additional options from Capybara. Refer to documentation for the following methods:
contains
This provides a shorthand for delegating methods from one page object to another, flattening the hierarchy of page objects and making it easier to interact with application-level componentry.
class SidebarModal < PageEz::Page
base_selector "div[data-role=sidebar]"
has_one :sidebar_heading, "h2"
has_one :sidebar_contents, "section[data-role=contents]"
end
class PeopleIndex < PageEz::Page
contains SidebarModal
has_many :people_rows, "ul[data-role=people-list] li" do
has_one :name, "span[data-role=person-name]"
has_one :edit_link, "a", text: "Edit"
end
def change_person_name(from:, to:)
people_row_matching(text: from).edit_link.click
within sidebar_contents do
fill_in "Name", with: to
click_on "Save Person"
end
end
end
By default, this delegates all methods to an instance of the page object. If
you prefer to delegate a subset of the methods, you can do so with the only
option:
class SidebarModal < PageEz::Page
base_selector "div[data-role=sidebar]"
has_one :sidebar_heading, "h2"
has_one :sidebar_contents, "section[data-role=contents]"
end
class PeopleIndex < PageEz::Page
contains SidebarModal, only: %i[sidebar_contents]
end
The equivalent functionality could be achieved with:
class SidebarModal < PageEz::Page
base_selector "div[data-role=sidebar]"
has_one :sidebar_heading, "h2"
has_one :sidebar_contents, "section[data-role=contents]"
end
class PeopleIndex < PageEz::Page
has_one :sidebar_modal, SidebarModal
delegate :sidebar_contents, to: :sidebar_modal
end
Using Methods as Dynamic Selectors
In the examples above, the CSS selectors are static.
However, there are a few different ways to define has_one
, has_many
, and
has_many_ordered
elements as dynamic.
class TodosIndex < PageEz::Page
has_one :todo_by_id
def todo_by_id(id:)
"[data-model=todo][data-model-id=#{id}]"
end
end
# generates the same methods as has_one (see above) but with a required `id:` keyword argument
todos_index = TodosIndex.new
todos_index.todo_by_id(id: 5) # => find("[data-model=todo][data-model-id=5]")
todos_index.has_todo_by_id?(id: 5) # => has_css?("[data-model=todo][data-model-id=5]")
todos_index.has_no_todo_by_id?(id: 5) # => has_no_css?("[data-model=todo][data-model-id=5]")
The first mechanism declares the has_one :todo_by_id
at the top of the file,
and the definition for the selector later on. This allows for grouping multiple
has_one
s together for readability.
The second approach syntactically mirrors Ruby's private_class_method
:
class TodosIndex < PageEz::Page
has_one def todo_by_id(id:)
"[data-model=todo][data-model-id=#{id}]"
end
# or
def todo_by_id(id:)
"[data-model=todo][data-model-id=#{id}]"
end
has_one :todo_by_id
end
In either case, the method needs to return a CSS string. PageEz will generate
the corresponding predicate methods as expected, as well (in the example above,
#has_todo_by_id?(id:)
and #has_no_todo_by_id?(id:)
For the additional methods generated with the has_many_ordered
macro (e.g.
for has_many_ordered :items
, the methods #item_at
and #has_item_at?
), the
first argument is the index of the element, and all other args will be passed
through.
class TodosList < PageEz::Page
has_many_ordered :items do
has_one :name, "[data-role='title']"
has_one :checkbox, "input[type='checkbox']"
end
def items(state:)
"li[data-state='#{state}']"
end
end
This would enable usage as follows:
todos = TodosList.new
expect(todos.items(state: "complete")).to have_count_of(1)
expect(todos.items(state: "incomplete")).to have_count_of(2)
expect(todos).to have_item_at(0, state: "complete")
expect(todos).not_to have_item_at(1, state: "complete")
expect(todos).to have_item_at(0, state: "incomplete")
expect(todos).to have_item_at(1, state: "incomplete")
expect(todos).not_to have_item_at(2, state: "incomplete")
One key aspect of PageEz is that page hierarchy can be codified and scoped for interaction.
class TodosList
has_many_ordered :items, "li" do
has_one :name, "span[data-role=name]"
has_one :complete_button, "input[type=checkbox][data-action=toggle-complete]"
end
end
# generates the following method chains
todos_list = TodosList.new
todos_list.items.first.name # => all("li").first.find("span[data-role=name]")
todos_list.items.first.has_name? # => all("li").first.has_css?("span[data-role=name]")
todos_list.items.first.has_no_name?(text: "Buy yogurt") # => all("li").first.has_no_css?("span[data-role=name]", text: "Buy yogurt")
todos_list.items.first.complete_button.click # => all("li").first.find("input[type=checkbox][data-action=toggle-complete]").click
# and, because we're using has_many_ordered:
todos_list.item_at(0).name # => find("li:nth-of-type(1)").find("span[data-role=name]")
todos_list.item_at(0).has_name? # => find("li:nth-of-type(1)").has_css?("span[data-role=name]")
todos_list.item_at(0).has_no_name?(text: "Buy yogurt") # => find("li:nth-of-type(1)").has_no_css?("span[data-role=name]", text: "Buy yogurt")
todos_list.item_at(0).complete_button.click # => find("li:nth-of-type(1)").find("input[type=checkbox][data-action=toggle-complete]").click
Base Selectors
Certain components may exist across multiple pages but have a base selector from which all interactions should be scoped.
This can be configured on a per-object basis:
class ApplicationHeader < PageEz::Page
base_selector "header[data-role=primary]"
has_one :application_title, "h1"
end
Page Object Composition
Because page objects can encompass as much or as little of the DOM as desired, it's possible to compose multiple page objects.
Composition via DSL
class Card < PageEz::Page
has_one :header, "h3"
end
class PrimaryNav < PageEz::Page
has_one :home_link, "a[data-role='home-link']"
end
class Dashboard < PageEz::Page
has_many_ordered :metrics, "ul.metrics li" do
has_one :card, Card
end
has_one :primary_nav, PrimaryNav, base_selector: "nav.primary"
end
Manual Composition
class Card < PageEz::Page
has_one :header, "h3"
end
class PrimaryNav < PageEz::Page
has_one :home_link, "a[data-role='home-link']"
end
class Dashboard < PageEz::Page
has_many_ordered :metrics, "ul.metrics li" do
def card
# passing `self` is required to scope the query for the specific card
# within the metric when nested inside `has_one`, `has_many`, and
# `has_many_ordered`
Card.new(self)
end
end
def primary_nav
# pass the element `Capybara::Node::Element` to scope page interaction when
# composing at the top-level PageEz::Page class
PrimaryNav.new(find("nav.primary"))
end
end
With the following markup:
<nav class="primary">
<ul>
<li><a data-role="home-link" href="/">Home</a></li>
</ul>
</nav>
<ul class="metrics">
<li><h3>Metric 0</h3></li>
<li><h3>Metric 1</h3></li>
<li><h3>Metric 2</h3></li>
</ul>
<ul class="stats">
<li><h3>Stat 1</h3></li>
<li><h3>Stat 2</h3></li>
<li><h3>Stat 3</h3></li>
</ul>
One could then interact with the card as such:
# within a spec file
visit "/"
dashboard = Dashboard.new
expect(dashboard.primary_nav).to have_home_link
expect(dashboard.metric_at(0).card.header).to have_text("Metric 0")
Review page object composition within the composition specs.
Configuration
Logger
Configure PageEz's logger to capture debugging information about which page objects and methods are defined.
PageEz.configure do |config|
config.logger = Logger.new($stdout)
end
Pluralization Warnings
Use of the different macros imply singular or plural values, e.g.
has_one :todos_list, "ul"
has_many :cards, "li[data-role=card]"
By default, PageEz allows for any pluralization usage regardless of macro. You can configure PageEz to either warn (via its logger) or raise an exception if pluralization doesn't look to align. Behind the scenes, PageEz uses ActiveSupport's pluralization mechanisms.
PageEz.configure do |config|
config.on_pluralization_mismatch = :warn # or :raise, nil is the default
end
Collisions with Capybara's RSpec Matchers
Capybara ships with a set of RSpec matchers, including:
have_title
have_link
have_button
have_field
have_select
have_table
have_text
By default, if any elements are declared in PageEz that would overlap
with these matchers (e.g. has_one :title, "h3"
), PageEz will raise an
exception in order to prevent confusing errors when asserting via predicate
matchers (since PageEz will define corresponding has_title?
and
has_no_title?
methods).
You can configure the behavior to warn (or do nothing):
PageEz.configure do |config|
config.on_matcher_collision = :warn # or nil, :raise is the default
end
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run
rake spec
to run the tests. You can also run bin/console
for an interactive
prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To
release a new version, update the version number in version.rb
, and then run
bundle exec rake release
, which will create a git tag for the version, push
git commits and the created tag, and push the .gem
file to
rubygems.org.
Feature Tests
This uses a test harness for Rack app generation called AppGenerator
, which
handles mounting HTML responses to endpoints accessible via GET
. In tests,
call build_page
with the markup you'd like and it will mount that response to
the root of the application.
page = build_page(<<-HTML)
<form>
<input name="name" type="text" />
<input name="email" type="text" />
</form>
HTML
To drive interactions with a headless browser, add the RSpec metadata :js
to
either individual it
s or describe
s.
Roadmap
- [x] Verify page object interactions work within
within
- [ ] Define
form
syntax - [ ] Define
define
syntax (FactoryBot style) - [x] Nested/reference-able page objects (from
define
syntax, by symbol, or by class name)
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/joshuaclayton/page_ez. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the PageEz project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.