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:
Here is the same test using the DSL Elementium to wrap the Selenium functions:
(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:
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.
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:
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:
Then we can use our locator class as an object within the Page Object Class like this:
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:
So for all of our try blocks, we will have the following:
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.
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.
1. Create the following directories and files in your 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:
a) Within the Features directory, 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__.py ” and 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.
Also, the 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!