Python Automation Testing: Pytest and Pytest-bdd

   Quality Assurance
Python, Pytest, Pytest-bdd Automation Testing

This is part of the Python Automation Testing blog series. You can review the code from this article on the Python Automation Git repo.

A while back I started working on a new project where I had to put together an automated testing solution for Hybrid Applications developed with Ionic/Angular. I first considered ProtractorJS, as I’ve used it before and it works well with AngularJS. But the client wanted to use Appium/Python due to restrictions of the test environment, Amazon Device Farm (ADF).

Using Python for Automation Testing was new to me. I had only used it in the past to write backend scripts and DevOps automation. My journey began when I took to the web, trying to find something to help me get started. I was surprised to find there were either no complete setups for this Technology Stack or I could not find them. Placed in a situation where I had to start from scratch, I began to research again. This time I started reading documentation on the tools … a lot of documentation.

I will describe everything that you need for a successful setup which can be used for local runs below. Follow these instructions and you will benefit from skipping all the bumps in the road I faced. You’re welcome 😉

For setup, you will use two tools: pytest, which is a test framework for Python (this allows us to write scalable and complex functional tests) and Pytest_bdd, which implements a subset of the Gherkin language for the automation of the project requirements testing (and easier behavioral driven development). Please note, pytest_bdd does not require a separate test runner; it works out of the box with the py.test runner.

A full tutorial on how to install all dependencies can be found here.

First, let’s talk about the folder structure. Ideally, you should place all your files into a separate folder. In my example it’s called tests_root:

Tests Root Folder for Python Automation Testing


The WebDriver configuration is kept within conf_driver.py and needs to contain the following methods:

def set_up():# This sets up the browser object
  ...
def tear_down():# This will destroy the browser object and clear all related data
  ...

Next let’s address the conftest.py file. This is the root configuration file for pytest. Here you can set Fixtures, External plugin loading, Hooks or Test root path. Your setup can also have test specific configurations. Refer to this GitHub project for examples. — more –.

I use pytest.fixtures for actions like set_up and tear_down. The purpose of test fixtures is to provide a fixed baseline upon which tests can reliably and repeatedly execute. Fixtures have explicit names and are activated by declaring their use from test functions, modules, classes or whole projects.
I also use tags which skip the test scenarios that are not automated, for example. So, by applying the tag @automated to a scenario, pytest will try to run it, otherwise it will skip it.

The next file to understand is the constants.json file. This is where project specific data and test configuration data is stored:

{
"driver": {
  "implicit_wait_time": 10,
  "timeout": 30
},
"project": {
  "suites": {
     "calculator": "Calculator"
},
  "tags":"",
  "language": "en",
  "market": "us"
}
}

Since BDD is used, don’t miss the Gherkin Feature files:

@JIRA-1
Feature: Calculator
  As a user
  I want to be able to sum numbers

  @JIRA-2 @automated
  Scenario: Add two numbers with examples
    Given I have powered calculator on
    When I enter  into the calculator
    When I enter  into the calculator
    When I press 
    Then the result should be  on the screen
    Examples:
      | number_1 | number_2 | result |
      | 10       | 20       | 30     |
      | 50       | 60       | 110    |

First we describe the feature, which in most cases is the acceptance criteria, then we have different scenarios, which are test cases. The Gherkin language uses the given, when, then steps to describe an action, which is easy to read and understand not only for the QA team, but for all stakeholders.

Next we have the i18n.json file where we define our internationalization keys:

{
  "en": {
     "ADD": "Add"
},
  "es": {
     "ADD": "Anadir"
}
}

This allows you to choose which language you want to use. And example is: en/es.

Next up is the pages folder. This is where we have the page objects, which are structured like this:

  • Base_page.py – contains the generic methods that are inherited by all pages:
def find_element(self, *locator):
  if locator.__len__() == 2:
     return self.driver.find_element(*locator)
  # This was added to make the parametrization of a locator possible.
  # Usage: self.find_element(‘Next’, *LoginPageLocators.login_button
  return self.driver.find_element(*(locator[1], locator[2] % locator[0]))

def find_elements(self, *locator):
  if locator.__len__() == 2:
     return self.driver.find_elements(*locator)
  return self.driver.find_elements(*(locator[1], locator[2] % locator[0]))

-- more --

As you can see this creates a new object class, then creates methods for different helper functions that can be used in functional tests.

Next we have the locators.py file, where the locators for all pages are defined:

from selenium.webdriver.common.by import by
class GlobalLocators(object):
  button = (By.XPATH, '//button/*[contains(., "%s")]')

button_list = {
  'disagree': 'Disagree',
  'continue': 'Continue',
  'done': 'Done'
}

class WelcomeLocators(object):
  …

You might notice the %s placeholder in the button locator. This is there because I created a single locator for all the buttons in the app. Above in the base_page.py file you can see, for example, the find_element method, which has two returns:
return self.driver.find_element(*locator) – used when no extra parameter is passed, like in the button_list example, therefore the locator will have 2 arguments
return self.driver.find_element(*(locator[1], locator[2] % locator[0])) – this return is used when locator length has 3 arguments

Below, in the welcome_page.py, are some examples on how the find_element is used using 2 and 3 arguments:

from tests.pages.base_page import BasePage
from tests.pages.locators import GlobalLocators, WelcomeLocators

class WelcomePage(BasePage):

  def get_started(self):
     self.find_element(*WelcomeLocators.get_started_button).click()
     self.find_element(GlobalLocators.button_list['continue'], 
*GlobalLocators.button).click()

In test_common.py there are common steps reusable in different places:

import pytest
from pytest_bdd import when, parsers

@when(parsers.parse('I enter {number:d} into the calculator'))
def input_number(number):
  return pytest.globalDict['number'].append(number)

@when('I enter  into the calculator')
def input_number_first(number_1):
  input_number(number_1)

And finally, we have the test_calculator.py file where the main test lives:

import pytest
from pytest_bdd import given
from pytest_bdd import scenarios

scenarios('features/calculator.feature',
     example_converters=dict(number_1=int, number_2=int, result=int))

@given('I have switched calculator on')
def power_on_calculator(driver):
  print('PLATFORM NAME')
  if pytest.globalDict['driver']:
     print(pytest.globalDict['driver'].desired_capabilities['platformName'])
  else:
     print('NONE')
  print('PLATFORM NAME')
  if driver:
     print(driver.desired_capabilities['platformName'])
  else:
     print('NONE')
  pytest.globalDict['number'] = []

Congratulations! Now you can run the automated tests by simply running Appium in a different terminal window, and run:
py.test -vv --gherkin-terminal-reporter
This will run the test suite that is specified in the constants.json file, and give you a FAILED/PASSED status for each scenario.

The full code can be accessed in the github repository here.

Next article we will talk about Pytest and Amazon Device Farm integration.


Like What You See?

Got any questions?


>