Pytest is a framework that helps you to write tests for various types of software. It is commonly used for testing APIs, front-end and back-end services.
It is a versatile instrument that you can use to write simple unit tests or complex functional testing. My personal experience showed that it also works great with hardware (HIL) testing.
What makes Pytest good?
In this article I want to share useful patterns that can make your work with Pytest easier and more efficient. For convenience, I gathered all the examples from this article in the Pytest project
If you take a look at the
First is tests outside application code.
pyproject.toml
src/
mypkg/
__init__.py
app.py
view.py
tests/
test_app.py
test_view.py
...
Another one - tests as part of application code.
pyproject.toml
[src/]mypkg/
__init__.py
app.py
view.py
tests/
__init__.py
test_app.py
test_view.py
...
I prefer and recommend you to use the “Tests outside application code” way. On top of that, I would suggest adding an extra layer (or even several layers) of test separation in the test directory. You can apply any logic to separate the tests, such as:
Choose the logic that serves best to your project, you can even mix these approaches. This will help you to not get overwhelmed by tens, hundreds or even thousands of tests.
However, you should keep in mind that Pytest has its own test collector and you need to follow the rules in order to keep your experience smooth.
Pytest uses the “test_” pattern in file names to collect tests. That is why files with tests should be named according to masks “test_*.py” or “*_test.py”, where * stands for any name of your choice.
Tests directory or folders should not be Python packages, meaning they should not contain “
It is considered a good manners rule among the software engineers, QA automation testers and others who may use the project to include a README file and the dependencies list.
The README can be a regular quick start guide that may contain some helpful information, namely: how to install required dependencies, where to find it, how to run tests. Do not hesitate to mention anything that could help others use your project or understand its idea and logic. The README file could be very useful even for you as the project author, if you need to pick up work on it after a long break.
The common practice is to use Markdown and create a file named README.md in the root folder of your project, but even a plain text file is better than nothing. You can find an example of such a file
Dependencies are external, non built-in libraries or code that your project uses to run. In our project, Pytest itself will serve as a dependency, because it’s not a part of the built-in Python functionality. Such
Python and its pip utility can create and install dependency lists with cmd or terminal commands. As the first step, the command
pip freeze > requirements.txt
Then, the command
pip install -r requirements.txt
This approach works even better if you work with Python virtual environments (
Most Pytest projects have an object of testing or require test environment preparation. For this, you may need some input information, arguments or parameters. In the case of classic Python software or script implementation, it is easy to do: you need to add the argparse module to your main file and handle the input data.
But first, we need to keep in mind the following things:
For implementation, we need to call pytest_addoption function in conftest.py and parse the data with request.config.getoption. In the example, request is a built-in
def pytest_addoption(parser):
parser.addoption('--server_address', action="store", default='', type=str,
help="base_http hostname or IP address")
parser.addoption('--server_port', action="store", default='', type=str, help="base_http port")
@pytest.fixture(scope='session')
def console_options(request):
options = namedtuple('options', ['host', 'port'])
host = request.config.getoption('--server_address')
port = request.config.getoption('--server_port')
return options(host=host, port=port)
Now we can request the console_options fixture and its data from any other fixture in conftest.py:
@pytest.fixture(scope='session', autouse=True)
def server(console_options):
host = console_options.host
You can notice that this code has flows and generally could be better. Don’t worry, we will improve it further in the article.
Quality engineers are not the lonely wolves, they work in teams. It can be very useful to share your tests with other QA engineers or developers in order to briefly check how the local code runs. However, filling in the console arguments every time can be frustrating for users. This can be solved with a
Configs are common for various types of projects and in our tests we want to keep both methods of arguments/parameters passing. To address this task, I strongly recommend do do the following:
Moreover, you can use any external library to operate your configuration file. In this sample project, I will use
Let’s create our first helper in root_folder/helpers/config.py and add three classes to meet our goals.
from os import path
from vyper import v as config
class CfgPath:
SERVER_ADDRESS = 'server.address'
SERVER_PORT = 'server.port'
class Options:
SERVER_ADDRESS = '--server_address'
SERVER_PORT = '--server_port'
class Config:
def __init__(self, consts=CfgPath):
self.ROOT_DIR = path.abspath(path.join(__file__, '../../'))
self.consts = consts
self.init_config()
def init_config(self):
config_name = 'config'
config.set_config_name(config_name)
config.set_config_type('yaml')
config.add_config_path(self.ROOT_DIR)
config.read_in_config()
@staticmethod
def get_raw_value(key_path):
return config.get(key_path)
This will change conftest.py as well.
@pytest.fixture(scope='session', autouse=True)
def init():
Config()
def pytest_addoption(parser):
parser.addoption(Options.SERVER_ADDRESS, action="store", default='', type=str,
help="base_http hostname or IP address")
parser.addoption(Options.SERVER_PORT, action="store", default='', type=str, help="base_http port")
@pytest.fixture(scope='session')
def console_options(request):
options = namedtuple('options', ['host', 'port'])
host = request.config.getoption(Options.SERVER_ADDRESS)
port = request.config.getoption(Options.SERVER_PORT)
return options(host=host, port=port)
Now conftest.py does not pay any attention to the key names for terminal arguments or config fields - instead, it works with an abstraction. This allow us to:
In this fixture of conftest.py we choose what values are more important (console options).
@pytest.fixture(scope='session', autouse=True)
def server(console_options):
server = namedtuple('options', ['host', 'port', 'xml', 'json', 'common'])
if not console_options.host == '':
host = console_options.host
else:
host = Config.get_raw_value(CfgPath.SERVER_ADDRESS)
if not console_options.port == '':
port = console_options.port
else:
port = Config.get_raw_value(CfgPath.SERVER_PORT)
xml = XML(host, port)
json = JSON(host, port)
common = CommonMethods(host, port)
return server(host=host, port=port, xml=xml, json=json, common=common)
You might have noticed that namedtuples are used in the code examples. It is not just a coincidence - they are actually very useful for code improvement. Named Tuples can help you create transparent and compact code that is easy to maintain.
According to the official
collections.namedtuple(typename, field_names, *, rename=False, defaults=None, module=None)¶
Returns a new tuple subclass named typename. The new subclass is used to create tuple-like objects that have fields accessible by attribute lookup as well as being indexable and iterable. Instances of the subclass also have a helpful docstring (with typename and field_names) and a helpful
repr () method which lists the tuple contents in a name=value format.
This means that we can pass a single fixture (instead of 3 or even 5) with multiple sub-objects to any test and have access to any of them. In this
This pattern is ideal to cover the cases when testing objects have several ports with various APIs, or when you have a device with multiple subunits.
@pytest.mark.json
@pytest.mark.order('last')
@allure.testcase('Write data id', name='Write data id')
@allure.description('Write json data and assert id in it')
def test_write_data_id(server):
with allure.step('reset json data to default'):
server.json.reset_data()
with allure.step('assert default data id'):
assert_that(server.json.get_data_id(), 'FEFE')
with allure.step('send test data'):
test_data = {'status': True, 'data': {'id': 'FFFF', 'some_field': 'some_field_data'}}
server.json.send_data(test_data)
with allure.step('assert test data id'):
assert_that(server.json.get_data_id(), 'FFFF')
In this article, we touched upon:
I hope that these simple tips help you improve your project, work process and open new automation opportunities. Treat your Pytest project as a software product (because that’s what it is!) and you will be surprised how much it can do for you and your team.
P.S. This is just the tip of the iceberg of all the Pytest possibilities for testing. In my next articles, I am going to to dive deeper into:
Stay tuned!