TestProject recently released its new OpenSDK, and one of its major features is the inclusion of Python testing support! Since I love using Python for test automation, I couldn’t wait to give it a try. This article is my crash-course tutorial on writing Web UI tests in Python with TestProject.
What is TestProject?
TestProject is a free end-to-end test automation platform for Web, mobile, and API tests. It provides a cloud-based way to teams to build, run, share, and analyze tests. Manual testers can visually build tests for desktop or mobile sites using TestProject’s in-browser recorder and test builder. Automation engineers can use TestProject’s SDKs in Java, C#, and now Python for developing coded test automation solutions, and they can use packages already developed by others in the community through TestProject’s add-ons. Whether manual or automated, TestProject displays all test results in a sleek reporting dashboard with helpful analytics. And all of these features are legitimately free – there’s no tiered model or service plan.
Recently, TestProject announced the official release of its new OpenSDK. This new SDK (“software development kit”) provides a simple, unified interface for running tests with TestProject across multiple platforms and languages (now including Python). Things look exciting for the future of TestProject!
What’s My Interest?
It’s no secret that I love testing with Python. When I heard that TestProject added Python support, I knew I had to give it a try. I never used TestProject before, but I was interested to learn what it could do. Specifically, I wanted to see the value it could bring to reporting automated tests. In the Python space, test automation is slick, but reporting can be rough since frameworks like pytest
and unittest
are command-line-focused. I also wanted to see if TestProject’s SDK would genuinely help me automate tests or if it would get it my way. Furthermore, I know some great people in the TestProject community, so I figured it was time to jump in myself!
The Python SDK
TestProject’s Python SDK is an open-source project. It was originally developed by Bas Dijkstra, with the support of the TestProject team, and its code is hosted on GitHub. The Python SDK supports Selenium for Web UI automation (which will be the focus for this tutorial) and Appium for Android and iOS UI automation as well!
Since I’d never used TestProject before, let alone this new Python SDK, I wanted to review the code to see how to use it. Thankfully, the README included lots of helpful information and example code. When I looked at the code for TestProject’s BaseDriver, I discovered that it simply extend’s Selenium WebDriver’s RemoteDriver class. That means all the TestProject WebDrivers use exactly the same API as Python’s Selenium WebDriver implementation. To me, that was a big relief. I know WebDriver’s API very well, so I wouldn’t need to learn anything different in order to use TestProject. It also means that any test automation project can be easily retrofitted to use TestProject’s SDKs – they just need to swap in a new WebDriver object!
Setup Steps
TestProject has a straightforward architecture. Users sign up for free TestProject accounts online. Then, they set up their own machines for running tests. Each testing machine must have the TestProject agent installed and linked to a user’s account. When tests run, agents automatically push results to the TestProject cloud. Users can then log into the TestProject portal to view and analyze results. They can invite team mates to share results, and they can also set up multiple test machines with agents. Users can even integrate TestProject with other tools like Jenkins, qTest, and Sauce Labs. The TestProject docs, especially the ecosystem diagram, explain everything in more detail.
When I did my test drive, I created a TestProject account, installed the agent on my Mac, and ran Python Web UI tests from my Mac. I already had the latest version of Python installed (Python 3.8 at the time of writing this article). I also already had my target browsers installed: Google Chrome and Mozilla Firefox.
Below are the precise steps I followed to set up TestProject:
1. Sign up for an account
TestProject accounts are “free forever.” Use this signup link.
2. Download the TestProject Agent
The signup wizard should direct you to download the TestProject agent. If not, you can always download it from the TestProject dashboard. Be warned, the download package is pretty large – the macOS package was 345 MB. Alternatively, you can fetch the agent as a container image from Docker Hub.
The TestProject agent contains all the stuff needed to run tests and upload results to the TestProject app in the cloud. You don’t need to install WebDriver executables like ChromeDriver or geckodriver. Once the agent is downloaded, install it on the machine and register the agent with your account. For me, registration happened automatically.
3. Find your developer token
You’ll need to use your developer token to connect your automated tests to your account in the TestProject app. The signup wizard should reveal it to you, but you can always find it (and also reset it) on the Integrations page.
4. Install the Python SDK
TestProject’s Python SDK is distributed as a package through PyPI. To install it, simply run pip install testproject-python-sdk
at the command line. This package will also install dependencies like selenium
and requests
.
A Classic Web UI Test
After setting up my Mac to use TestProject, it was time to write some Web UI tests in Python! Since I discovered that TestProject’s WebDriver objects could easily retrofit any existing test automation project, I thought, “What if I try to run my PyCon 2020 tutorial project with TestProject?” For PyCon 2020, I gave an online tutorial about building a Web UI test automation project in Python from the ground up using pytest
and Selenium WebDriver. The tutorial includes one test case: a DuckDuckGo web search and verification. I thought it would be easy to integrate with TestProject since I already had the code. Thankfully, it was!
Below, I’ll walk though my code. You can check out my example project repository from GitHub at AndyLPK247/testproject-python-sdk-example. My code will be a bit more advanced than the examples shown in the Python SDK’s README or in Bas Dijkstra’s tutorial article because it uses the Page Object Model and pytest
fixtures. Make sure to pip install pytest
, too.
1. Write the test steps
The test case covers a simple DuckDuckGo web search. Whenever I automate tests, I always write out the steps in plain language. Good tests follow the Arrange-Act-Assert pattern, and I like to use Gherkin’s Given-When-Then phrasing. Here’s the test case:
Scenario: Basic DuckDuckGo Web Search
Given the DuckDuckGo home page is displayed
When the user searches for "panda"
Then the search result query is "panda"
And the search result links pertain to "panda"
And the search result title contains "panda"
2. Specify automation inputs
Inputs configure how automated tests run. They can be passed into a test automation solution using configuration files. Testers can then easily change input values in the config file without changing code. Automation should read config files once at the start of testing and inject necessary inputs into every test case.
In Python, I like to use JSON for config files. JSON data is simple and hierarchical, and Python includes a module in its standard library named json
that can parse a JSON file into a Python dictionary in one line. I also like to put config files either in the project root directory or in the tests
directory.
Here’s the contents of config.json
for this test:
{
"browser": "Chrome",
"implicit_wait": 10,
"testproject_projectname": "TestProject Python SDK Example",
"testproject_token": ""
}
browser
is the name of the browser to testimplicit_wait
is the implicit waiting timeout for the WebDriver instancetestproject_projectname
is the project name to use for this test suite in the TestProject apptestproject_token
is the developer token
3. Read automation inputs
Automation code should read inputs one time before any tests run and then inject inputs into appropriate tests. pytest
fixtures make this easy to do.
I created a fixture named config
in the tests/conftest.py
module to read config.json
:
import json
import pytest
@pytest.fixture
def config(scope='session'):
# Read the file
with open('config.json') as config_file:
config = json.load(config_file)
# Assert values are acceptable
assert config['browser'] in ['Firefox', 'Chrome', 'Headless Chrome']
assert isinstance(config['implicit_wait'], int)
assert config['implicit_wait'] > 0
assert config['testproject_projectname'] != ""
assert config['testproject_token'] != ""
# Return config so it can be used
return config
Setting the fixture’s scope to “session” means that it will run only one time for the whole test suite. The fixture reads the JSON config file, parses its text into a Python dictionary, and performs basic input validation. Note that Firefox, Chrome, and Headless Chrome will be supported browsers.
4. Set up WebDriver
Each Web UI test should have its own WebDriver instance so that it remains independent from other tests. Once again, pytest
fixtures make setup easy.
The browser
fixture in tests/conftest.py
initialize the appropriate TestProject WebDriver type based on inputs returned by the config
fixture:
from selenium.webdriver import ChromeOptions
from src.testproject.sdk.drivers import webdriver
@pytest.fixture
def browser(config):
# Initialize shared arguments
kwargs = {
'projectname': config['testproject_projectname'],
'token': config['testproject_token']
}
# Initialize the TestProject WebDriver instance
if config['browser'] == 'Firefox':
b = webdriver.Firefox(**kwargs)
elif config['browser'] == 'Chrome':
b = webdriver.Chrome(**kwargs)
elif config['browser'] == 'Headless Chrome':
opts = ChromeOptions()
opts.add_argument('headless')
b = webdriver.Chrome(chrome_options=opts, **kwargs)
else:
raise Exception(f'Browser "{config["browser"]}" is not supported')
# Make its calls wait for elements to appear
b.implicitly_wait(config['implicit_wait'])
# Return the WebDriver instance for the setup
yield b
# Quit the WebDriver instance for the cleanup
b.quit()
This was the only section of code I needed to change to make my PyCon 2020 tutorial project work with TestProject. I had to change the WebDriver invocations to use the TestProject classes. I also had to add arguments for the project name and developer token, which come from the config file. (Note: you may alternatively set the developer token as an environment variable.)
5. Create page objects
Automated tests could make direct calls to the WebDriver interface to interact with the browser, but WebDriver calls are typically low-level and wordy. The Page Object Model is a much better design pattern. Page object classes encapsulate WebDriver gorp so that tests can call simpler, more readable methods.
The DuckDuckGo search test interacts with two pages: the search page and the result page. The pages
package contains a module for each page. Here’s pages/search.py
:
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
class DuckDuckGoSearchPage:
URL = 'https://www.duckduckgo.com'
SEARCH_INPUT = (By.ID, 'search_form_input_homepage')
def __init__(self, browser):
self.browser = browser
def load(self):
self.browser.get(self.URL)
def search(self, phrase):
search_input = self.browser.find_element(*self.SEARCH_INPUT)
search_input.send_keys(phrase + Keys.RETURN)
And here’s pages/result.py
:
from selenium.webdriver.common.by import By
class DuckDuckGoResultPage:
RESULT_LINKS = (By.CSS_SELECTOR, 'a.result__a')
SEARCH_INPUT = (By.ID, 'search_form_input')
def __init__(self, browser):
self.browser = browser
def result_link_titles(self):
links = self.browser.find_elements(*self.RESULT_LINKS)
titles = [link.text for link in links]
return titles
def search_input_value(self):
search_input = self.browser.find_element(*self.SEARCH_INPUT)
value = search_input.get_attribute('value')
return value
def title(self):
return self.browser.title
Notice that this code uses the “regular” WebDriver interface because TestProject’s WebDriver classes extend the Selenium WebDriver classes.
To make setup easier, I added fixtures to tests/conftest.py
to construct each page object, too. They call the browser
fixture and inject the WebDriver instance into each page object:
from pages.result import DuckDuckGoResultPage
from pages.search import DuckDuckGoSearchPage
@pytest.fixture
def search_page(browser):
return DuckDuckGoSearchPage(browser)
@pytest.fixture
def result_page(browser):
return DuckDuckGoResultPage(browser)
6. Automate the test case
All the automation plumbing is finally in place. Here’s the test case in tests/traditional/test_duckduckgo.py
:
import pytest
@pytest.mark.parametrize('phrase', ['panda', 'python', 'polar bear'])
def test_basic_duckduckgo_search(search_page, result_page, phrase):
# Given the DuckDuckGo home page is displayed
search_page.load()
# When the user searches for the phrase
search_page.search(phrase)
# Then the search result query is the phrase
assert phrase == result_page.search_input_value()
# And the search result links pertain to the phrase
titles = result_page.result_link_titles()
matches = [t for t in titles if phrase.lower() in t.lower()]
assert len(matches) > 0
# And the search result title contains the phrase
assert phrase in result_page.title()
I parametrized the test to run it for three different phrases. The test function does not interact with the WebDriver instance directly. Instead, it interacts exclusively with the page objects.
7. Run the tests
The tests run like any other pytest
tests: python -m pytest
at the command line. If everything is set up correctly, then the tests will run successfully and upload results to the TestProject app.
In the TestProject dashboard, the Reports tab shows all the test you have run. It also shows the different test projects you have.
You can also drill into results for individual test case runs. TestProject automatically records the browser type, timestamps, pass-or-fail results, and every WebDriver call. You can also download PDF reports!
What if … BDD?
I was delighted to see how easily I could run a traditional pytest
suite using TestProject. Then, I thought to myself, “What if I could use a BDD test framework?” I personally love Behavior-Driven Development, and Python has multiple BDD test frameworks. There is no reason why a BDD test framework wouldn’t work with TestProject!
So, I rewrote the DuckDuckGo search test as a feature file with step definitions using pytest-bdd
. The BDD-style test uses the same fixtures and page objects as the traditional test.
Here’s the Gherkin scenario in tests/bdd/features/duckduckgo.feature
:
Feature: DuckDuckGo
As a Web surfer,
I want to search for websites using plain-language phrases,
So that I can learn more about the world around me.
Scenario Outline: Basic DuckDuckGo Web Search
Given the DuckDuckGo home page is displayed
When the user searches for "<phrase>"
Then the search result query is "<phrase>"
And the search result links pertain to "<phrase>"
And the search result title contains "<phrase>"
Examples:
| phrase |
| panda |
| python |
| polar bear |
And here’s the step definition module in tests/bdd/step_defs/test_duckduckgo_bdd.py
:
from pytest_bdd import scenarios, given, when, then, parsers
from selenium.webdriver.common.keys import Keys
scenarios('../features/duckduckgo.feature')
@given('the DuckDuckGo home page is displayed')
def load_duckduckgo(search_page):
search_page.load()
@when(parsers.parse('the user searches for "{phrase}"'))
@when('the user searches for "<phrase>"')
def search_phrase(search_page, phrase):
search_page.search(phrase)
@then(parsers.parse('the search result query is "{phrase}"'))
@then('the search result query is "<phrase>"')
def check_search_result_query(result_page, phrase):
assert phrase == result_page.search_input_value()
@then(parsers.parse('the search result links pertain to "{phrase}"'))
@then('the search result links pertain to "<phrase>"')
def check_search_result_links(result_page, phrase):
titles = result_page.result_link_titles()
matches = [t for t in titles if phrase.lower() in t.lower()]
assert len(matches) > 0
@then(parsers.parse('the search result title contains "{phrase}"'))
@then('the search result title contains "<phrase>"')
def check_search_result_title(result_page, phrase):
assert phrase in result_page.title()
There’s one more nifty trick I added with pytest-bdd
. I added a hook to report each Gherkin step to TestProject with a screenshot! That way, testers can trace each test case step more easily in the TestProject reports. Capturing screenshots also greatly assists test triage when failures arise. This hook is located in tests/conftest.py
:
def pytest_bdd_after_step(request, feature, scenario, step, step_func):
browser = request.getfixturevalue('browser')
browser.report().step(description=str(step), message=str(step), passed=True, screenshot=True)
Since pytest-bdd
is just a pytest
plugin, its tests run using the same python -m pytest
command. TestProject will group these test results into the same project as before, but it will separate the traditional tests from the BDD tests by name. Here’s what the Gherkin steps with screenshots look like:
This is Awesome!
As its name denotes, TestProject is a great platform for handling project-level concerns for testing work: reporting, integrations, and fast feedback. Adding TestProject to an existing automation solution feels seamless, and its sleek user experience gives me what I need as a tester without getting in my way. The one word that keeps coming to mind is “simple” – TestProject simplifies setup and sharing. Its design takes to heart the renowned Python adage, “Simple is better than complex.” As such, TestProject’s new Python SDK is a welcome addition to the Python testing ecosystem.
I look forward to exploring Python support for mobile testing with Appium soon. I also look forward to seeing all the new Python add-ons the community will develop.
This is superb. Expecting a video for all the steps to follow to work on windows will be more helpful
LikeLike
Thanks! I don’t have plans to make a video recording, though.
LikeLike