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.
📍 If you are looking for a single Python Package for Android, iOS and Web Testing – there is also an easy open source solution provided by TestProject. With a single executable, zero configurations, and familiar Selenium APIs, you can develop and execute robust Python tests and get automatic HTML test reports as a bonus! All you need is: pip install testproject-python-sdk
. Simply follow this Github link to learn more about it, or read through this great tutorial to get started.
Tutorial Chapters
- Web UI Testing Made Easy with Python, Pytest and Selenium WebDriver (Overview)
- Set Your Test Automation Goals (Chapter 1)
- Create A Python Test Automation Project Using Pytest (Chapter 2)
- Installing Selenium WebDriver Using Python and Chrome (Chapter 3)
- Write Your First Web Test Using Selenium WebDriver, Python and Chrome (Chapter 4)
- You’re here → Develop Page Object Selenium Tests Using Python (Chapter 5)
- How to Read Config Files in Python Selenium Tests (Chapter 6)
- Take Your Python Test Automation To The Next Level (Chapter 7)
- Create Pytest HTML Test Reports (Chapter 7.1)
- Parallel Test Execution with Pytest (Chapter 7.2)
- Scale Your Test Automation using Selenium Grid and Remote WebDrivers (Chapter 7.3)
- Test Automation for Mobile Apps using Appium and Python (Chapter 7.4)
- Create Behavior-Driven Python Tests using Pytest-BDDÂ (Chapter 7.5)
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.
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 ?
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.
Hi, I’m just learning automation through python and this blog has been a great resource. I’m confused about using yield here… I get that the first run will get the browser going, but when do you actually run quit?
When I use Test Project using a Page Objet model with Driver set up and tear down fixture, Test Project only seems to report 1 test even if there are multiple test methods in the file. I know that Test Project records a test on driver quit but I don’t want to have to do this for each test method as it would increase the run time.I was under the impression that Test Project should notice a new test method name and log a test for each but I can’t get this to work