>>> На главную <<<

Pytest

Библиотечка для [модульные-тесты]

Документация

API - все методы пайтеста

Usage

pytest --version   # shows where pytest was imported from
pytest --fixtures  # show available builtin function arguments
pytest -h | --help # show help on command line and config file options

pytest -x           # stop after first failure
pytest --maxfail=2  # stop after two failures

# run selecting tests

pytest test_mod.py
pytest testing/ # all in one directory
pytest -k "MyClass and not method" # by keyword. The example above will run TestMyClass.test_something but not TestMyClass.test_method_simple

pytest test_mod.py::test_func # by functions name
pytest test_mod.py::TestClass::test_method # by method name
pytest -m slow # by mark, such a @pytest.mark.slow
pytest --pyargs pkg.testing # This will import pkg.testing and use its filesystem location to find and run tests from

# Modifying Python traceback printing

pytest --showlocals # show local variables in tracebacks
pytest -l           # show local variables (shortcut)

pytest --tb=auto    # (default) 'long' tracebacks for the first and last
                     # entry, but 'short' style for the other entries
pytest --tb=long    # exhaustive, informative traceback formatting
pytest --tb=short   # shorter traceback format
pytest --tb=line    # only one line per failure
pytest --tb=native  # Python standard library formatting
pytest --tb=no      # no traceback at all

Вывод саммари с отметкой pass/fail/skip pytest -v

Можно запускать через pytest -r

Опции для запуска:

Выбор групп:

Пример pytest -ra или [ytest -rfs]

Запуск с [pdb-python-debugger] pytest --pdb или pytest --trace (для запуска дебаггера в начале каждого теста).

Запуск из кода

Написание ассертов

Тестирование неудачных случаев

Используется pytest.raises() с контекстным менеджером.

import pytest
with pytest.raises(ZeroDivisionError):
    1/0

Или анлогично встроенным функциям [unittest]

def myfunc():
    raise ValueError("Exception 123 raised")


def test_match():
    with pytest.raises(ValueError, match=r".* 123 .*"):
        myfunc()

Определение собственных описаний для проваленных тестов

Используется хук pytest_assertrepr_compare(config: Config, op: str, left: object, right: object) → Optional[List[str]]

# content of conftest.py
from test_foocompare import Foo


def pytest_assertrepr_compare(op, left, right):
    if isinstance(left, Foo) and isinstance(right, Foo) and op == "==":
        return [
            "Comparing Foo instances:",
            "   vals: {} != {}".format(left.val, right.val),
        ]

# content of test_foocompare.py
class Foo:
    def __init__(self, val):
        self.val = val

    def __eq__(self, other):
        return self.val == other.val


def test_compare():
    f1 = Foo(1)
    f2 = Foo(2)
    assert f1 == f2

Фикстуры

Применяются с помощью декораторв @pytest.fixture. Доступно несколько дефолтных фикстур.

Почитать что такое [фикстуры]

В pytest «фикстуры» - это определяемые вами функции, которые служат цели задания условий теста. Они также могут предоставить шаг действия, и это может быть мощным методом для разработки более сложных тестов. Пример:

class Fruit:
    def __init__(self, name):
        self.name = name
        self.cubed = False

    def cube(self):
        self.cubed = True


class FruitSalad:
    def __init__(self, *fruit_bowl):
        self.fruit = fruit_bowl
        self._cube_fruit()

    def _cube_fruit(self):
        for fruit in self.fruit:
            fruit.cube()


# Arrange
@pytest.fixture
def fruit_bowl():
    return [Fruit("apple"), Fruit("banana")]


def test_fruit_salad(fruit_bowl):
    # Act
    fruit_salad = FruitSalad(*fruit_bowl)

    # Assert
    assert all(fruit.cubed for fruit in fruit_salad.fruit)

Фикстуры можно выстраивать в зависимости

# Arrange
@pytest.fixture
def first_entry():
    return "a"


# Arrange
@pytest.fixture
def order(first_entry):
    return [first_entry]


def test_string(order):
    # Act
    order.append("b")

    # Assert
    assert order == ["a", "b"]

С этим механизмом фикстуры легко становятся reusable

# Arrange
@pytest.fixture
def first_entry():
    return "a"


# Arrange
@pytest.fixture
def order(first_entry):
    return [first_entry]


def test_string(order):
    # Act
    order.append("b")

    # Assert
    assert order == ["a", "b"]


def test_int(order):
    # Act
    order.append(2)

    # Assert
    assert order == ["a", 2]

Можно так-же запрашивать более одной фикстуры за разделе

# Arrange
@pytest.fixture
def first_entry():
    return "a"


# Arrange
@pytest.fixture
def second_entry():
    return 2


# Arrange
@pytest.fixture
def order(first_entry, second_entry):
    return [first_entry, second_entry]


# Arrange
@pytest.fixture
def expected_list():
    return ["a", 2, 3.0]


def test_string(order, expected_list):
    # Act
    order.append(3.0)

    # Assert
    assert order == expected_list

Кроме того, фикстуры можно вызвать более одного раза за тест, значение кешируется.

# Arrange
@pytest.fixture
def first_entry():
    return "a"


# Arrange
@pytest.fixture
def order():
    return []


# Act
@pytest.fixture
def append_first(order, first_entry):
    return order.append(first_entry)


def test_string_only(append_first, order, first_entry):
    # Assert
    assert order == [first_entry]

Можно сделать автоюзабельные фикстуры (тогда они будут применяться для всех тестов)

@pytest.fixture
def first_entry():
    return "a"


@pytest.fixture
def order(first_entry):
    return []


@pytest.fixture(autouse=True)
def append_first(order, first_entry):
    return order.append(first_entry)


def test_string_only(order, first_entry):
    assert order == [first_entry]


def test_string_and_int(order, first_entry):
    order.append(2)
    assert order == [first_entry, 2]

Мы можем пошарить фикстуры на классы, модули, пакеты и сессии

import pytest
import smtplib


@pytest.fixture(scope="module")
def smtp_connection():
    return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
def test_ehlo(smtp_connection):
    response, msg = smtp_connection.ehlo()
    assert response == 250
    assert b"smtp.gmail.com" in msg
    assert 0  # for demo purposes


def test_noop(smtp_connection):
    response, msg = smtp_connection.noop()
    assert response == 250
    assert 0  # for demo purposes
$ pytest test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 2 items

test_module.py FF                                                    [100%]

================================= FAILURES =================================
________________________________ test_ehlo _________________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

    def test_ehlo(smtp_connection):
        response, msg = smtp_connection.ehlo()
        assert response == 250
        assert b"smtp.gmail.com" in msg
>       assert 0  # for demo purposes
E       assert 0

test_module.py:7: AssertionError
________________________________ test_noop _________________________________

smtp_connection = <smtplib.SMTP object at 0xdeadbeef>

    def test_noop(smtp_connection):
        response, msg = smtp_connection.noop()
        assert response == 250
>       assert 0  # for demo purposes
E       assert 0

test_module.py:13: AssertionError
========================= short test summary info ==========================
FAILED test_module.py::test_ehlo - assert 0
FAILED test_module.py::test_noop - assert 0
============================ 2 failed in 0.12s =============================

Для scopes оступно:

Кроме того, можно динамически

def determine_scope(fixture_name, config):
    if config.getoption("--keep-containers", None):
        return "session"
    return "function"


@pytest.fixture(scope=determine_scope)
def docker_container():
    yield spawn_container()

Errors фикстур

Teardown/Cleanup (AKA Fixture finalization)

  1. return is swapped out for yield.
  2. Any teardown code for that fixture is placed after the yield
import pytest

from emaillib import Email, MailAdminClient


@pytest.fixture
def mail_admin():
    return MailAdminClient()


@pytest.fixture
def sending_user(mail_admin):
    user = mail_admin.create_user()
    yield user
    admin_client.delete_user(user)


@pytest.fixture
def receiving_user(mail_admin):
    user = mail_admin.create_user()
    yield user
    admin_client.delete_user(user)


def test_email_received(receiving_user, email):
    email = Email(subject="Hey!", body="How's it going?")
    sending_user.send_email(_email, receiving_user)
    assert email in receiving_user.inbox

Все что записано после yield будет запускаться после выпаолнения теста, но в обратном порядке. Поскольку receiving_user последний, то сначала будет удален узер для него, затем для sending_user

Прямая реализация финализирующей части

Безопасный тирдаун

import pytest

from emaillib import Email, MailAdminClient


@pytest.fixture
def setup():
    mail_admin = MailAdminClient()
    sending_user = mail_admin.create_user()
    receiving_user = mail_admin.create_user()
    email = Email(subject="Hey!", body="How's it going?")
    sending_user.send_emai(email, receiving_user)
    yield receiving_user, email
    receiving_user.delete_email(email)
    admin_client.delete_user(sending_user)
    admin_client.delete_user(receiving_user)


def test_email_received(setup):
    receiving_user, email = setup
    assert email in receiving_user.inbox

Обеспечение доступности фикстур на разных уровнях видимости

Fixture instantiation order

Running multiple assert statements safely

Using markers to pass data to fixtures

@pytest.fixture
def fixt(request):
    marker = request.node.get_closest_marker("fixt_data")
    if marker is None:
        # Handle missing marker in some way...
        data = None
    else:
        data = marker.args[0]

    # Do something with the data
    return data


@pytest.mark.fixt_data(42)
def test_fixt(fixt):
    assert fixt == 42

Factories as fixtures

Parametrizing fixtures

Позволяет запустить серию тестов.

import pytest
import smtplib

@pytest.fixture(scope="module", params=["smtp.gmail.com", "mail.python.org"])
def smtp_connection(request):
    smtp_connection = smtplib.SMTP(request.param, 587, timeout=5)
    yield smtp_connection
    print("finalizing {}".format(smtp_connection))
    smtp_connection.close()

Далее еще несколько разделов про использование меток, группировку фикстур и автоматическое выполнение

Использование меток для обозначения режимов запуска тестов

Запускается через pytest --markers. По дефолту доступно:

Метку можно зарегистрирповать в pytest.ini

[pytest]
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    serial

или в pyproject.toml

[tool.pytest.ini_options]
markers = [
    "slow: marks tests as slow (deselect with '-m \"not slow\"')",
    "serial",
]

или через хук пайтеста

def pytest_configure(config):
    config.addinivalue_line(
        "markers", "env(name): mark test to run only on named environment"
    )

Monkeypatching/mocking modules and environments

monkeypatch фикстура позволяет безопасно добавлять/убирать атрибуты и объекты, а так-же менять sys.path

monkeypatch.setattr(obj, name, value, raising=True)
monkeypatch.delattr(obj, name, raising=True)
monkeypatch.setitem(mapping, name, value)
monkeypatch.delitem(obj, name, raising=True)
monkeypatch.setenv(name, value, prepend=False)
monkeypatch.delenv(name, raising=True)
monkeypatch.syspath_prepend(path)
monkeypatch.chdir(path)

Все очищается, как только запрашивающая тестирующая функция завершает свою работу. Простой пример

# contents of test_module.py with source code and the test
from pathlib import Path


def getssh():
    """Simple function to return expanded homedir ssh path."""
    return Path.home() / ".ssh"


def test_getssh(monkeypatch):
    # mocked return function to replace Path.home
    # always return '/abc'
    def mockreturn():
        return Path("/abc")

    # Application of the monkeypatch to replace Path.home
    # with the behavior of mockreturn defined above.
    monkeypatch.setattr(Path, "home", mockreturn)

    # Calling getssh() will use mockreturn in place of Path.home
    # for this test with the monkeypatch.
    x = getssh()
    assert x == Path("/abc/.ssh")

monkeypatch.setattr может использоваться для конструирования классов, которые возвращают объект вместо значений

import requests


def get_json(url):
    """Takes a URL, and returns the JSON."""
    r = requests.get(url)
    return r.json()
# import requests for the purposes of monkeypatching
import requests

# our app.py that includes the get_json() function
# this is the previous code block example
import app

# custom class to be the mock return value
# will override the requests.Response returned from requests.get
class MockResponse:

    # mock json() method always returns a specific testing dictionary
    @staticmethod
    def json():
        return {"mock_key": "mock_response"}


def test_get_json(monkeypatch):

    # Any arguments may be passed and mock_get() will always return our
    # mocked object, which only has the .json() method.
    def mock_get(*args, **kwargs):
        return MockResponse()

    # apply the monkeypatch for requests.get to mock_get
    monkeypatch.setattr(requests, "get", mock_get)

    # app.get_json, which contains requests.get, uses the monkeypatch
    result = app.get_json("https://fakeurl")
    assert result["mock_key"] == "mock_response"

Global patch example: preventing “requests” from remote operations

Monkeypatching environment variables

Monkeypatching dictionaries

Temporary directories and files

tmp_path позволяет создавать временные директории для теста

CONTENT = "content"


def test_create_file(tmp_path):
    d = tmp_path / "sub"
    d.mkdir()
    p = d / "hello.txt"
    p.write_text(CONTENT)
    assert p.read_text() == CONTENT
    assert len(list(tmp_path.iterdir())) == 1
    assert 0

The tmp_path_factory fixture

Capturing of the stdout/stderr output

Можно задать методы получения стандартного вывода

pytest -s                  # disable all capturing
pytest --capture=sys       # replace sys.stdout/stderr with in-mem files
pytest --capture=fd        # also point filedescriptors 1 and 2 to temp file
pytest --capture=tee-sys   # combines 'sys' and '-s', capturing sys.stdout/stderr
                           # and passing it along to the actual sys.stdout/stderr

Можно использовать print() для дебаг

def setup_function(function):
    print("setting up", function)


def test_func1():
    assert True


def test_func2():
    assert False

Accessing captured output from a test function

Warnings Capture

Doctest integration for modules and test files

[doctest]

Skip and xfail: dealing with tests that cannot succeed

@pytest.mark.skip(reason="no way of currently testing this")
def test_the_unknown():
    ...

# альтернатива
def test_function():
    if not valid_config():
        pytest.skip("unsupported configuration")

Скипать можно и на уровне модуля

import sys
import pytest

if not sys.platform.startswith("win"):
    pytest.skip("skipping windows-only tests", allow_module_level=True)

skipif

import sys


@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher")
def test_function():
    ...

Можно шарить скипы между разными модулями.

Крмое того, можно проискпать все тесты модуля или класса в модуле:

@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
class TestPosixCalls:
    def test_function(self):
        "will not be setup or run under 'win32' platform"

Кроме того, можно скипать тесты, если невозможно заимпортить зависимости.

XFail: mark test functions as expected to fail

@pytest.mark.xfail
def test_function():
    ...

# альтернатива
def test_function():
    if not valid_config():
        pytest.xfail("failing configuration (but should work)")

# или
def test_function2():
    import slow_module

    if slow_module.slow_function():
        pytest.xfail("slow_module taking too long")

Мы так-же можем задать несколько параметров

@pytest.mark.xfail(sys.platform == "win32", reason="bug in a 3rd party library")
def test_function():

@pytest.mark.xfail(reason="known parser issue")
def test_function():
    ...

# можно задать ошибку, которая будет поднята
@pytest.mark.xfail(raises=RuntimeError)
def test_function():
    ...

@pytest.mark.xfail(run=False)
def test_function():
    ...

@pytest.mark.xfail(strict=True)
def test_function():
    ...

Проигнорить xfail можно так pytest --runxfail

Аналогом является zpass

Скип, фейл и пас могут быть параметризованы для запуска в серии тестов

Parametrizing fixtures and test functions

Используется для запуска серии тестов

# content of test_expectation.py
import pytest


@pytest.mark.parametrize("test_input,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)])
def test_eval(test_input, expected):
    assert eval(test_input) == expected

Cache: working with cross-testrun state

Позволяет кешировать и выводить только определеныне тесты

Мы можем сконфигурирровать следующий запуск тестов

pytest --last-failed --last-failed-no-failures all    # run all tests (default behavior)
pytest --last-failed --last-failed-no-failures none   # run no tests and exit

Очистить кеш pytest --cache-clear

unittest.TestCase Support

pytest саппортит запуск [unittest]. Из коробки это можно сделать так: pytest tests

Крмое того, пайтест автоматически коллектит unittest.TestCase сабклассы и их методы в test_*.py или *_test.py. Кроме того, саппортятся:

не саппортится load test протокол и сабтесты

В юниттестовых сабклассах саппортятся:

Не работаюти (и никогда не будут):

pytest fixtures можно миксить в unittest.TestCase сабклассы

# we define a fixture function below and it will be "used" by
# referencing its name from tests

import pytest


@pytest.fixture(scope="class")
def db_class(request):
    class DummyDB:
        pass

    # set a class attribute on the invoking test context
    request.cls.db = DummyDB()

Теперь, если мы используем db_class, он будет вызван однажды для каждого теста и установит на уровне атрибута класса инстанс сласса DummyDB(). Он станет доступен в тесте юниттеста благодаря cls

import unittest
import pytest


@pytest.mark.usefixtures("db_class")
class MyTest(unittest.TestCase):
    def test_method1(self):
        assert hasattr(self, "db")
        assert 0, self.db  # fail for demo purposes

    def test_method2(self):
        assert 0, self.db  # fail for demo purposes

@pytest.mark.usefixtures("db_class") проверяет, что db_class вызван только один раз для теста.

$ pytest test_unittest_db.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-0.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 2 items

test_unittest_db.py FF                                               [100%]

================================= FAILURES =================================
___________________________ MyTest.test_method1 ____________________________

self = <test_unittest_db.MyTest testMethod=test_method1>

    def test_method1(self):
        assert hasattr(self, "db")
>       assert 0, self.db  # fail for demo purposes
E       AssertionError: <conftest.db_class.<locals>.DummyDB object at 0xdeadbeef>
E       assert 0

test_unittest_db.py:10: AssertionError
___________________________ MyTest.test_method2 ____________________________

self = <test_unittest_db.MyTest testMethod=test_method2>

    def test_method2(self):
>       assert 0, self.db  # fail for demo purposes
E       AssertionError: <conftest.db_class.<locals>.DummyDB object at 0xdeadbeef>
E       assert 0

test_unittest_db.py:13: AssertionError
========================= short test summary info ==========================
FAILED test_unittest_db.py::MyTest::test_method1 - AssertionError: <conft...
FAILED test_unittest_db.py::MyTest::test_method2 - AssertionError: <conft...
============================ 2 failed in 0.12s =============================

Using autouse fixtures and accessing other fixtures

Иногда это полезно, когда мы хотим использовать фикстуру автоматически в данном контексте. Например мы можем так получить доступ к фикстурам временного пути и временных директорий

import pytest
import unittest


class MyTest(unittest.TestCase):
    @pytest.fixture(autouse=True)
    def initdir(self, tmpdir):
        tmpdir.chdir()  # change to pytest-provided temporary directory
        tmpdir.join("samplefile.ini").write("# testdata")

    def test_method(self):
        with open("samplefile.ini") as f:
            s = f.read()
        assert "testdata" in s

Running tests written for nose

classic xunit-style setup

Installing and Using plugins

pip install pytest-NAME
pip uninstall pytest-NAME

Writing plugins

Writing hook functions

Logging¶

Пример хорошей интеграции в проект

[[Flaky-tests]] статья

pytest import mechanisms and sys.path/PYTHONPATH

Конфигурирование

Примеры

Остальные главы посвящены разработке пайтеста, лицйензированию, депрекетам и другим вопросам развития проекта.

Смотри [pytest-parametrizing]

>>> На главную <<<