Using Page Objects in your Acceptance Tests
April 10, 2016
If you’ve written acceptance tests for web applications in the past (also called feature tests), you might be familiar with tools like Capybara, Simplelenium and FluentLenium. These are great abstractions over the browser (thanks, Selenium!) that provide very nice APIs for testing web applications.
If you’ve done this for a while, you might also have heard of Page Objects. The idea behind them is that your tests should be about the behavior of your application and not about the underlying HTML, since the HTML is an implementation detail and probably not the interesting part of your tests.
Our base acceptance test
Let’s assume that we are working on an application where you can browse and review restaurants and we have an acceptance test that goes like this:
# spec/features/reviewing_a_restaurant_spec.rb
feature 'Reviewing a restaurant' do
background do
# create a user in the database
end
scenario 'user writes a two-star review for a restaurant' do
visit root_path
click_on 'Sign In'
within '.sign-in-form' do
fill_in 'Email', with: 'myemail@example.org'
fill_in 'Password', with: 'anawesomepassword'
click_on 'Sign In'
end
fill_in 'Search', with: 'Steakhouse'
click_on 'Go'
within '.search-results' do
click_on "Freddy's Unclean Steakhouse"
end
click_on 'Write a Review'
click_on '.rating[data-rating="2"]'
fill_in 'Comments', with: 'Did NOT like it'
click_on 'Save'
expect(page).to have_content 'Your review has been saved!'
expect(page).to have_content "Freddy's Unclean Steakhouse"
expect(page).to have_selector '.rating[data-rating="2"].selected'
expect(page).to have_content 'Did NOT like it'
end
end
The test above is fine, but we can do better. It’s very hard to see what the test is about with all the clicks and all the form filling happening.
Extracting methods to clarify intent
Let’s extract a few methods to better communicate what we’re trying to validate with this test:
# spec/features/reviewing_a_restaurant_spec.rb
feature 'Reviewing a restaurant' do
# ...
scenario 'user writes a two-star review for a restaurant' do
user_signs_in
user_searches_for_a_restaurant
user_writes_a_two_star_review
user_sees_two_star_review_on_restaurant
end
def user_signs_in
visit root_path
click_on 'Sign In'
within '.sign-in-form' do
fill_in 'Email', with: 'myemail@example.org'
fill_in 'Password', with: 'anawesomepassword'
click_on 'Sign In'
end
end
def user_searches_for_a_restaurant
fill_in 'Search', with: 'Steakhouse'
click_on 'Go'
within '.search-results' do
click_on "Freddy's Unclean Steakhouse"
end
end
def user_writes_a_two_star_review
click_on 'Write a Review'
click_on '.rating[data-rating="2"]'
fill_in 'Comments', with: 'Did NOT like it'
click_on 'Save'
end
def user_sees_two_star_review_on_restaurant
expect(page).to have_content 'Your review has been saved!'
expect(page).to have_content "Freddy's Unclean Steakhouse"
expect(page).to have_selector '.rating[data-rating="2"].selected'
expect(page).to have_content 'Did NOT like it'
end
end
That’s definitely better. We are expressing our intent in the test itself. But… we can still do better. This test file continues to be too coupled to the specific details of how to interact with the HTML. Additionally, the assertions that we have at the end are not specific enough since the contained strings could be anywhere on the page. This does not bode well.
Using Page Objects
Let’s extract a few Page Objects. Some of them will represent the whole web page being displayed and other will represent particular sections of such page:
# spec/features/pages/sign_in_page.rb
module Pages
class SignInPage
include Capybara::DSL
def navigate
visit root_path
click_on 'Sign In'
end
def sign_in(email, password)
within '.sign-in-form' do
fill_in 'Email', with: email
fill_in 'Password', with: password
click_on 'Sign In'
end
end
end
end
# spec/features/pages/search_page.rb
module Pages
class SearchPage
include Capybara::DSL
def search_for(term)
fill_in 'Search', with: term
click_on 'Go'
end
def results
all('.search-results a').map do |element|
Pages::Search::Result.new(element)
end
end
end
end
# spec/features/pages/search/result.rb
module Pages
module Search
class Result
def initialize(element)
@element = element
end
def name
element.text
end
def view
element.click
end
private
attr_reader :element
end
end
end
# spec/features/pages/restaurant_page.rb
module Pages
class RestaurantPage
include Capybara::DSL
def name
find('h1').text
end
def has_success_message?
has_selector? '[data-flash-success]'
end
def success_message
find('[data-flash-success]').text
end
def reviews
all('.review').map do |element|
Pages::Restaurant::Review.new(element)
end
end
def write_review(stars, comments)
click_on 'Write a Review'
click_on rating_selectors[stars]
fill_in 'Comments', with: comments
click_on 'Save'
end
private
def rating_selectors
{
1 => '[data-rating="1"]',
2 => '[data-rating="2"]',
3 => '[data-rating="3"]',
4 => '[data-rating="4"]',
5 => '[data-rating="5"]',
}
end
end
end
# spec/features/pages/restaurant/review.rb
module Pages
module Restaurant
class Review
def initialize(element)
@element = element
end
def rating
within element do
find('.rating.selected')['data-rating'].to_i
end
end
def comments
within element do
find('.comments').text
end
end
private
attr_reader :element
end
end
end
And now, we can rewrite our acceptance test like this:
# spec/features/reviewing_a_restaurant_spec.rb
require_relative 'pages/sign_in_page'
require_relative 'pages/search_page'
require_relative 'pages/restaurant_page'
require_relative 'pages/restaurant/review'
feature 'Reviewing a restaurant' do
# ...
scenario 'user writes a two-star review for a restaurant' do
user_signs_in
user_searches_for_a_restaurant
user_writes_a_two_star_review
user_sees_two_star_review_on_restaurant
end
def user_signs_in
sign_in_page.navigate
sign_in_page.sign_in 'myemail@example.org', 'anawesomepassword'
end
def user_searches_for_a_restaurant
search_page.search_for 'Steakhouse'
expect(search_page.results).to eq 1
result = search_page.results.first
expect(result.name).to eq "Freddy's Unclean Steakhouse"
result.view
end
def user_writes_a_two_star_review
restaurant_page.write_review 1, 'Did NOT like it'
end
def user_sees_two_star_review_on_restaurant
expect(restaurant_page.name).to eq "Freddy's Unclean Steakhouse"
expect(restaurant_page.has_success_message?).to be true
expect(restaurant_page.success_message).to eq 'Your review has been saved!'
expect(restaurant_page.reviews.count).to eq 1
review = restaurant_page.reviews.first
expect(review.rating).to eq 2
expect(review.comments).to eq 'Did NOT like it'
end
end
As you can see, these Page Objects give us better ways to express the intent of the test. We are now expecting the first review in the rated restaurant page to have a particular rating and a particular comment, instead of just asserting that the number and text are in the page. We could’ve achieved that without Page Objects, but having Page Objects allowed us to get the HTML handling out of the way and really focus on writing an interesting test. We even added an assertion to count the number of reviews on the page.
On top of that, this gives us the added benefit of reducing duplication. If we had other tests with similar steps and all of a sudden we realized that we needed to change the way we sign into the application or the way we search for restaurants, it wouldn’t be fun to go through all of the acceptance test files to change the same thing over and over again. In our case, we would now just have to go to the SignInPage
or the SearchPage
to reflect the new changes and, voilà, all the tests are updated.
A common occurrence with acceptance tests, especially for applications that are JavaScript heavy (and what app isn’t these days?!), is that they start failing intermittently (a.k.a. flakey tests), usually because we’re trying to interact with the browser without giving it time to fully process the results of some JavaScript action. Page Objects can help with this, because you can wait for the right elements to appear or disappear from the page after a particular action was taken without ever revealing this to the tests using the page object.
One last benefit I want to mention is that, like domain objects, Page Objects allow the team, developers or not, to speak in the same language. Everyone knows what you’re talking about when you refer to the reviews list in the restaurant page, and if the tests reflect that, it means there is a lower mental barrier to cross whenever you need to translate acceptance criteria into tests.
Extracting a couple of base utility objects
From the Page Objects described before, you can easily reduce duplication between Page Objects by extracting a base page and component objects:
# spec/features/pages/page_component.rb
module Pages
class Page
include Capybara::DSL
def has_success_message?
has_selector? '[data-flash-success]'
end
def success_message
find('[data-flash-success]').text
end
end
end
# spec/features/pages/page_component.rb
module Pages
class PageComponent
def initialize(element)
@element = element
end
private
def within_element
within element do
yield
end
end
attr_reader :element
end
end
With these, you can now define a shorter implementation for the Review
:
# spec/features/pages/restaurant/review.rb
module Pages
module Restaurant
class Review < Pages::PageComponent
def rating
within_element do
find('.rating.selected')['data-rating'].to_i
end
end
def comments
within_element do
find('.comments').text
end
end
end
end
end
That’s it for this time. Enjoy your new and shiny acceptance tests!
Notes
- Updated on 2016-04-11 to use a better name for the restaurant receiving that review. Thanks to Mark McDonald for pointing that out!
- Updated on 2016-04-24 to use the
all
method instead offind
when looking for element collections