Tutorials / Selenium WebDriver series

Part 3: Page objects for Selenium tests

Page objects give every page of the site one class that knows its URL, its title and what can be done on it. Tests then read like the steps a person would take, and when the site changes, the fix goes in one place.

The base page

Everything every page has: where it lives, what its title should be, and how to open it:

src/pages/BasePage.pypython
from src.pages.Browser import Browser
from src.utils.Settings import Settings


class BasePage:

    def __init__(self, directory, title, domain=None):
        # every page object knows where it lives and what its <title> should say
        self.__domain = domain or Settings.DOMAIN
        self.__directory = directory
        self.__title = title

    @property
    def expected_url(self):
        return self.__domain + self.__directory

    @property
    def expected_title(self):
        return self.__title

    @staticmethod
    def get_actual_url():
        return Browser.get_driver().current_url

    @staticmethod
    def get_actual_title():
        return Browser.get_driver().title

    def open(self):
        # override in a page whose URL has an ID or other parameters in it
        Browser.get_driver().get(self.expected_url)
        return self

Shared components: header and footer

The header and footer are the same on every page, so they're components that every page object includes. Each click_... method returns the page object of the page you land on, so a test can carry on from there:

src/pages/components/PageHeader.pypython
from selenium.webdriver.common.by import By

from src.pages.UiObject import UiObject


class PageHeader:

    # the nav is in the page twice (desktop and mobile menu) - the first match is the desktop one
    HOME_LINK = UiObject(By.XPATH, "//nav//a[normalize-space() = 'HOME']")
    GET_STARTED_LINK = UiObject(By.XPATH, "//nav//a[normalize-space() = 'GET STARTED']")
    TUTORIALS_LINK = UiObject(By.XPATH, "//nav//a[normalize-space() = 'TUTORIALS']")
    DOCUMENTATION_LINK = UiObject(By.XPATH, "//nav//a[normalize-space() = 'DOCUMENTATION']")
    ABOUT_LINK = UiObject(By.XPATH, "//nav//a[normalize-space() = 'ABOUT']")
    LOGO = UiObject(By.XPATH, "//a[@aria-label = 'Test Junkie home']")
    NAV_BAR = UiObject(By.XPATH, "//nav")

    @staticmethod
    def click_home():
        PageHeader.HOME_LINK.click()
        from src.pages.HomePage import HomePage
        return HomePage()

    @staticmethod
    def click_logo():
        PageHeader.LOGO.click()
        from src.pages.HomePage import HomePage
        return HomePage()

    @staticmethod
    def click_get_started():
        PageHeader.GET_STARTED_LINK.click()
        from src.pages.GetStartedPage import GetStartedPage
        return GetStartedPage()

    @staticmethod
    def click_tutorials():
        PageHeader.TUTORIALS_LINK.click()
        from src.pages.TutorialsPage import TutorialsPage
        return TutorialsPage()

    @staticmethod
    def click_documentation():
        PageHeader.DOCUMENTATION_LINK.click()
        from src.pages.DocumentationPage import DocumentationPage
        return DocumentationPage()

    @staticmethod
    def click_about():
        PageHeader.ABOUT_LINK.click()
        from src.pages.AboutPage import AboutPage
        return AboutPage()
src/pages/components/PageFooter.pypython
from selenium.webdriver.common.by import By

from src.pages.UiObject import UiObject


class PageFooter:

    # the footer links wrap their text in other elements, so they're found by where they point
    COPYRIGHT = UiObject(By.XPATH, "//footer//p[contains(., '©')]")
    HOME_LINK = UiObject(By.XPATH, "//footer//a[@href = '/']")
    GET_STARTED_LINK = UiObject(By.XPATH, "//footer//a[@href = '/get-started/']")
    TUTORIALS_LINK = UiObject(By.XPATH, "//footer//a[@href = '/tutorials/']")
    DOCUMENTATION_LINK = UiObject(By.XPATH, "//footer//a[@href = '/documentation/']")
    ABOUT_LINK = UiObject(By.XPATH, "//footer//a[@href = '/about/']")

    @staticmethod
    def get_copyright():
        return PageFooter.COPYRIGHT.get_text()

    @staticmethod
    def click_home():
        PageFooter.HOME_LINK.click()
        from src.pages.HomePage import HomePage
        return HomePage()

    @staticmethod
    def click_get_started():
        PageFooter.GET_STARTED_LINK.click()
        from src.pages.GetStartedPage import GetStartedPage
        return GetStartedPage()

    @staticmethod
    def click_tutorials():
        PageFooter.TUTORIALS_LINK.click()
        from src.pages.TutorialsPage import TutorialsPage
        return TutorialsPage()

    @staticmethod
    def click_documentation():
        PageFooter.DOCUMENTATION_LINK.click()
        from src.pages.DocumentationPage import DocumentationPage
        return DocumentationPage()

    @staticmethod
    def click_about():
        PageFooter.ABOUT_LINK.click()
        from src.pages.AboutPage import AboutPage
        return AboutPage()
Locators that survive a redesign
The footer links wrap their text in other elements, so text() = 'Home' wouldn't match them; they're found by where they point (@href) instead. Prefer what an element is (its link target, an aria-label, an id) over how it looks: the header logo is found by its aria-label, not by an image file name.

The pages

A simple page only needs its address and title:

src/pages/HomePage.pypython
from src.pages.BasePage import BasePage
from src.pages.components.PageFooter import PageFooter
from src.pages.components.PageHeader import PageHeader


class HomePage(BasePage):

    def __init__(self):
        BasePage.__init__(self, directory="/", title="Test Junkie")
        self.header = PageHeader()
        self.footer = PageFooter()

    def get_headlines(self):
        # DICT of the page's headings by tag, e.g. {"h1": [...], "h2": [...]}
        from selenium.webdriver.common.by import By
        from src.pages.UiObject import UiObject
        headlines = {}
        for tag in ("h1", "h2", "h3"):
            if UiObject(By.TAG_NAME, tag).exists():
                headlines[tag] = [element.text for element in UiObject(By.TAG_NAME, tag).get_elements()]
        return headlines

GetStartedPage, TutorialsPage and AboutPage are the same with their own class name, directory and title ("/get-started/", "Get Started with Test Junkie", and so on). The documentation page also knows its menu and sections:

src/pages/DocumentationPage.pypython
from selenium.webdriver.common.by import By

from src.pages.BasePage import BasePage
from src.pages.UiObject import UiObject
from src.pages.components.PageFooter import PageFooter
from src.pages.components.PageHeader import PageHeader


class DocumentationPage(BasePage):

    LEFT_NAV_LINKS = UiObject(By.XPATH, "//*[@id = 'leftCol']//a[starts-with(@href, '#')]")
    SECTIONS = UiObject(By.XPATH, "//div[@id = 'content']//div[contains(@class, 'section') and @id]")

    def __init__(self):
        BasePage.__init__(self, directory="/documentation/", title="Test Junkie Documentation")
        self.header = PageHeader()
        self.footer = PageFooter()

    def get_left_nav_links(self):
        # LIST of UiObjects, one per section link in the left-hand menu
        return [UiObject.from_web_element(element) for element in DocumentationPage.LEFT_NAV_LINKS.get_elements()]

    def get_content_links_per_section(self):
        # DICT of section id -> the in-page (#anchor) links in that section, e.g. {"retry": [...], "tags": [...]}
        links = {}
        for section in DocumentationPage.SECTIONS.get_elements():
            anchors = section.find_elements(By.XPATH, ".//a[starts-with(@href, '#')]")
            if anchors:
                links[section.get_attribute("id")] = [UiObject.from_web_element(anchor) for anchor in anchors]
        return links


if __name__ == "__main__":
    import pprint
    from src.pages.Browser import Browser
    page = DocumentationPage().open()
    pprint.pprint({section: len(links) for section, links in page.get_content_links_per_section().items()})
    print(len(page.get_left_nav_links()), "left nav links")
    Browser.shutdown()

Try it

The if __name__ == "__main__" block at the end of DocumentationPage.py lets you check a page object on its own, before any test uses it:

python -m src.pages.DocumentationPagetext
{'assertions': 2,
 'cli': 6,
 'parameters': 11,
 'retry': 5,
 ...
 'tags': 11,
 'test': 20}
34 left nav links

Why this pays off

  • A new page gets the header and footer for free.
  • When a component changes, you fix it in one place.
  • The domain comes from Settings.DOMAIN, so the same tests run against any environment.
  • A bug in a shared component breaks many tests at once, but one fix also repairs them all: keep page objects simple, and check them on their own like above.