QA UI Automation Part 3: Advanced Automation Design Patterns

In the first post of this series, we built a basic QA UI automation framework, and in the second post we explored automation best practices and common UI design patterns. In this final post, we will write a fully working automation framework using advanced automation design patterns and best practices. We’ll also provide more information about the concepts behind these approaches, so you have a framework that is easy to understand for new team members, easy to adapt to changing needs, and most importantly, easy to maintain. 

 

Best Practice: Streamlining the QA Process Using Abstractions

You can approach most problems in several different ways. For this project, I approach streamlining the QA process by using several abstraction layers. This approach might not be best if your QA team lacks experience; in that case, you should consider that your team needs time to learn. On the other hand, if your team is familiar with Python, writing test cases or scenarios using this approach will be straightforward. 

For a simpler project, you could also choose to use fewer “necessary” layers, such as bot-style functions, or you could use a different selector approach. For example, you could initialize the webElements at the beginning of the class; in my opinion, these approaches can be harder to maintain. 

 

Best Practice: Wrap Selenium Functions Into Common, More Legible Functions

As we shared in the first post, we use Selenium WebDriver, a web automation framework, to test different browsers and platforms.

We’re going to use a Selenium Domain-Specific Language (DSL) to wrap Selenium functions into common and more legible functions. Some DSLs do this by using JQuery-style syntactic sugar for easier reading, and some use chainable methods with obvious names.

For this exercise, I chose Elementium. Other DSLs examples are, Splinter or Selene.

Here is a basic Selenium test:

Basic Selenium test

Here is the same test using the DSL Elementium to wrap the Selenium functions:

Using DSL Elementium to wrap the Selenium functions in a basic Selenium test

(Note: You can read the Elementium documentation for more details.)

 

Automation Design Patterns: Bot-Style Functions to Simplify Automation

Some workflows contain common actions that a user may repeat. For example, a search function can join a click and a write function into a single function. To simplify our automation process by creating a smaller page object, we can combine exceptions, code flows, or specific actions such as these into just a single code line. 

Here is an example of this bot-style function:

Bot-style function

The write() function in this example encapsulates the two ways Elementium calls WebElements: by xpath only, or by using the find function where you can search by ID, CSS, name, tag_name, and more.

Selenium selector strategy

To automate low-level interactions such as mouse movements, mouse button actions, key presses, and context menu interactions, we can use ActionChains. ActionChains are especially useful for more complex actions like hover-over and drag-and-drop.

(Refer to the documentation for Python Selenium bindings documentation here and ActionChains bindings here.)

Here’s a MouseHover ActionChains example:

MouseHover ActionChains example

After writing our functions, we can use them as the following examples:

• bot.mousehover(browser, self.locators.send_button) 

• bot.search(self.browser, self.locators.search_textbox, self.locators.search_button, text)

Bot-style functions make it much easier to write and repeat code lines for the same actions.

 

Automation Design Patterns: Dynamic Selectors to Streamline Testing

To test web apps, we need to know how to locate elements on a web page and perform operations on them. This is easy enough to do with static elements that don’t change. We use the locators associated with the element (ID, xPath, Name, CSS Selector, or some combination of these) and perform the operation.

However, many web pages contain dynamic elements that change every time the page reloads. Consider a page on an e-commerce site that lists products. If the user chooses to sort or filter the product list based on price or category, the page reloads, and then the locator associated with each product changes. 

To interact with these dynamic elements, we can use a selector dictionary class instead of a single webElement. This will return the dictionary and iterate until we get a functional selector.

Here’s a class that contains the locators dictionary:

A class that contains the locators dictionary

Then we can use our locator class as an object within the Page Object Class like this:

Use locator class as an object within the Page Object Class

How useful is this approach? Consider this: what happens if an ID, CSS, or xPath selector changes? Nothing! If just one selector works, our test case will pass. 

The primary disadvantage to this approach is that it may add more time in setting up. However, if we maintain outdated selectors, then we can focus on one class only and our test cases and functions remain as they are—and it’s all easier to read.

 

Automation Design Patterns: How to Handle Common Exceptions

Exceptions happen, and we need to prepare for them in our framework. In the book Clean Code, Robert Martine writes, “One of the most interesting things about exceptions is that they define a scope within your program. When you execute code in the try portion of a try-catch-finally statement, you are stating that execution can abort at any point and then resume at the catch.”

In our scope, we will definitely encounter common errors when working with Selenium. We should be aware of what is happening when the errors occur so we can fix issues in the code. To get this information, I wrote these two functions: 

Functions to use when errors occur so we can fix issues in the code

So for all of our try blocks, we will have the following:

wrapping_exceptions()

With “wrapping_exceptions()”, all the common exceptions thrown are concatenated and called here. If there is a match, it will be handled gracefully.

We can see that “Return get_error_details(sys.exc_info(), e)” will read the traceback, and will show us the file and code line that has the error with the corresponding error message.

File and code line that has the error with the corresponding error message

 

Let’s Put It Into Action: Writing Our Project

Before we write our project, make sure you’ve set up the environment. This example uses Python 3.6. You should have the dependencies already installed and configured, and the ChromeDriver should be configured as well.  For a detailed explanation of what’s required, please refer to the first blog post in this series.

Environment set up

1. Create the following directories and files in your development directory:

Directories and files to create in development directory

2. Run configs/userconfig.json.

{
    “browser”: “chrome”,
    “url”    : “https://www.google.com/”
}

3. Run features/steps/common.py.

from hamcrest import assert_that, equal_to, is_


@step(u'I navigate to google page')
def step_impl(context):
    context.login_url=context.config.userdata.get('url')
    context.browser.navigate(context.login_url)
    assert_that(context.browser.browser.title, is_(equal_to("Google")))

4. Run features/steps/google_search_page.py.

import sys
from hamcrest import assert_that, equal_to, is_not, is_

sys.path.append('../libs')
from libs.pages import *


@step(u'I search for "{search_term}"')
def step_i_search_for(context, search_term):
    context.page = PageBuilder("GoogleSearch")(context.browser)
    assert_that(context.page.search(search_term), is_not("Error when trying to search"))


@step('I see "{search_result}"')
def step_i_check_a_result(context, search_result):
    context.page = PageBuilder("GoogleSearch")(context.browser)
    assert_that(context.page.check_text_result(search_result), is_(equal_to(search_result)))

5. Run features/environment.py.

import sys
import os

sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
from utils import utils, userdata


def before_scenario(context, scenario):
    userdata.loading_userdata_from_json(context)
    utils.setup_browser(context)

def after_scenario(context, scenario):
    utils.clean_browser(context)    

6. Run features/google_search.feature.

Feature: Search Page

  Background: Opening webpage
  Given I navigate to google page

  Scenario: Search into google
    When I search for "python behave"
    And I see "Welcome to behave! — behave 1.2.7.dev1 documentation"

7. Run libs/locators/google_search_locators.py.

class GoogleSearchLocators():
    search_button = {
        "css": "ul[role='listbox'] > li:nth-of-type(1)  div[role='option']  span",
        "xpath": "//*//ul[@role='listbox']/li[1]//div[@role='option']//span]"
    }

    search_textbox = {
        "css": "[maxlength]",
        "xpath": "//*/input[@role='combobox']"
    }

    search_result ={
        "xpath": "//*/h3[contains(.,'{}')]"
    }

8. Run libs/pages/__init__.py.

__author__ = 'dsolis'
"""
This class will handle all class pages within libs.pages
"""
# imports are not used until the return eval calls them
import pages.google_search_page as GoogleSearchPage


class PageBuilder(object):
    """
    It will return the given class as  an object
    """
    def __new__(cls, page_name):
        try:
            return eval(page_name + 'Page.'+ page_name + 'Page')
        except NameError:
            print('Unable to find ' + page_name + 'Page' + '. Try adding the'
                  ' class to the pages __init__, verify the class'
                  ' exist within the libs.pages module, and double check the '
                  'class spelling ')

9. Run libs/pages/base.py.

"""
This is the base class where basic browser settings are set up
"""


class BasePage(object):
    def __init__(self, browser):
        self.browser = browser
        #for example
        #self.browser.browser.set_page_load_timeout(10)

10. Run libs/pages/bot_style.py.

import sys
sys.path.insert(0,"")
import hamcrest
from utils import exceptions_handler


def click(browser, selector):
    global se
    try:
        se = browser.xpath(selector["xpath"], wait=True)
    except (exceptions_handler.wrapping_exceptions()):
        try:
            se = browser.find(selector["css"], wait=True)
        except (exceptions_handler.wrapping_exceptions()) as e:
            return exceptions_handler.get_error_details(sys.exc_info(), e)
    try:
        hamcrest.assert_that(str(se), hamcrest.is_not("SeElements([])"))
        se.click()
    except (exceptions_handler.wrapping_exceptions()) as e:
        return exceptions_handler.get_error_details(sys.exc_info(), e)


def write(browser, selector, text):
    global se
    try:
        se = browser.xpath(selector["xpath"], wait=True)
    except (exceptions_handler.wrapping_exceptions()):

        for locator in selector:
            try:
                se = browser.find(selector[locator], wait=True)
            except (exceptions_handler.wrapping_exceptions()):
                continue
    try:
        hamcrest.assert_that(str(se), hamcrest.is_not("SeElements([])"))
        se.clear()
        se.write(text)
    except (exceptions_handler.wrapping_exceptions()) as e:
        return exceptions_handler.get_error_details(sys.exc_info(), e)


def search(browser, search_textbox, search_button, text):
    try:
        write(browser,search_textbox, text)
        click(browser,search_button)
    except (exceptions_handler.wrapping_exceptions()) as e:
        return exceptions_handler.get_error_details(sys.exc_info(), e)


def check_text(browser, selector, text):
    global se
    try:
        se = browser.xpath(selector["xpath"].format(text), wait=True)
    except (exceptions_handler.wrapping_exceptions()):

        for locator in selector:
            try:
                se = browser.find(selector[locator].format(text), wait=True)
            except (exceptions_handler.wrapping_exceptions()):
                continue
    try:
        hamcrest.assert_that(str(se), hamcrest.is_not("SeElements([])"))
        return se.text()
    except (exceptions_handler.wrapping_exceptions()) as e:
        return exceptions_handler.get_error_details(sys.exc_info(), e)

11. Run libs/pages/google_search_page.py.

import sys, os
import pages.bot_style as bot

sys.path.insert(0, "")
from . import base
import libs.locators.google_search_locators as google_search_locators
from utils import exceptions_handler


class GoogleSearchPage(base.BasePage):
    def __init__(self, browser):
        super(GoogleSearchPage, self).__init__(browser)
        self.GSLocators = google_search_locators.GoogleSearchLocators

    def search(self, text):
        try:
            bot.search(self.browser, self.GSLocators.search_textbox, self.GSLocators.search_button, text)
        except (exceptions_handler.wrapping_exceptions()) as e:
            exceptions_handler.get_error_details(sys.exc_info(), e)
            return 'Error when trying to search'

    def check_text_result(self, text):
        try:
            return bot.check_text(self.browser, self.GSLocators.search_result, text)
        except (exceptions_handler.wrapping_exceptions()) as e:
            exceptions_handler.get_error_details(sys.exc_info(), e)
            return 'Error when trying to get the result text'

12. Run utils/utils.py.

from selenium import webdriver
from elementium.drivers.se import SeElements
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities


# browser general options should be set at base.py
def setup_browser(context):
    if 'browser' in context.config.userdata.keys():
        browser = context.config.userdata.get('browser')
        if browser is None:
            context.browser = SeElements(webdriver.Chrome())
        elif browser == "chrome":
            context.browser = SeElements(webdriver.Chrome())
        elif browser == "chrome_h":
            chrome_options = webdriver.ChromeOptions()
            chrome_options.add_argument("headless")
            chrome_options.add_argument('--no-sandbox')
            chrome_options.add_argument('--allow-running-insecure-content')
            chrome_options.add_argument('--ignore-certificate-errors')
            chrome_options.add_argument("--window-size=1920,1200")

            capabilities = DesiredCapabilities.CHROME.copy()
            capabilities['acceptSslCerts'] = True
            capabilities['acceptInsecureCerts'] = True

            context.browser = SeElements(webdriver.Chrome(chrome_options=chrome_options, desired_capabilities=capabilities))
    else:
        print("browser var does not exist",
              "Please configure a browser var within: userconfig.json - " +
              "behave.ini or using -D flag")
    return context.browser


def clean_browser(context):
    try:
        context.browser.browser.quit()
        context.browser = None
    except Exception as e:
        print(str(e))

13. Run utils/exceptions_handler.py.

import os
import selenium.common.exceptions
import elementium.elements, elementium.exc
import urllib.error


def get_error_details(exc_info, e):
    exc_type, exc_obj, exc_tb = exc_info
    fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1]
    print(f"File: {str(fname)}\nLine number: {str(exc_tb.tb_lineno)}")
    error = "Selector not found" if str(e) == "return elements.until(lambda e: len(e) > 0, ttl=ttl)" else str(e)
    print(f"Something happened: {str(error)}\n")


def wrapping_exceptions():
    return selenium.common.exceptions.WebDriverException, AssertionError, selenium.common.exceptions.NoSuchElementException, \
           elementium.elements.TimeOutError, \
            TypeError, elementium.exc.TimeOutError, selenium.common.exceptions.UnexpectedTagNameException, AttributeError,  \
           selenium.common.exceptions.InvalidSelectorException, urllib.error.URLError

14. Run utils/userdata.py.

import json
from utils.directory_handler import move_from_feaures_or_configs_dir_to


def loading_userdata_from_json(context):
    try:
        move_from_feaures_or_configs_dir_to("configs")
        userdata = context.config.userdata
        configfile = userdata.get("configfile", "userconfig.json")
        more_userdata = json.load(open(configfile))
        context.config.update_userdata(more_userdata)
        context.logger = None
        return context
    except Exception as e:
        print(str(e))

15. Run utils/directory_handler.py.

import os

def get_project_path():
    abs_filepath = os.path.abspath(__file__)
    file_dir = os.path.dirname(abs_filepath)
    parent_dir = os.path.dirname(file_dir)
    return parent_dir


def move_from_feaures_or_configs_dir_to(new_dir):
    os.chdir(get_project_path()+"/"+new_dir)


def move_from_output_dir_to_failed_scenarios_screenshots():
    if not os.path.exists("failed_scenarios_screenshots"):
        os.makedirs("failed_scenarios_screenshots")
    else:
        pass
    os.chdir("failed_scenarios_screenshots")


def move_from_failed_scenarios_screenshots_dir_to__output():
    os.chdir("..")


def create_dir_failed_scenarios():
    if not os.path.exists("failed_scenarios_zip_runs"):
        os.makedirs("failed_scenarios_zip_runs")

16. Go to requirements.txt:

Select requirements.txt

a) Within the Features directory, run “behave” command:

Within the Features directory, select run “behave” command

Our  “Features/steps/google_search_page.py step class handles the libs/pages/google_search_page.py page object class in this code line: 

context.page = PageBuilder("GoogleSearch")(context.browser) 

PageBuilder is the class name for “libs/pages/__init__.pyand it returns the class sent as argument within our step class.

This gives us a common structure, limiting into only one class the place where we need to import the class pages.

Common structure

Also, the page object classes must inherit Base class, in case we are using custom browser options.

Page object classes must inherit Base class, in case we are using custom browser options

In libs/pages/ we will have the functions that our Features/steps/ pages are going to use. Remember:

• The lib pages will use the bot-style functions

• No asserts will exist in this directory, only actions or returns 

• We’ll make our assertions into the steps functions

 

Congratulations! We’ve Built and Used a Flexible QA UI Framework to Test a Web App

In this post, we’ve built on our previous work to explore ways we can streamline our QA process for web apps using our QA UI testing framework. By using advanced automation design patterns we’ve:

• Abstracted into layers to streamline our coding process.

• Implemented a Selenium DSL to wrap our code in common, easy to understand functions.

• Used an external library (Elementium) to write an internal DSL with the use of our bot-style code, transforming Selenium with Elementium into common actions for our page objects to use.  

• Found a way to reduce failed test cases by using Dynamic Selectors to limit the impact of a selector changing on the frontend. 

• Improved how we understand the tracebacks that Python displays for errors, so we have more accurate information about the error and can more quickly fix issues in code.

Using all these different approaches gives us ownership of the actions we need to effectively QA our web application. Of course, we also have a working example of applying all these different approaches. Now I invite you to share what you learn in putting all this into action for your own projects!

 

Subscribe to our Blog

Daniel Solis
Daniel Solis
Daniel is a QA engineer with 7.5 years of experience. He prefers working with Python but has worked with Java, JS, and Ruby as well. He is an avid fan of anime and manga and in his free time enjoys to play video games and soccer. He currently lives in Cartago, Costa Rica, and is married with a wonderful eight-year-old daughter.

Deliver off-the-chart results.

WordPress Video Lightbox Plugin