Python and PyTest

Python and PyTest

Working with multiple python versions may cause issues and confusion. It is recommended to specify the python version to use.

Working with python 3.9:

py -3.9 -m <command>

It is recommended to upgrade the pip version:

py -3.9 -m pip install --upgrade pip

It is recommended to install tox, which is a virtual environment (venv) manager for Python.

py -3.9 -m pip install tox

To run tests with tox, the following example assumes that pytest has been configured by the tox.ini commands parameter:

py -3.9 -m tox --recursive -- <pytest parameters>

PyTest

Why PyTest?

  • allow to run a standalone test function as its own case
  • easy to read syntax, allowing you to use the standard assert method
  • powerful CLI
  • automates test setup, teardown, and common test scenarios (uses fixtures)
  • Great to use with CI tools like Jenkins, Travis, Circle CI, etc.
  • actively maintained with participatory open-source community

requirements.txt content for pytest

coverage===xxx
pytest===xxx
pytest-cov===xxx
pytest-flakes===xxx
pytest-pep8===xxx
pytest-pythonpath==xxx
docker
  • pytest-flakes will make pytest use PyFlake and Flake8. It will make pytest and python use a Linter and code style checker.

Run a test

Get pytest help:

pytest -h

Run all tests:

pytest

Run test using keyword for filename

pytest -k <test_name_keyword>

Explore test coverage of a script - requires the pytest-cov package to be installed:

pytest --cov scripts

pytest.ini configuration file example

[pytest]
# Configure the logging within PyTest (the configuration won't work if configured in test files)
log_cli = 1
log_cli_level = WARNING
log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)
log_cli_date_format=%Y-%m-%d %H:%M:%S

# Filter Warnings
filterwarnings =
    ignore::FutureWarning

# Uses classes with prefix Test as test files
python_files = Test*.py

Tox

Tox aims to automate and standardize testing in Python. It is part of a larger vision of easing the packaging, testing and release process of Python software.

tox.ini

Tox may be used along with a tox.ini configuration file.

[framework]
files = tests
coverages = --cov=src

[tox]
envlist = py3.9
skip_missing_interpreters = false
skipsdist = true
toxworkdir = tmp

[flake8]
max-line-length = 159
# E501: Ignore max line length
# ignore = E501

[pytest]
norecursedirs = .cache tmp

# pytest-spec configuration
spec_header_format = {module_path}:
spec_test_format = {result} {name}

[testenv]
deps = -rrequirements.txt

# Only forward the environment variables with the following prefix.
passenv = PYTHON_SANDBOX_*
commands =
    flake8 src tests --exclude=__init__.py
    pytest -p no:cacheprovider --spec --durations=5 --cov-config .coveragerc --cov-report term-missing {posargs} {[framework]coverages} {[framework]files}

Test file skeleton

from path.to.class.to.test import ClassName
from pytest # to use pytest Context Manager


def test_name():
    obj = ClassName("value1", "value2")
    assert obj.value1 == "value1"
    assert obj.value2 == "value2"

def test_with_exception_context_manager():
    with pytest.raises(ValueError) as ex:
        # Code that will raise an exception.
        obj = ClassName("value1", "value2")
        obj.raises_exception()

    assert str(ex.value) == "this is an exception!"

Test class skeleton

The Test Class will work like the fixtures from the test file.

# conftest.py
import pytest
import logging


@pytest.fixture(scope="session", autouse=True)
def set_logging() -> None:
    logging.info("set_logging on conftest.py")


# TestExample.py
import logging


class TestExample:
    @classmethod
    def setup_class(cls):
        logging.info("starting class: {} execution".format(cls.__name__))

    @classmethod
    def teardown_class(cls):
        logging.info("starting class: {} execution".format(cls.__name__))

    def setup_method(self, method):
        logging.info("starting execution of tc: {}".format(method.__name__))

    def teardown_method(self, method):
        logging.info("starting execution of tc: {}".format(method.__name__))

    def test_tc1(self):
        logging.info("running tc1")
        assert True

    def test_tc2(self):
        logging.info("running tc2")
        assert True

The output of this example Test Class will be:

============================= test session starts =============================
collecting ... collected 2 items

TestExample.py::TestExample::test_tc1
------------------------------- live log setup --------------------------------
2022-07-18 12:57:45 [    INFO] set_logging on conftest.py (conftest.py:7)
2022-07-18 12:57:45 [    INFO] starting class: TestExample execution (TestExample.py:7)
2022-07-18 12:57:45 [    INFO] starting execution of tc: test_tc1 (TestExample.py:14)
-------------------------------- live log call --------------------------------
2022-07-18 12:57:45 [    INFO] running tc1 (TestExample.py:20)
PASSED                                                                   [ 50%]
------------------------------ live log teardown ------------------------------
2022-07-18 12:57:45 [    INFO] starting execution of tc: test_tc1 (TestExample.py:17)

TestExample.py::TestExample::test_tc2
------------------------------- live log setup --------------------------------
2022-07-18 12:57:45 [    INFO] starting execution of tc: test_tc2 (TestExample.py:14)
-------------------------------- live log call --------------------------------
2022-07-18 12:57:45 [    INFO] running tc2 (TestExample.py:24)
PASSED                                                                   [100%]
------------------------------ live log teardown ------------------------------
2022-07-18 12:57:45 [    INFO] starting execution of tc: test_tc2 (TestExample.py:17)
2022-07-18 12:57:45 [    INFO] starting class: TestExample execution (TestExample.py:11)

============================== 2 passed in 0.02s ==============================

Process finished with exit code 0

A session configuration can be done in the conftest.py as shown in the example. It will be run once only in the session setup.

Fixtures

A test fixture is a concept used in both electronics and software. It’s a piece of software or device that sets up a system to satisfy certain preconditions of the process. Its biggest advantage is that it provides consistent results so that the test results can be repeatable. Examples of fixtures could be loading a test set to the database, reading a configuration file, setting up environment variables, etc.

A pytest fixture has a specific scope. By default, the scope is a function. Pytest fixtures have five different scopes: function, class, module, package, and session. The scope basically controls how often each fixture will be executed.

Order of priority:

  1. session (higher priority)
  2. package
  3. module
  4. class
  5. function (lower priority)

Function

THe default scope is function: `scope=“function” and therefore may be omitted.

import pytest
from datetime import datetime

@pytest.fixture()
def only_used_once():
    with open("app.json") as f:
        config = json.load(f)
    return config

@pytest.fixture()
def light_operation():
    return "I'm a constant"

@pytest.fixture()
def need_different_value_each_time():
    return datetime.now()

Class

The scope="class" run before any function or test of the Test Class.

@pytest.fixture(scope="class")
def dummy_data(request):
    request.cls.num1 = 10
    request.cls.num2 = 20
    logging.info("Execute fixture")

@pytest.mark.usefixtures("dummy_data")
class TestCalculatorClass:
    def test_distance(self):
        logging.info("Test distance function")
        assert distance(self.num1, self.num2) == 10

    def test_sum_of_square(self):
        logging.info("Test sum of square function")
        assert sum_of_square(self.num1, self.num2) == 500

Special usage of the keyword yield in a fixture: the code before the yield keyword will be executed before the test functions of the Test Class while the code after the yield keyword will be executed after the test functions of the Test Class.

@pytest.fixture(scope="class")
def prepare_db(request):
    # pseudo code
    connection = db.create_connection()
    request.cls.connection = connection
    yield
    connection = db.close()

@pytest.mark.usefixtures("prepare_db")
class TestDBClass:
    def test_query1(self):
        assert self.connection.execute("..") == "..."

    def test_query2(self):
        assert self.connection.execute("..") == "..."

Module and package

The scope="module" runs the fixture per module while the scope="package" runs by package. The scope module is usually used more often than the scope package. The difference between scope function and scope module is that the scope module will only be run once, even if used in many functions in the module.

@pytest.fixture(scope="module")
def read_config():
    with open("app.json") as f:
        config = json.load(f)
        logging.info("Read config")
    return config

def test1(read_config):
    logging.info("Test function 1")
    assert read_config == {}

def test2(read_config):
    logging.info("Test function 2")
    assert read_config == {}

Session

The scope="session" is only run once every time pytest is run. A per-directory conftest.py file will be executed once per pytest execution.

# test/conftest.py
@pytest.fixture(scope="session")
def read_config():
    with open("app.json") as f:
        config = json.load(f)
        logging.info("Read config")
    return config

# test/test_code1.py
def test1(read_config):
    logging.info("Test function 1")
    assert read_config == {}

def test2(read_config):
    logging.info("Test function 2")
    assert read_config == {}

# test/test_code2.py
def test3(read_config):
    logging.info("Test function 3")
    assert read_config == {}

def test4(read_config):
    logging.info("Test function 4")
    assert read_config == {}

Using conftest.py for common functions:

  • stores common utility test fixtures and extension code often referred to as hooks
  • pytest collects the fixtures in this file so they are globally accessible within the testing directory
  • it must be placed under your /tests directory
  • good practice to cross-reference this file when reading a testing suite

conftest.py modularization

It is possible to modularize the conftest.py file when it is getting too big.

# referring to modules:
# tests/utils/db.py
# tests/utils/network.py

# in conftest.py
pytest_plugins = [
    "tests.utils.db",
    "tests.utils.network"
]

The autouse=True fixtures must stay in the conftest.py file.

Autouse

The fixture parameter autouse=True will make the fixture used automatically even if the fixture isn’t called by the test function.

@pytest.fixture(autouse=True)
def function_autouse():
    logging.info("scope function with autouse")

def test_autouse():
    assert True

Parametrize

The [pytest.mark.parametrize] fixture allows the user to run the same test, multiple times, by modifying the input parameters.

@pytest.mark.parametrize("num, output",[(1,11),(2,22),(3,35),(4,44)])
def test_multiplication_11(num, output):
   assert 11*num == output

Python

Class file skeleton

class ClassName():
    """
    This is a multi-line comment (used for header in this example)
    This is a second line of comment..
    """
    def __init__(self, var1: str, var2: str): # the "var1: str" format will require the var1 to be a string type
        self._var1 = var1 # the _ is to make the variable "protected"
        self._var2 = var2

    @property # Using property decorator as a getter function
    def var1(self) -> str: # the -> specifies the return type
        return self._var1

    @var1.setter # using setter decorator for setter function
    def var1(self, value: str):
        self.var1 = value

    def raises_exception():
        raise ValueError("this is an exception!")

It is possible to use decorators for getters and setters.

Commonly used functions and examples

Verify variable type

if not isinstance(variable, str):
    raise ValueError("wrong type!")

Open file with context manager

with open("test.txt", 'w', encoding='utf-8') as f:
   f.write("my first file\n")
   f.write("This file\n\n")
   f.write("contains three lines\n")

Iterate a list

data = ["abc", "def", "ghi"]

for each_data in data:
  assert each_data in data

try except

try:
    # Some Code
except:
    # Executed if error in the
    # try block
else:
    # execute if no exception
finally:
    # Some code .....(always executed)