In the previous post, we added the page object framework SitePrism to a project, did some organization and customization, and added helper methods so we could call visit_page
and on_page
to interact with the app. Next, let’s start using it.
Navigation
First Step
Let’s say that for the first test you want to go to the login page, log in, and then assert that you’re on the dashboard page. That’s easy enough to do like so:
visit_page("Login").login(username, password) on_page("Dashboard") module PageObjects class Login < SitePrism::Page set_url "/login" element :username, "input#login" element :password, "input#password" element :login, "input[name='commit']" def login(username, password) self.username.set username self.password.set password login.click end end end module PageObjects class Dashboard < SitePrism::Page set_url "/dashboard" end end
The first line will go to the “/login” page and then try to submit the form, and will fail if it can’t find or interact with the “username”, “password”, or “login” elements. For instance, if the css for the username field changes, the test will fail with:
Unable to find css "input#login" (Capybara::ElementNotFound) ./test_commons/page_objects/login.rb:10:in `login' ./features/step_definitions/smoke.rb:7:in `login' ./features/step_definitions/smoke.rb:3:in `/^I log in$/' features/smoke.feature:5:in `When I log in’
The second line will fail if the path it arrives at after logging in is something other than “/dashboard”.
Because the default message for the different path was uninformative (expected true, but was false
), we added a custom matcher to provide more information. The default message for not finding an element on the page, including the css it’s expecting and the backtrace to the page object it came from and the line in the method it was trying to run, seems sufficiently informative to leave as-is.
If the login field were disabled instead of not found, the error message would be slightly less informative:
invalid element state (Session info: chrome=…) (Driver info: chromedriver=2.14. … (Selenium::WebDriver::Error::InvalidElementStateError) ./test_commons/page_objects/login.rb:10:in `login' ./features/step_definitions/smoke.rb:7:in `login' ./features/step_definitions/smoke.rb:3:in `/^I log in$/' features/smoke.feature:5:in `When I log in’
but since the backtrace still gives you the line of the page object where it failed, and that gets you the element in an invalid state, it feels lower priority.
Second Step
If the next thing you want to test is a subcomponent flow, you can navigate to its first page either by calling visit_page('ComponentOne::StepOne')
(the equivalent of typing the path in the browser address bar), or by having the test interact with the same navigation UI that a user would. The second choice feels more realistic and more likely to expose bugs, like the right navigation links not being displayed.
(Ironically, having created the visit_page
method, we might only ever use it for a test’s first contact with the app, using in-app navigation and on_page
verification for all subsequent interactions.)
To get that to work, we need to model the navigation UI.
SitePrism Sections and Navigation UI
Supose the app has a navigation bar on every logged-in page. We could add it as a section to the Dashboard, say, but if it’s on every page it probably makes more sense to add it as a section to a BasePage and have Dashboard (and all other logged-in pages) inherit from it:
module PageObjects class BasePage < SitePrism::Page section :navigation, Navigation, ".navigation" end end module PageObjects class Dashboard < BasePage … end end module PageObjects class Navigation < SitePrism::Section element :component_one, ".component_one" element :componont_two, ".component_two" def goto_component_one component_one.click end def goto_component_two component_two.click end end end
Now, if we want to go from the dashboard to the start of component one, we can modify the on_page("Dashboard")
line above to
on_page("Dashboard").navigation.goto_component_one
Can we delegate to the section from the enclosing page? Yes. If we add this to the base page:
module PageObjects class BasePage < SitePrism::Page … delegate :goto_component_one, :goto_component_two, to: :navigation end end
we can then call, still more briefly:
on_page("Dashboard").goto_component_one
And now the test from the top can continue like so:
visit_page("Login").login(username, password) on_page("Dashboard").goto_component_one on_page("ComponentOne::StepOne").do_something
and line two will fail if the link to “component one” isn’t visible on the screen, and line three will fail if the user doesn’t end up on the first page of component one (perhaps they followed the link but authorizations aren’t set up correctly, so they end up on an “unauthorized” page instead).
What Else Is on the Page?
If you call a method on a page object it will verify that any item that it interacts with is on the page, and fail if it can’t find it or can’t interact with it. But what if you’re adding tests to an existing app, and you aren’t yet calling methods that interact with everything on the page, but you do want to make sure certain things are on the page?
Suppose you have a navigation section with five elements, unimaginatively called :one
, :two
, :three
, :four
, :five
. And you have three types / authorization levels of users, like so:
User | Can see elements |
user1 | :one |
user3 | :one, :two, :three |
user5 | :one, :two, :three, :four, :five |
And you want to log each of them in and verify that they only see what they should see. We can do this with SitePrism’s has_<element>?
and has_no_<element>?
methods.
A naive approach might be:
all_navigation_elements = %w{one two three four five} visit_page('Login').login(user1.username, user1.password) on_page('Dashboard') do |page| user1_should_see = %w{one} user1_should_see.each do |element| expect(page.navigation).to send("have_#{element}") end user1_should_not_see = all_navigation_elements - user1_should_see user1_should_not_see.each do |element| expect(page.navigation).to send("have_no_#{element}") end end
Even before doing the other two, it’s clear that we’re going to want to extract this into a more generalized form.
To build out the design by “programming by wishful thinking”, if we already had the implementation we might call it like this:
all_navigation_elements = %w{one two three four five} visit_page('Login').login(user1.username, user1.password) on_page('Dashboard') do |page| should_see = %w{one} has_these_elements(page.navigation, should_see, all_elements) end visit_page('Login').login(user3.username, user3.password) on_page('Dashboard') do |page| should_see = %w{one two three} has_these_elements(page.navigation, should_see, all_elements) end # do. for user5
And then we can reopen test_commons/page_objects/helpers.rb
to add an implementation:
def has_these_elements(page, has_these, all) has_these.each do |element| expect(page).to send("have_#{element}") end does_not_have = all - has_these does_not_have.each do |element| expect(page).to send("have_no_#{element}") end end
And this works. The raw error message (suppose we’re doing this test first so we’ve got the test for user1 but user1 still sees all five items) is unhelpful:
expected #has_no_two? to return true, got false (RSpec::Expectations::ExpectationNotMetError) ./test_commons/page_objects/helpers.rb:28:in `block in has_these_elements' ./test_commons/page_objects/helpers.rb:27:in `each' ./test_commons/page_objects/helpers.rb:27:in `has_these_elements' ./features/step_definitions/navigation_permissions.rb:24:in `block in dashboard' …
And we could build a custom matcher for it, but in this case since the error is mostly likely to show up (if we’re working in Cucumber) after
When user1 logs in Then user1 should only see its subset of the navigation
seeing expected #has_no_two?
after that seems perfectly understandable.
Summing Up So Far
I’m quite liking this so far, and I can easily imagine after I’ve used them for a bit longer bundling the helpers into a gem to make it trivially easy to use them on other projects. And possibly submitting a PR to suggest adding something like page.has_these_elements(subset, superset)
into SitePrism, because that feels like a cleaner interface than has_these_elements(page, subset, superset)
.