What Are Page Objects?
Page objects are an organizing tool for UI test suites. They provide a place to identify the route to and elements on a page and to add methods to encapsulate paths through a page. Martin Fowler has a more formal description.
Suppose you’re testing a web app which has a component with a four-page flow, with a known happy path a user would normally work through. Using page objects, you could test that flow as:
def component_one_flow(user, interests, contacts) goto_component_one_start on_page('ComponentOne::StepOne').register(user) on_page('ComponentOne::StepTwo').add_interests(interests) on_page('ComponentOne::StepThree').add_contacts(contacts) on_page('ComponentOne::StepFour').approve end
If anything changes on one of those pages, you can make the change inside the page object, and the calling code can remain the same.
For small- to medium-sized web apps this might seem a nice-to-have, but for larger apps where you end up afraid to open features/step_definitions
or spec/features
because it’s impossible to find anything, this is a very useful pattern to introduce, or to start out with on the next project.
This ran quite long, so I’ve split it into two parts: in this first post I’ll describe setting up a page object framework, and in the second I’ll talk about implementation patterns.
Available Frameworks
In 2013 and 2014 I enjoyed working with Jeff Morgan’s page-object, which runs on watir-webdriver. (His book Cucumber and Cheese was my introduction to page objects.) Jeff Nyman’s Symbiont also runs on watir-webdriver and also looks quite interesting (as does his testing blog). There’s also Nat Ritmeyer’s SitePrism, running on Capybara.
Here’s a login page in all three:
Page-object
class LoginPage include PageObject page_url "/login" text_field(:username, id: 'username') text_field(:password, id: 'password') button(:login, id: 'login') def login_with(username, password) self.username = username self.password = password login end end
SitePrism
class Login < SitePrism::Page set_url "/login" element :username, "input#login" element :password, "input#password" element :login, "input[name='commit']" def login_with(username, password) self.username.set username self.password.set password login.click end end
Symbiont
class Login attach Symbiont url_is "/login" title_is "Login" text_field :username, id: 'user' text_field :calendar, id: 'password' button :login, id: 'submit' def login_with(username, password) self.username.set username self.password.set password login.click end end
The similarities are clear: in each case you can identify the URL, the elements, and methods that interact with those elements. And since it is the internal page object names for elements that are used in the methods, if (when) the css changes, the only line that needs to change in the page object is the declaration of the element.
What about differences? Page-object (here) and Symbiont (here) provide page factories, so that in page-object you can write
visit(LoginPage) do |page| page.login(username, password) end
or, even more compactly
visit(LoginPage).login(username, password)
where SitePrism is, somewhat more long-windedly:
page = Login.new page.load page.login(username, password)
SitePrism offers, in addition to element
, the named concepts of elements
(for a collection of rows, say) and section
(for a subset of elements on a page, or elements which are common across multiple pages, a navigation bar, say, which you can identify separately in a SitePrism::Section
object).
The further point that inclined me to try SitePrism (though with a mental note to add a page factory) was that when we were using page-object last year we sometimes needed to write explicit wrappers for waiting code because the implicit when_present
wait did not always seem to be reliable, and it looked as though Symbiont wrapped it the same way (here). I was curious to see if SitePrism (or its underlying driver) had a more robust solution.
Setting up SitePrism
So How Does It Handle Waiting?
By default, SitePrism requires explicit waits, so either wait_for_<element>
or wait_until_<element>_visible
. This has been challenged (issue#41, in which Ritmeyer explains that he likes having more fine-grained control over timing of objects than Capybara’s implicit waits allows), and a compromise has been implemented (issue#43), by which you can configure SitePrism to use Capybara’s implicit waits, so:
SitePrism.configure do |config| config.use_implicit_waits = true end
I’ve been using that, and it’s working so far.
Where to Put Them
On the earlier project we were using page objects from Cucumber, so we put them under features/page_objects
. The new project is larger and already has non-test-framework-specific code under both features
and spec
, some of which is called from the other framework’s directory, so it makes sense to create a common top-level directory for them. I called it test_commons
to make it clear at a glance through the top-level directories that it was test-related instead of app-related. Then page_objects
goes under that.
The disadvantage of the location is that we will need to load the files in explicitly. So we add this at test_commons/all_page_objects.rb
(approach and snippet both from Panayotis Matsinopoulos’s useful post):
page_objects_path = File.expand_path('../page_objects', __FILE__) Dir.glob(File.join(page_objects_path, '**', '*.rb')).each do |f| require f end
and in features/support/env.rb
we add:
require_relative '../../test_commons/all_page_objects'
and we’re away. (If/when we use them from RSpec features as well, we can require all_page_objects
in spec_helper.rb
too.)
Further Organization
Probably you’ll have some top-level or common pages, like login or dashboard, and others which belong to specific flows and components, like the four pages of “component one” in the first example. A login page could go in the top-level page objects directory:
# test_commons/page_objects/login.rb module PageObjects class Login < SitePrism::Page … end end
whereas the four pages of component one could be further namespaced:
# test_commons/page_objects/component_one/step_one.rb module PageObjects module ComponentOne class StepOne < SitePrism::Page … end end end
This becomes essential when you’ve got more than a handful of pages.
Adding a Page Factory for SitePrism
I started by looking at page-object’s implementation (which itself references Alister Scott’s earlier post).
That seemed to do more than I needed right away, so I started with the simpler:
# test_commons/page_objects/helpers.rb module PageObjects def visit_page(page_name, &block) Object.const_get("PageObjects::#{page_name}").new.tap do |page| page.load block.call page if block end end end
… though we will need to add World(PageObjects)
to our features/support/env.rb
to use this from Cucumber (the require
got us the page object classes, but not module methods). If we were running from RSpec, to borrow a snippet from Robbie Clutton and Matt Parker’s excellent LA Ruby Conference 2013 talk, we would need:
RSpec.configure do |c| c.include PageObjects, type: [:feature, :request] end
So now we can call visit_page
(not visit
because that conflicts with a Capybara method), it’ll load the page (using the page object’s set_url
value), and if we have passed in a block it’ll yield to the block, and either way it’ll return the page, so we can call methods on the returned page. In other words: visit_page('Login').login(username, password)
.
We can use the same pattern to build an on_page
method, which instead of loading the page asserts that the app is on the page that the test claims it will be on:
def on_page(name, args = {}, &block) Object.const_get("PageObjects::#{page_name}").new.tap do |page| expect(page).to be_displayed block.call page if block end end
Further iteration (including the fact that both page.load
and page.displayed?
take arguments for things like ids in URLs) results in something like this:
module PageObjects def visit_page(name, args = {}, &block) build_page(name).tap do |page| page.load(args) block.call page if block end end def on_page(name, args = {}, &block) build_page(name).tap do |page| expect(page).to be_displayed(args) block.call page if block end end def build_page(name) name = name.to_s.camelize if name.is_a? Symbol Object.const_get("PageObjects::#{name}").new end end
We haven’t implemented page-object’s if_page
yet (don’t assert that you’re on a page and fail if you aren’t, but check if you’re on a page and if you are carry on), but we can add it later if we need it.
What Would that Failed Assertion Look Like?
Suppose we tried on_page('Dashboard').do_something
when we weren’t on the dashboard. What error message would we get?
expected displayed? to return true, got false (RSpec::Expectations::ExpectationNotMetError) ./test_commons/page_objects/helpers.rb:11:in `block in on_page'
We know, because SitePrism has a #current_path
method, what page we’re actually on. A custom matcher to produce a more informative message seems a good idea. Between the rspec-expectations docs and Daniel Chang’s exploration, let’s try this:
# spec/support/matchers/be_displayed.rb RSpec::Matchers.define :be_displayed do |args| match do |actual| actual.displayed?(args) end failure_message_for_should do |actual| expected = actual.class.to_s.sub(/PageObjects::/, '') expected += " (args: #{args})" if args.count > 0 "expected to be on page '#{expected}', but was on #{actual.current_path}" end end
which we’d get for free from RSpec, and from Cucumber we can get by adding to features/support/env.rb
:
require_relative '../../spec/support/matchers/be_displayed'
The first time I ran this, because I wasn’t passing in arguments to SitePrism’s #displayed?
method, I hit a weird error:
comparison of Float with nil failed (ArgumentError) ./spec/support/matchers/be_displayed.rb:3:in `block (2 levels) in <top (required)>'
… emending the helper methods to make sure that I included an empty hash even if no arguments were passed in (part of the final version of helpers.rb
above) fixed that. Now, with the new matcher working, if we run on_page('Dashboard').do_something
when we’re actually at the path /secondary-control-room
, we get the much more useful error
expected to be on page 'Dashboard', but was on '/secondary-control-room' (RSpec::Expectations::ExpectationNotMetError) ./test_commons/page_objects/helpers.rb:11:in `block in on_page'
… note that if there had been expected arguments, we would have printed those out too (expected to be on page 'Dashboard' (args: {id: 42})
).
I built the custom matcher in RSpec because that’s what I tend to use, but if Minitest is more your thing, you could add a custom matcher by reopening Minitest::Assertions
. (Example here.)
Setup Complete
We’ll break now that the page object framework is set up, and discuss further implementation patterns in the next post.