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?
- It’s free and open source
- It has a relatively simple Python syntax
- It collects tests automatically: Pytest has multiple instruments to manage the test execution queue and prepare the environment for it. It allows skipping tests as well.
- There is a Python and Pytest library list
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
Project structure
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:
- Functionality: registration, cart, payment etc.
- Services: frontend, gateway, billing, user data, order tracking etc.
- Devices or hardware parts: GSM modem, screen, battery, camera etc.
- Test type: unit, smoke, regression, stability, load etc.
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 “
The README file
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
Dependency list
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 (
Parameters parsing from console input
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:
- You can use conftest.py as the main file or entry point of your project.
- Data should be passed to test cases with
fixtures . - Pytest has its own
class (hook) to parse input arguments. It has pretty much the same logic and rules as argparse.
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.
Parameters parsing from a configuration file
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:
- Create an abstraction for config field names/config paths
- Create an abstraction for console option keys
- Create an abstraction class for your config that will provide a kind of a read_field method
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:
- Modify key/field names in a single class/file
- Find a usage of these names using any IDE
- Easy to add new ones
- Use IDE autocomplete (console_options.host)
- Build simple code to work with both console arguments and config values
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)
Named tuples for fixtures
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')
Conclusion
In this article, we touched upon:
- Project structure
- Readme for your project
- Dependencies and pip freeze functionality
- Parameters parsing from console
- Parameters parsing from config, config abstraction
- Named tuples and their benefits
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:
- Allure and allure serve
- Fixture dependency, execution order
- Fixture usage, data flow between fixture and tests
- Test scenario description and tips. Separation of test scenario and implementation
- Hamcrest and your own assert function
- Pytest.ini and marks, access to closest mark from conftest.py
- Tests execution order
- Pytest internal variables and its usage
- Stability tests data pass
- How to create setup.py and use external git code base in a project
Stay tuned!