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 of find when looking for element collections