Better Acceptance Tests with Page Objects

Josh Clayton

During my Test-Driven Rails workshop earlier this week (which is also available as an online workshop), my students and I were writing acceptance tests surrounding marking todo items as complete. The spec looked like this:

feature 'Manage todos' do
  scenario 'view only todos the user has created' do
    sign_in_as 'other@example.com'
    create_todo_titled 'Lay eggs'
    sign_in_as 'me@example.com'
    user_should_not_see_todo_titled 'Lay eggs'
  end

  scenario 'complete my todos' do
    sign_in_as 'person@example.com'
    create_todo_titled 'Buy eggs'
    complete_todo_titled 'Buy eggs'
    user_should_see_completed_todo_titled 'Buy eggs'
  end

  scenario 'mark my todos incomplete' do
    sign_in_as 'person@example.com'
    create_todo_titled 'Buy eggs'
    complete_todo_titled 'Buy eggs'
    mark_incomplete_todo_titled 'Buy eggs'
    user_should_see_incomplete_todo_titled 'Buy eggs'
  end

  def create_todo_titled(title)
    click_link 'Create a new todo'
    fill_in 'Title', with: title
    click_button 'Create'
  end

  def user_should_see_todo_titled(title)
    within 'ol.todos' do
      expect(page).to have_css 'li', text: title
    end
  end

  def user_should_see_completed_todo_titled(title)
    within 'ol.todos' do
      expect(page).to have_css 'li.complete', text: title
    end
  end

  def user_should_see_incomplete_todo_titled(title)
    within 'ol.todos' do
      expect(page).not_to have_css 'li.complete', text: title
    end
  end

  def user_should_not_see_todo_titled(title)
    within 'ol.todos' do
      expect(page).not_to have_css 'li', text: title
    end
  end

  def complete_todo_titled(title)
    todo = Todo.where(title: title).first
    within("[data-id='#{todo.id}']") { click_link 'Complete' }
  end

  def mark_incomplete_todo_titled(title)
    todo = Todo.where(title: title).first
    within("[data-id='#{todo.id}']") { click_link 'Incomplete' }
  end
end

There’s a handful of things that can probably be refactored in the helper methods, but that’s not what I wanted to focus on; check out the scenarios themselves:

scenario 'create a new todo' do
  sign_in_as 'person@example.com'
  create_todo_titled 'Buy eggs'
  user_should_see_todo_titled 'Buy eggs'
end

scenario 'view only todos the user has created' do
  sign_in_as 'other@example.com'
  create_todo_titled 'Lay eggs'
  sign_in_as 'me@example.com'
  user_should_not_see_todo_titled 'Lay eggs'
end

scenario 'complete my todos' do
  sign_in_as 'person@example.com'
  create_todo_titled 'Buy eggs'
  complete_todo_titled 'Buy eggs'
  user_should_see_completed_todo_titled 'Buy eggs'
end

scenario 'mark completed todo as incomplete' do
  sign_in_as 'person@example.com'
  create_todo_titled 'Buy eggs'
  complete_todo_titled 'Buy eggs'
  mark_incomplete_todo_titled 'Buy eggs'
  user_should_see_incomplete_todo_titled 'Buy eggs'
end

While each of these lines reads well, the subject of each line is the user, or “you”. You sign in, you create a todo titled “Buy eggs”, and you should see a todo with the correct title; the focus should be the todo. The todo is the subject of the test; we’re making assertions about if it’s on the page and its state after certain page interactions occur. The other thing we’re doing is repeating the todo title everywhere, which seems too verbose.

What if we used a Page Object?

scenario 'create a new todo' do
  sign_in_as 'person@example.com'
  todo = todo_on_page
  todo.create

  expect(todo).to be_visible
end

scenario 'view only todos the user has created' do
  sign_in_as 'other@example.com'
  todo = todo_on_page
  todo.create

  sign_in_as 'me@example.com'

  expect(todo).not_to be_visible
end

scenario 'complete my todos' do
  sign_in_as 'person@example.com'
  todo = todo_on_page
  todo.create
  todo.mark_complete

  expect(todo).to be_complete
end

scenario 'mark completed todo as incomplete' do
  sign_in_as 'person@example.com'
  todo = todo_on_page
  todo.create
  todo.mark_complete
  todo.mark_incomplete

  expect(todo).not_to be_complete
end

Aside from signing in, everything focuses on the todo since it’s the star of the show. All that needs to be done is move the helper methods from the original code into methods #create, #mark_complete, #mark_incomplete, #visible?, and #complete?. First, we’ll start with #todo_on_page, though:

def todo_on_page
  TodoOnPage.new('Buy eggs')
end

An instance of TodoOnPage, instantiated with a specific title, is returned which will implement the handful of methods enumerated above.

class TodoOnPage < Struct.new(:title)
  include Capybara::DSL

  def create
    click_link 'Create a new todo'
    fill_in 'Title', with: title
    click_button 'Create'
  end

  def mark_complete
    todo_element.click_link 'Complete'
  end

  def mark_incomplete
    todo_element.click_link 'Incomplete'
  end

  def visible?
    todo_list.has_css? 'li', text: title
  end

  def complete?
    todo_list.has_css? 'li.complete', text: title
  end

  private

  def todo_element
    find 'li', text: title
  end

  def todo_list
    find 'ol.todos'
  end
end

By using well-named methods like #todo_element and #todo_list, it becomes immediately obvious how to mark todos complete and incomplete, as well as how to check if a todo is complete or on the page.

The TodoOnPage is a page object. Given a specific context (in this case, the title of a todo), it encapsulates page interaction (#create, #mark_complete, #mark_incomplete) and assertions (using RSpec’s predicate matchers with #visible? and #complete?).

While I’ve written plenty of page objects before, often they only involve interaction and not predicate methods for matchers. Including both seems totally obvious now; I’m really excited to start using this pattern throughout other areas of my acceptance testing.

Detect emerging problems in your codebase with Ruby Science. We’ll deliver solutions for fixing them, and demonstrate techniques for building a Ruby on Rails application that will be fun to work on for years to come.

Grab a free sample of Ruby Science today!