logo logo

Develop Page Object Selenium Tests Using Python

main post image

This tutorial will make web UI testing easy. We will build a simple yet robust web UI test solution using Python, pytest, and Selenium WebDriver. We will learn strategies for good test design as well as patterns for good automation code. By the end of the tutorial, you’ll be a web test automation champ! Your Python test project can be the foundation for your own test cases, too.

Tutorial Chapters

  1. Set Your Test Automation Goals (Chapter 1)
  2. Create A Python Test Automation Project Using Pytest (Chapter 2)
  3. Installing Selenium WebDriver Using Python and Chrome (Chapter 3)
  4. Write Your First Web Test Using Selenium WebDriver, Python and Chrome (Chapter 4)
  5. You’re here → Develop Page Object Selenium Tests Using Python (Chapter 5)
  6. How to Read Config Files in Python Selenium Tests (Chapter 6)
  7. Take Your Python Test Automation To The Next Level (Chapter 7)

The test function we wrote in the last chapter was good, but we can make it even better if we refactor it using the Page Object Pattern.

WebDriver Test Problems

For reference, here’s the current version of our test function:

def test_basic_duckduckgo_search(browser):
  URL = 'https://www.duckduckgo.com'
  PHRASE = 'panda'

  browser.get(URL)

  search_input = browser.find_element_by_id('search_form_input_homepage')
  search_input.send_keys(PHRASE + Keys.RETURN)

  link_divs = browser.find_elements_by_css_selector('#links > div')
  assert len(link_divs) > 0

  xpath = f"//div[@id='links']//*[contains(text(), '{PHRASE}')]"
  phrase_results = browser.find_elements_by_xpath(xpath)
  assert len(phrase_results) > 0

  search_input = browser.find_element_by_id('search_form_input')
  assert search_input.get_attribute('value') == PHRASE

All WebDriver calls are made directly within the test function. Without any comments, this code can be hard to read and understand. The WebDriver calls use unintuitive locators with low-level interaction commands. What’s missing is the intent – this test is a DuckDuckGo search, not a slew of clicks and scrapes. Plus, web element locators can be duplicated, such as the locator for the search input.

The Page Object Pattern (a.k.a the Page Object “Model”) is a design pattern that abstracts web page interactions for enhanced readability and reusability. Pages are represented as classes with locator attributes and interaction methods. Instead of making raw WebDriver calls, tests call page object methods instead. Page Object Pattern is arguably the most common pattern used for web UI test automation. There are many ways to implement the pattern, but most of them are fairly similar.

Restructuring for Page Objects

Our test interacts with two pages: the DuckDuckGo search page and the result page. Let’s write a page object class for each. Create a new directory named pages/ under the project root directory, and add an empty __init__.py file to make it a package. In this package, create two files named search.py and result.py.

Your project directory layout should look like this:

$ tree
.
├── Pipfile
├── Pipfile.lock
├── pages
│   ├── __init__.py
│   ├── result.py
│   └── search.py
└── tests
    ├── test_math.py
    └── test_web.py

Placing test-related modules outside the tests/ directory might seem odd. However, remember that pytest recommends the tests module to not be a package. Creating a separate package for page objects enables test modules to import them easily. It also enforces a separation of concerns between test cases and web interactions. This directory layout will be sufficient for our test project, but other (especially larger) projects may consider alternative layouts. Please refer to pytest’s Good Integration Practices guide for further advice.

The Search Page

The search page is pretty simple. Our test interacts with it in two ways: loading it and entering a search phrase. The search input is the only locator, too. Add the following code to pages/search.py:

from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys

class DuckDuckGoSearchPage:
  URL = 'https://www.duckduckgo.com'

  SEARCH_INPUT = (By.ID, 'search_form_input_homepage')

  def __init__(self, browser):
    self.browser = browser

  def load(self):
    self.browser.get(self.URL)

  def search(self, phrase):
    search_input = self.browser.find_element(*self.SEARCH_INPUT)
    search_input.send_keys(phrase + Keys.RETURN)

Despite its small size, this class is a good example of what page objects should be. Its name, DuckDuckGoSearchPage, uniquely and clearly identifies the page. There are locator attributes (SEARCH_INPUT), an initializer (__init__), and interaction methods (load and search). Let’s look at each part.

Locator Attributes

The only locator in this class is SEARCH_INPUT, which finds the search input element. It is a class (or “static”) attribute because the value should be the same for all search pages. It is also written as a tuple because every locator has two parts: a type (like By.ID or By.XPATH) and a query. This locator finds the search element by the name “q”. Writing locators as class attribute tuples makes them very readable and accessible. Locators should always have intuitive names, too.

Initializer

All page objects need a reference to the WebDriver instance. Typically, it is injected via the constructor. Here, it is passed into the __init__ method as the browser parameter and then stored as the self.browser attribute. Dependency injection allows page objects to polymorphically use any type of WebDriver, whether it’s ChromeDriver, IEDriver, or something else. It also lets the test framework control setup and cleanup.

Interaction Methods

Interaction methods should be intuitively named. The load method navigates the browser to the search page. Notice that URL is a class attribute. The search method enters the search phrase into the input field, but now the phrase is parametrized so that any phrase may be used.

The search method also finds the target element in a novel way. Instead of using the find_element_by_name method, it uses the more general find_element method that takes two arguments: the locator type and the query. Those arguments match our SEARCH_INPUT tuple. The * operator expands self.SEARCH_INPUT into positional arguments for the method call. Nice! Personally, I like this pattern because locator tuples can be changed without affecting interaction code.

Search Refactoring

Let’s refactor the test case steps with our new search page object. Replace these lines:

URL = 'https://www.duckduckgo.com'
PHRASE = 'panda'

browser.get(URL)

search_input = browser.find_element_by_id('search_form_input_homepage')
search_input.send_keys(PHRASE + Keys.RETURN)

With new page object calls:

PHRASE = 'panda'

search_page = DuckDuckGoSearchPage(browser)
search_page.load()
search_page.search(PHRASE)

That’s much better! The lines now read much more like test steps than programming calls.

The Results Page

Let’s write the result page object next. Add the following code to pages/result.py:

from selenium.webdriver.common.by import By


class DuckDuckGoResultPage:
  LINK_DIVS = (By.CSS_SELECTOR, '#links > div')
  SEARCH_INPUT = (By.ID, 'search_form_input')

  @classmethod
  def PHRASE_RESULTS(cls, phrase):
    xpath = f"//div[@id='links']//*[contains(text(), '{phrase}')]"
    return (By.XPATH, xpath)

  def __init__(self, browser):
    self.browser = browser

  def link_div_count(self):
    link_divs = self.browser.find_elements(*self.LINK_DIVS)
    return len(link_divs)

  def phrase_result_count(self, phrase):
    phrase_results = self.browser.find_elements(*self.PHRASE_RESULTS(phrase))
    return len(phrase_results)
  
  def search_input_value(self):
    search_input = self.browser.find_element(*self.SEARCH_INPUT)
    return search_input.get_attribute('value')

DuckDuckGoResultPage is just a bit more complex than DuckDuckGoSearchPage. The LINK_DIVS locator follows the tuple pattern, but the PHRASE_RESULTS locator does not. Instead, it uses a class method that returns a tuple so that the search phrase in its XPath may be parametrized. The initializer is the same. The three interaction methods find elements and return values.

Note that the interaction methods do not make assertions. They simply return state. Assertions are a concern for a test case, not page objects. Different tests may use the same page object calls for different types of assertions.

It’s time for more refactoring. Replace the following test function lines:

link_divs = browser.find_elements_by_css_selector('#links > div')
assert len(link_divs) > 0

xpath = f"//div[@id='links']//*[contains(text(), '{PHRASE}')]"
phrase_results = browser.find_elements_by_xpath(xpath)
assert len(phrase_results) > 0

search_input = browser.find_element_by_id('search_form_input')
assert search_input.get_attribute('value') == PHRASE

With these:

result_page = DuckDuckGoResultPage(browser)
assert result_page.link_div_count() > 0
assert result_page.phrase_result_count(PHRASE) > 0
assert result_page.search_input_value() == PHRASE

Wow! Again, that’s much cleaner.

Rerun the Test

tests/test_web.py should now look like this:

import pytest

from pages.result import DuckDuckGoResultPage
from pages.search import DuckDuckGoSearchPage

from selenium.webdriver import Chrome


@pytest.fixture
def browser():
  # Initialize ChromeDriver
  driver = Chrome()

  # Wait implicitly for elements to be ready before attempting interactions
  driver.implicitly_wait(10)
  
  # Return the driver object at the end of setup
  yield driver
  
  # For cleanup, quit the driver
  driver.quit()


def test_basic_duckduckgo_search(browser):
  # Set up test case data
  PHRASE = 'panda'

  # Search for the phrase
  search_page = DuckDuckGoSearchPage(browser)
  search_page.load()
  search_page.search(PHRASE)

  # Verify that results appear
  result_page = DuckDuckGoResultPage(browser)
  assert result_page.link_div_count() > 0
  assert result_page.phrase_result_count(PHRASE) > 0
  assert result_page.search_input_value() == PHRASE

Rerun the test to make sure it still works:

$ pipenv run python -m pytest
============================= test session starts ==============================
platform darwin -- Python 3.7.3, pytest-4.5.0, py-1.8.0, pluggy-0.12.0
rootdir: /Users/andylpk247/Programming/automation-panda/python-webui-testing
collected 9 items                                                              

tests/test_math.py ........                                              [ 88%]
tests/test_web.py .                                                      [100%]

=========================== 9 passed in 5.68 seconds ===========================

Nice! It works.

More Tests!

These past two chapters have expounded the intricacies of what initially appeared to be a simple web UI test. Not only did we automate web UI interactions for a DuckDuckGo search, but we did so with best practices and design patterns! What we learned here can be extended for new tests. Here are suggestions for additional tests you can write:

  • Parametrize the test to search other phrases.
  • Click one of the result links.
  • Search for images, videos, and news.

In the next chapter, we’ll learn how to set configurations for browser choice and wait times outside the automation code using config files.

 

[Update on August 25, 2019: Changes to the DuckDuckGo search page required locator updates for the search input elements.]

 

TestProject Test Automation Tool

Avatar

About the author

AutomationPanda

Andy Knight is the "Automation Panda" - an engineer, consultant, and international speaker who loves all things software. He specializes in building robust test automation systems from the ground up. Read his tech blog at AutomationPanda.com, and follow him on Twitter at @AutomationPanda.

Join TestProject Community

Get full access to the world's first cloud-based, open source friendly testing community. Enjoy TestProject's end-to-end test automation Platform, Forum, Blog and Docs - All for FREE.

Join Us Now

Comments

2 2 comments
  • Avatar
    benzito164 July 24, 2019, 8:55 am

    Hi i am currently using pycharm to build this framework and i am currently at

    def __init__(self, browser):
    self.browser = browser

    def load(self):
    self.browser.get(self.URL)

    now my issue is in the load method when i type self.browser and press the fullstop i dont get the autocomplete to show the webdriver methods eg get,find_elements
    which leads to my question how does this class know that browser is of type webdriver ?

    • Avatar
      AutomationPanda July 24, 2019, 9:35 pm

      Hi Benzito164! That’s how the Python type system works. Python is a dynamically typed language. Theoretically, anything could be passed in for the browser variable. The tests we write must make sure to pass in a WebDriver object for browser. Alternatively, we could use a Python type system like mypy or pyre for type checking.

Leave a Reply

Join TestProject Newsletter

Join a 20K community of readers! Always stay up-to-date with all the latest test automation trends, best practice and tips shared by leading software testing community experts across the globe!

FacebookLinkedInTwitterEmail