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:
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:
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()
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()
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:
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:
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:
{'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.