Selenium's Page Object Pattern: The Key to Maintainable Tests

At Sprint.ly, we've been investing in the longer term health of our code base. Part of our strategy for handling this involves selenium testing.

For those who may be unfamiliar, selenium tests operate by instructing a browser to click on things or type in text boxes in an order you specify. The biggest upside to this is it's the most accurate simulation of an actual user's experience. One of the biggest obstacles of these sorts of tests is they are brittle. To combat this, we're using a concept called the page object pattern.

At its core, the page object pattern is a specialized form of the facade pattern. If you are unfamiliar with the facade pattern, it means hiding bad APIs behind better ones. As its name suggests, the major thing the page object pattern offers is the notion of representing each page of your app as an object. These objects expose APIs for performing actions on these pages. What's nice about this is the test code is much cleaner. Compare the following two code samples:

  # Tests without the page object pattern
  class SignupTest(unittest.TestCase):
      def setUp(self):
          self.browser = webdriver.Firefox()
  
      def test_someone_can_signup(self):
          self.browser.get("http://localhost:8000")
          first_name = self.browser.find_element_by_css_selector("#id_first_name")
          first_name.send_keys("Justin")
          last_name = self.browser.find_element_by_css_selector("#id_last_name")
          last_name.send_keys("Abrahms")
          password = self.browser.find_element_by_css_selector("#id_password")
          password.send_keys("asdf")
          email = self.browser.find_element_by_css_selector("#id_email")
          randomNumber = int(time.time())
          email.send_keys("test+%s@example.com" % randomNumber)
          product = self.browser.find_element_by_css_selector("#id_product_name")
          product.send_keys("test")
          self.browser.find_element_by_css_selector('#create_account_form button').click()
          self.browser.find_element_by_css_selector('#next-step').click()
          self.browser.find_element_by_css_selector('#next-step').click()
          self.browser.find_element_by_css_selector('#next-step').click()
  
          elem = self.browser.find_element_by_css_selector('#logged_in_header')
          assert elem is not None
  
      def tearDown(self):
          self.browser.close()
  # Tests with the page object pattern.
  class SignupTest(unittest.TestCase):
      def setUp(self):
          self.browser = webdriver.Firefox()
  
      def test_someone_can_signup(self):
          homepage = Homepage(self.browser)
          homepage.navigate()
          signup_form = homepage.getSignupForm()
          signup_form.setName("Justin", "Abrahms")
          signup_form.setPassword("asdf")
          randomNumber = int(time.time())
          signup_form.setEmail('test+%s@example.com' % randomNumber)
          signup_form.setProductName('test')
  
          onboarding_1 = signup_form.submit()
          onboarding_2 = onboarding_1.next()
          onboarding_3 = onboarding_2.next()
          items_page = onboarding_3.next()
  
          elem = self.browser.find_element_by_css_selector('#logged_in_header')
          assert elem is not None
  
      def tearDown(self):
          self.browser.close()

The major difference here is that we've taken some code whose purpose is unclear and put it behind some well-named methods. As such, the code reads almost like pseudo code (which is to say you're making your own Domain Specific Language or DSL).

With this nicer code, changing the CSS structure of the page doesn't mean hunting through the code for all uses. Instead, just go to the page (object) that has that component and make the change in one place.

Another novel thing about this pattern is navigation events. Not only do some actions trigger navigation events, but these methods allow for returning entirely new page objects representing the destination page. This chaining of calls offers a great cohesiveness to the test suite and minimizes the boilerplate in structuring things.

An example of what one of these page objects looks like is below (copy and pasted directly from our actual code). The key aspects of this pattern are that we treat things as if they are just normal Python objects, so all the same principles of well written code apply.

  class BasePage(object):
      url = None
  
      def __init__(self, driver):
          self.driver = driver
  
      def fill_form_by_css(self, form_css, value):
          elem = self.driver.find(form_css)
          elem.send_keys(value)
  
      def fill_form_by_id(self, form_element_id, value):
          return self.fill_form_by_css('#%s' % form_element_id, value)
  
      def navigate(self):
          self.driver.get(self.url)
  
  
  class Homepage(BasePage):
      url = "http://localhost:8000"
  
      def getSignupForm(self):
          return SignupPage(self.driver)
  
  class SignupPage(BasePage):
      url = "http://localhost:8000/account/create/"
  
      def setName(self, first, last):
          self.fill_form_by_id("id_first_name", first)
          self.fill_form_by_id("id_last_name", last)
  
      def setEmail(self, email):
          self.fill_form_by_id("id_email", email)
  
      def setPassword(self, password):
          self.fill_form_by_id("id_password", password)
          self.fill_form_by_id("id_password_confirmation", password)
  
      def setProductName(self, name):
          self.fill_form_by_id("id_first_product_name", name)
  
      def submit(self):
          self.driver.find('#create_account_form button').click()
          return OnboardingInvitePage(self.driver)

With this technique in hand, you should be prepped and ready to start diving into your own selenium test writing. By keeping your APIs clean and your tests readable, the maintenance burden of these tests should be better than tests normally.

For selenium's own documentation on this pattern, see the java-centric documentation.