PART 3: creating page objects

BUILDING TEST AUTOMATION FRAMEWORK WITH TEST JUNKIE & SELENIUM WEBDRIVER

THE GOAL

Build a streamlined interface to interact with the target website.

THE HOW

There are many ways to fail when building Page Objects and magnitude of the failure will, largely, depend on the application under test and the people who are building it. In this tutorial I will show the most resilient way, that I know of, to build Page Objects so that they can withstand any changes and inconsistencies in the application under test.

With that said, I shall start with the Base Page! A Base Page will contain the bare minimum functionality that will be available to any Page Object. When defining Base Page, think about what if your cat, that you don't have (or maybe you do have), created a website - the functionality in the Base Page must work on that site. But wait, how do you know what that site may do or what it will look like? Well that's the thing, you don't. If you have functionality in the Base Page that you are questioning and not sure if its going to work on every site - that means its not generic enough and it, probably, should be removed from the Base Page. Now that we have the scientific stuff out of the way, lets take a look at the code. Feel free to copy this code into BasePage.py

from src.pages.Browser import Browser


class BasePage:

    def __init__(self, domain, directory, title):
        """
        This class will be inherited by all of the Page Objects
        :param domain: STRING, Base domain of the website aka "https://test-junkie.com"
                               Base domain & expected URL will be used to open this page
        :param directory: STRING, expected URL of the page that is inheriting this class aka:
                                  "/documentation/" or "/get-started/"
        :param title: STRING, expected Title of the page that is inheriting this class
        """
        self.__title = title
        self.__directory = directory
        self.__domain = domain
        self.__expected_url = "{domain}{directory}".format(domain=domain, directory=directory)

    @property
    def expected_directory(self):
        return self.__directory

    @property
    def expected_domain(self):
        return self.__domain

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

    @property
    def expected_url(self):
        return self.__expected_url

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

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

    def open(self, **kwargs):
        """
        This method will open the page which inherited BasePage
        :return: self (Page Object which inherited BasePage will be returned)
        """
        Browser.get_driver().get(self.expected_url)
        return self

As you can see, there is nothing complicated going on with the Base Page. Just some functions to get expected meta data and the actual meta data through one common interface. Which will allow us to do basic assertions. Bigger functionality here is the open function which will open any page that we want. This Class will be inherited, therefore, the open function can be overwritten in case we need to support dynamic pages with IDs in their URL or any other parameters for that matter.

Its good that I have a Base Page but I still need to support the header and the footer. We will treat those as common components. A component is anything on the page that has the same DOM structure and re-used in multiple places. So lets take a look at how to create support for components. Feel free to copy the code for components in their respective Python files.

from selenium.webdriver.common.by import By
from src.pages.UiObject import UiObject


class PageHeader:

    HOME_LINK = UiObject(By.XPATH, "//ul[2]/li/a[text() = 'HOME']")
    GET_STARTED_LINK = UiObject(By.XPATH, "//ul[2]/li/a[text() = 'GET STARTED']")
    TUTORIALS_LINK = UiObject(By.XPATH, "//ul[2]/li/a[text() = 'TUTORIALS']")
    DOCUMENTATION_LINK = UiObject(By.XPATH, "//ul[2]/li/a[text() = 'DOCUMENTATION']")
    ABOUT_LINK = UiObject(By.XPATH, "//ul[2]/li/a[text() = 'ABOUT']")
    LOGO = UiObject(By.XPATH, "//img[@title = 'Test Junkie logo']/parent::a")
    NAV_BAR = UiObject(By.XPATH, "//div[@class = 'navbar-fixed']")

    def __init__(self):
        pass

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

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

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

    @staticmethod
    def click_documentation():

        PageHeader.DOCUMENTATION_LINK.click()
        from src.pages.documentation.DocumentationPage import DocumentationPage
        return DocumentationPage()

    @staticmethod
    def click_about():

        PageHeader.ABOUT_LINK.click()
        from src.pages.about.AboutPage import AboutPage
        return AboutPage()

    @staticmethod
    def click_logo():

        PageHeader.LOGO.click()
        from src.pages.home.HomePage import HomePage
        return HomePage()

The reason I went with static methods for my Header and Footer components is because I know they will not change and there wont be anything different about them no matter what page they are on. However, there is one component that we have not covered yet. You may noticed that some of the pages have tabs. They are present on the Get Started page, the Documentation page and this very page. Thus I need to create a component that will allow me to support those in my Page Objects. I'm going to provide the code snippet bellow. Feel free to copy-paste the code into the Tabs.py file. Respective file names are displayed on the tabs.

from selenium.webdriver.common.by import By
from src.pages.UiObject import UiObject


class Tabs:

    def __init__(self, container=None):
        self.__common_xpath = "{container}//div/ul[@class = 'tabs']"\
                              .format(container="" if not container else container)

    def get_active_content(self):
        return UiObject(By.XPATH, "{container}/parent::div/following-sibling::div[@id and contains(@class, 'active')]"
                        .format(container=self.__common_xpath)).get_text()

    def click_tab(self, value):
        UiObject(By.XPATH, "{container}//a[text() = '{value}']"
                 .format(container=self.__common_xpath, value=value)).click()
        return self

    def get_tab_objects(self):
        tabs = []
        elements = UiObject(By.XPATH, "{container}//a".format(container=self.__common_xpath)).get_elements()
        for element in elements:
            tabs.append(UiObject.from_web_element(element))
        return tabs

Notice that it does not have any static methods because this component is dynamic and there can be more than one on the same page. Thus I need to be able to instantiate multiple instances of the class and the XPATH must accommodate that. When deciding between using static methods do consider this factor.

Now I can put all the pieces in place and finish the Page Objects for the site! I'm going to provide the code snippets bellow for each page. Feel free to copy-paste the code into their respective files. Respective file names are displayed on the tabs.

from selenium.webdriver.common.by import By

from src.pages.BasePage import BasePage
from src.pages.UiObject import UiObject
from src.pages.components.FloatingMenu import FloatingMenu
from src.pages.components.PageFooter import PageFooter
from src.pages.components.PageHeader import PageHeader
from src.utils.Settings import Settings


class HomePage(BasePage):

    GIF = UiObject(By.XPATH, "(//img)[2]")

    def __init__(self):

        BasePage.__init__(self,
                          domain=Settings.DOMAIN,
                          title="Advanced Test Runner for Python - Test Junkie",
                          directory="")
        self.header = PageHeader()
        self.footer = PageFooter()
        self.floating_button = FloatingMenu()

    def get_headlines(self):
        """
        :return: DICT, all headlines on the page mapped by the H tag aka
                       {"h1": [...],
                        "h2": [..., ...]}
        """
        headlines = {}
        for heading in ["h1", "h2", "h3"]:
            elements = UiObject(By.TAG_NAME, heading).get_elements()
            for element in elements:
                if heading not in headlines:
                    headlines.update({heading: []})
                headlines[heading].append(UiObject.from_web_element(element).get_text())
        return headlines

    def get_card_info(self):
        """
        :return: LIST of DICTs, Why test junkie cards listed with title, desc, icon, etc aka
                                [{"title": ...,
                                  "description": ...,
                                 {...},
                                 {...}]
        """
        cards = []
        card = "//div[@class = 'card-content']"
        for index in range(1, 5):
            cards.append({"title": UiObject(By.XPATH, "({}/span)[{}]".format(card, index)).get_text(),
                          "description": UiObject(By.XPATH, "({}/p)[{}]".format(card, index)).get_text()})
        return cards

    def get_quote(self):
        """
        :return: DICT, structured data for the currently active quote that is on the screen aka
                       {"name": ..., "quote": ..., "title": ...}
        """
        block = "//div[contains(@class, 'slick-slide slick-current')]"
        return {"name": UiObject(By.XPATH, "{}//h3".format(block)).get_text(),
                "quote": UiObject(By.XPATH, "{}//blockquote".format(block)).get_text(),
                "title": UiObject(By.XPATH, "{}//p".format(block)).get_text()}

from src.pages.BasePage import BasePage
from src.pages.components.FlashMsg import FlashMsg
from src.pages.components.FloatingMenu import FloatingMenu
from src.pages.components.PageFooter import PageFooter
from src.pages.components.PageHeader import PageHeader
from src.pages.components.Tabs import Tabs
from src.utils.Settings import Settings


class GetStartedPage(BasePage):

    def __init__(self):

        BasePage.__init__(self,
                          domain=Settings.DOMAIN,
                          title="Get Started - Test Junkie",
                          directory="/get-started/")
        self.header = PageHeader()
        self.footer = PageFooter()
        self.floating_button = FloatingMenu()
        self.flash_msg = FlashMsg()
        self.installation_card = Tabs(container="//div[@id = 'installation']")
        self.usage_card = Tabs(container="//div[@id = 'basic_usage']")
        self.test_execution_card = Tabs(container="//div[@id = 'test_execution']")
from selenium.webdriver.common.by import By

from src.pages.BasePage import BasePage
from src.pages.UiObject import UiObject
from src.pages.components.FlashMsg import FlashMsg
from src.pages.components.FloatingMenu import FloatingMenu
from src.pages.components.PageFooter import PageFooter
from src.pages.components.PageHeader import PageHeader
from src.pages.components.Tabs import Tabs
from src.utils.Settings import Settings


class DocumentationPage(BasePage):

    def __init__(self):

        BasePage.__init__(self,
                          domain=Settings.DOMAIN,
                          title="Documentation - Test Junkie",
                          directory="/documentation/")
        self.header = PageHeader()
        self.footer = PageFooter()
        self.floating_button = FloatingMenu()
        self.flash_msg = FlashMsg()
        self.cli_card = Tabs(container="//div[@id = 'cli']")

    def get_left_nav_links(self):
        """
        Gets all the links from the Left Navigation (as UiObjects)
        :return: LIST of UIObjects aka
                     [<src.pages.UiObject.UiObject instance at 0x02F4CCB0>,
                      <src.pages.UiObject.UiObject instance at 0x02F4CAF8>,
                      <src.pages.UiObject.UiObject instance at 0x02F4CC88>,
                      ...,
                      ...]
        """
        links = []
        for element in UiObject(By.XPATH, "//div[@id='leftCol']//a").get_elements():
            links.append(UiObject.from_web_element(element))
        return links

    def get_content_links_per_section(self, section_id=None):
        """
        Gets all the links on the page (as UiObjects) and maps them to their respective section
        :param section_id: STRING, section ID as appears in the DOM. Allows to get links for a specific section.
                                (Faster if you don't need links for all sections)
        :return: DICT, section to links mapping aka
                       {'cli': [<src.pages.UiObject.UiObject instance at 0x02F09BC0>,
                                <src.pages.UiObject.UiObject instance at 0x02F17940>,
                                <src.pages.UiObject.UiObject instance at 0x02F174B8>],
                        'section': [..., ..., ...],
                        'section': [..., ..., ...]}
        """
        def get_links():
            section_links = UiObject(By.XPATH, "//div[@id='content']/div[@id='{}']//a".format(section_id))
            _links = {section_id: []}
            if section_links.exists():
                for link in section_links.get_elements():
                    _links[section_id].append(UiObject.from_web_element(link))
            return _links

        if section_id:
            return get_links()

        links = {}
        section_xpath = "//div[@id='content']/div[contains(@class, 'section') and @id]"
        sections = UiObject(By.XPATH, section_xpath).get_elements()
        for element in sections:
            section_id = UiObject.from_web_element(element).get_attribute("id")
            links.update(get_links())
        return links
from selenium.webdriver.common.by import By

from src.pages.BasePage import BasePage
from src.pages.UiObject import UiObject
from src.pages.components.FlashMsg import FlashMsg
from src.pages.components.FloatingMenu import FloatingMenu
from src.pages.components.PageFooter import PageFooter
from src.pages.components.PageHeader import PageHeader
from src.utils.Settings import Settings


class AboutPage(BasePage):

    AUTHOR_IMG = UiObject(By.XPATH, "//img[@class='team_member']")

    def __init__(self):

        BasePage.__init__(self,
                          domain=Settings.DOMAIN,
                          title="About - Test Junkie",
                          directory="/about/")
        self.header = PageHeader()
        self.footer = PageFooter()
        self.floating_button = FloatingMenu()
        self.flash_msg = FlashMsg()

    def get_description(self):

        return UiObject(By.XPATH, "//div[@id = 'content']/p").get_text()

    def get_project_status_links(self):
        links = []
        elements = UiObject(By.XPATH, "//div[@id = 'project_status']/a").get_elements()
        for element in elements:
            links.append(UiObject.from_web_element(element))
        return links

Look how much time we are able to save by reusing the components we created. This is what good development velocity looks like. Some of the pros of this architecture:

  1. If a new page is added, 2/3 of that page's functionality is likely going to be covered by the Footer/Header.
  2. If that new page will use tab components, it is already supported and tests can be created immediately.
  3. If functionality of a component changes, just need to address that change in one place.
  4. If functionality was removed from a page, just need to remove a component definition from that page. By having explicit definitions, its possible to see all the usages (most modern IDEs will let you do this) and remove them as well, keeping the code squeaky clean.
  5. Also, notice that domain for the pages is dynamic, coming from Settings.DOMAIN - which will allow to run tests on multiple environments just with a simple domain swap.
However, this approach can have very severe negative consequences, especially with large engineering teams. A bug in a component can impact many tests that use that component to go through user flows. Thus it is critical to make sure your page objects are flushed out. On the flip side though, a bug fix in a component (whether its an xpath change or something else) can resolve multiple broken tests.

TEST IT

I like to add sample code directly in the page objects. That way I can easily test any changes to that page without having to do anything special. Here is an example for the DocumentationPage.py:

...
if "__main__" == __name__:
    import pprint
    page = DocumentationPage().open()

    content_links = page.get_content_links_per_section()
    pprint.pprint(content_links)

    navigation_links = page.get_left_nav_links()
    pprint.pprint(navigation_links)

    # lets check and make sure the tab component works. By default its on "Run"
    print page.cli_card.get_active_content()  # description for "Run" section should be printed
    print page.cli_card.click_tab("Audit")  # switch to "Audit"
    print page.cli_card.get_active_content()  # new description for "Audit" section should be printed
Now we can just run that file and see how our code performs. If something does not work, apply fixes and run the file again. Next >>

footer-background-top