labs

Acceptance Testing with Page Objects

An acceptance test suite goes through stages of complexity. Our first acceptance tests started off using a browser DSL like capybara directly:

fill_in "tweet", with: "hi!"
click_button "Tweet"

(Note: For the purposes of this article, I’m going to pretend I’m working with a team to build Twitter. I’ve never actually worked on Twitter, but I like to use it since it’s a domain readers are likely to already know.)

As our test suites grew, we began to feel pain. All of the sudden, we had twenty tests clicking the “Tweet” button, and every single one of them did it slightly differently:

click_button "Tweet"
click_link_or_button "Tweet"
click_on "Tweet"
click_on "#tweet"
find("#tweet").click
execute_javascript("$(‘#tweet’).click();")

When our product owner decided that users needed to enter a captcha every time they submit a tweet, guess what – we suddenly had twenty tests breaking, and twenty tests to update manually.

We realized that we were dealing with knowledge duplication, so we decided to abstract it behind an intent-revealing method that submits a tweet and fills in the captcha:

tweet "hi!"

Lesson learned, we continued to develop our application, making sure to expose the “what”, not the “how”, in our tests, and isolate knowledge of the DOM behind these intent revealing “helper” methods. Our Cucumber step definition became teleportation devices.

Our application grew. And grew. And grew. Pretty soon we had dozens of features, and dozens more helper methods for our acceptance test suite. We tried organizing our helper methods into modules, but we were still stuck with a large, procedural DSL. And then it dawned on us – we’re writing code in an object oriented language – why not use objects to encapsulate the behavior of our application?

When /Alice tweets/ do
  session_for(alice) do
    tweet_form.tweet "hi"
  end
end

Then /Bob, Alice’s follower, should see that tweet in his timeline/ do
  session_for(bob) do
    timeline.tweets.should have_content "hi"
  end
end

And now we had a more logical place to encapsulate all of the DOM elements, CSS selectors, and behaviors of the various features of our application.

Although these objects are typically referred to as “Page” objects, they don’t necessarily have to correspond to an actual page on your site. In the previous example, we had a “tweet_form” object and a “timeline” object – but if you look at twitter.com, you’ll see that both the form for submitting tweets and the timeline can exist on the same page. And that’s OK. In fact, it’s even desired – we’re no longer hardcoding the knowledge of the specific UI organization directly into our step definitions, but rather encapsulating that knowledge into our page objects.

As an aside, if you’ve never used the `World` object in Cucumber, you might be scratching your head about where these objects come from. It’s as simple as creating the appropriate page classes, then adding helper methods that memoize the objects for every scenario run and then mixing those methods into the “World”:

class Timeline
  TIMELINE_SELECTOR = "#timeline"

  def initialize(browser: nil)
    @browser = browser
  end

  def tweets
    open
    browser.find(TIMELINE_SELECTOR)
  end

  private
  def open
    browser.visit browser.timeline_path unless opened?
  end

  def opened?
    browser.current_path == browser.timeline_path
  end
end

module PageHelpers
  def timeline
    timelines[session_name] ||= Timeline.new browser: self
  end

  def timelines
    @timelines ||= {}
  end
end

World PageHelpers

And if you’re _really_ curious, you might wonder how the “session_for” helper method works. Underneath the hood, it’s simply a matter of switching out the Capybara session_name for the duration of the block:

def session_for(user)
  old_session_name = Capybara.session_name
  Capybara.session_name = user.name
  yield
  Capybara.session_name = old_session_name
end

def session_name
  Capybara.session_name
end