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.
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:
- If a new page is added, 2/3 of that page's functionality is likely going to be covered by the Footer/Header.
- If that new page will use tab components, it is already supported and tests can be created immediately.
- If functionality of a component changes, just need to address that change in one place.
- 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.
- 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.
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 >>