Модифицируем свой тестовый комплекс в Pytest (часть 2)

Вторая часть выступления Рафаэля Пирцины (Raphael Pierzina), которым открылся PyBerlin 2019. Презентуются возможности Pytest для организации автоматизированного тестирования проекта на Python, в том числе использование специальных зафиксированных объектов (fixtures), исключение из прогона медленных тестов и написание двух плагинов (pytest plugin). Полный перевод текста выступления с примерами тестовых комплексов см. далее.

Customizing your pytest test suite (part 2) (Sep 30, 2019)

Первая часть данной статьи.

Содержание

  • Добавление индивидуальной метки
  • Новый тест, объединяющий все сценарии
  • Плагин для исключения медленных тестов по умолчанию
  • Тесты с вовлечением нескольких мероприятий
  • Использование метки с конкретными параметрами зафиксированного объекта
  • Добавление файла переменных в конфигурацию pytest
  • Плагин для кэширования времени выполнения тестов
  • Обновление точки добавления кода (hook) для исключения тестов
  • Плагин для запуска тестов со специальным зафиксированным объектом (fixture)

Всем привет! добро пожаловать на вторую часть данной инструкции по pytest. В первой части мы расширили покрытие кода (code coverage) в проекте earth с 53% до 98%. Для этого мы разработали серию автоматизированных тестов на основе pytest и использовали пример из файла README проекта.

Однако, еще много чего можно улучшить, особенно с точки зрения продуктивности разработчика, потому что прогон новых тестов занимает больше 20 секунд. Причиной этого является то, что test_large_group использует все функции из модуля adventurers, и, когда pandas готовятся к мероприятию, им нужно поесть, а это требует времени!

def new_panda(name, **kwargs):
    def eat(panda):
        for i in range(4):
            print(f"{panda.profile} {panda.name} is eating... ?")
            time.sleep(5)

    kwargs.setdefault("location", "Asia")
    return Adventurer(
        name=name, profile="?", getting_ready=[eat, pack], **kwargs
    )

Кроме того, три новых теста почти идентичны. Различаются только их параметры (parameters) и метки (markers). Это будет хорошим примером для реализации функций параметризации pytest.

Как и в предыдущей части, можно отслеживать изменения кода, которые мы будем вносить по мере продвижения по данной инструкции, с помощью коммитов в ветке write-pytest-plugins репозитория earth.

См. далее тесты и зафиксированные объекты (fixtures) из предыдущей части:

tests/test_earth.py
import pytest

from earth import adventurers, Event, Months


@pytest.fixture(name="event")
def fixture_event():
    return Event("PyCon US", "North America", Months.MAY)


@pytest.fixture(name="small_group")
def fixture_small_group():
    return [
        adventurers.new_frog("Bruno"),
        adventurers.new_lion("Michael"),
        adventurers.new_koala("Brianna"),
        adventurers.new_tiger("Julia"),
    ]


@pytest.fixture(name="large_group")
def fixture_large_group():
    return [
        adventurers.new_frog("Bruno"),
        adventurers.new_panda("Po"),
        adventurers.new_fox("Dave"),
        adventurers.new_lion("Michael"),
        adventurers.new_koala("Brianna"),
        adventurers.new_tiger("Julia"),
        adventurers.new_fox("Raphael"),
        adventurers.new_fox("Caro"),
        adventurers.new_bear("Chris"),
        # Bears in warm climates don't hibernate ?
        adventurers.new_bear("Danny", availability=[*Months]),
        adventurers.new_bear("Audrey", availability=[*Months]),
    ]


@pytest.fixture(name="no_pandas_group")
def fixture_no_pandas_group():
    return [
        adventurers.new_frog("Bruno"),
        adventurers.new_fox("Dave"),
        adventurers.new_lion("Michael"),
        adventurers.new_koala("Brianna"),
        adventurers.new_tiger("Julia"),
        adventurers.new_fox("Raphael"),
        adventurers.new_fox("Caro"),
        adventurers.new_bear("Chris"),
        # Bears in warm climates don't hibernate ?
        adventurers.new_bear("Danny", availability=[*Months]),
        adventurers.new_bear("Audrey", availability=[*Months]),
    ]


@pytest.mark.wip
@pytest.mark.happy
def test_small_group(event, small_group):
    for adventurer in small_group:
        event.invite(adventurer)

    for attendee in event.attendees:
        attendee.get_ready()
        attendee.travel_to(event)

    event.start()


@pytest.mark.wip
@pytest.mark.slow
@pytest.mark.happy
@pytest.mark.xfail(reason="Problems with TXL airport")
def test_large_group(event, large_group):
    for adventurer in large_group:
        event.invite(adventurer)

    for attendee in event.attendees:
        attendee.get_ready()
        attendee.travel_to(event)

    event.start()


@pytest.mark.wip
@pytest.mark.happy
@pytest.mark.xfail(reason="Problems with TXL airport")
def test_no_pandas_group(event, no_pandas_group):
    for adventurer in no_pandas_group:
        event.invite(adventurer)

    for attendee in event.attendees:
        attendee.get_ready()
        attendee.travel_to(event)

    event.start()

Добавление индивидуальной метки

Начнем данную инструкцию с добавления индивидуальной метки к тестам, в которых задействован объект TXL airport. Позднее мы будем использовать ее в качестве алиаса для метки xfail, а также создадим для нее документацию.

tests/test_earth.py
pytest.mark.txl = pytest.mark.xfail(reason="Problems with TXL airport")

Новый тест, объединяющий все сценарии

Все вышеприведенные тесты зависят от разных групп искателей приключений:

  • small_group
  • large_group
  • no_pandas_group

Самый быстрый способ переключаться между разными группами по принципу отдельного элемента теста — это создать зафиксированный объект (fixture), который возвращает конкретную группу для заданного параметра.

Мы сможем генерировать несколько тестовых элементов из одной тестовой функции с помощью параметризации (parametrize). Для этого нам понадобится функция pytest.param, которой мы передадим метки для именованных аргументов (keyword argument). Таким образом мы добавим метки к конкретным параметрам тестов.

Обратите внимание на то, что мы также добавим метки, которые будут применяться ко всем тестовым элементам, к тестовой функции.

tests/test_earth.py
@pytest.fixture(name="group")
def fixture_group(request, small_group, large_group, no_pandas_group):
    group_name = request.param
    groups = {
        "small_group": small_group,
        "large_group": large_group,
        "no_pandas_group": no_pandas_group,
    }
    return groups[group_name]


@pytest.mark.wip
@pytest.mark.happy
@pytest.mark.parametrize(
    "group",
    [
        pytest.param("small_group"),
        pytest.param(
            "large_group",
            marks=[pytest.mark.txl, pytest.mark.slow]
        ),
        pytest.param(
            "no_pandas_group",
            marks=[pytest.mark.txl]
        ),
    ],
    indirect=True,
)
def test_earth(group, event):
    for adventurer in group:
        event.invite(adventurer)

    for attendee in event.attendees:
        attendee.get_ready()
        attendee.travel_to(event)

    event.start()

Удаление тестов, которые стали избыточными. Поскольку мы создали новый тест, который объединяет все три разных сценария, мы можем удалить первоначальные тесты. Обязательно проверьте, чтобы покрытие кода осталось на уровне 98%, и мы не пропустили ни одного сценария.

Плагин для исключения медленных тестов по умолчанию

Давайте создадим файл conftest.py и добавим специальную опцию для командной строки. После этого мы модифицируем коллекцию тестовых элементов (test items collection) и автоматически исключим тесты с медленной меткой:

tests/conftest.py
def pytest_addoption(parser):
    group = parser.getgroup("earth")
    group.addoption(
        "--slow",
        action="store_true",
        default=False,
        help="Include slow tests in test run",
    )


def pytest_collection_modifyitems(items, config):
    """Deselect tests marked as slow if --slow is set."""

    if config.option.slow is True:
        return

    selected_items = []
    deselected_items = []

    for item in items:
        if item.get_closest_marker("slow"):
            deselected_items.append(item)
        else:
            selected_items.append(item)

    config.hook.pytest_deselected(items=deselected_items)
    items[:] = selected_items

Теперь pytest по умолчанию исключает медленные тесты:

pytest

Но если мы захотим прогнать все тесты, можно добавить специальную опцию для командной строки:

pytest --slow

Тесты с вовлечением нескольких мероприятий

На данный момент мы прогоняем свои тесты по одному мероприятию, которое проводится в мае на территории Северной Америки:

@pytest.fixture(name="event")
def fixture_event():
    return Event("PyCon US", "North America", Months.MAY)

Это необоснованный риск. Использование библиотеки может закончиться аварийным отказом, если ее использовать по различным сочетаниям местоположений и месяцев проведения мероприятий! Покрытие кода — хороший показатель того, какие строки исходного кода выполняются во время прогона тестов, однако оно не может измерить качество ваших тестовых сценариев.

Было бы разумно прогнать наши тесты, сделанные по методу счастливого пути (happy path tests), по нескольким возможным сценариям. Мы могли бы использовать информацию о реальных конференциях Python, чтобы сгенерировать больше мероприятий для наших тестов. Великолепный плагин pytest-variables загружает тестовые данные из файла JSON и предоставляет доступ к этим данным через зафиксированный объект (fixture) variables.

Давайте создадим новый файл JSON:

conferences.json
{
  "events": {
    "EuroPython": {
      "location": "Europe",
      "month": "JUL"
    },
    "PyCon US": {
      "location": "North America",
      "month": "MAY"
    },
    "PyCon AU": {
      "location": "Australia",
      "month": "AUG"
    },
    "PyCon Namibia": {
      "location": "Africa",
      "month": "FEB"
    },
    "Python Brasil": {
      "location": "South America",
      "month": "OCT"
    }
  }
}

Теперь мы модифицируем зафиксированный объект event, чтобы в нем появились дополнительные параметры, а также создадим экземпляры класса (instances) Event на основе информации, полученной из загруженного файла JSON:

tests/test_earth.py
@pytest.fixture(
    name="event",
    params=[
        "EuroPython",
        "PyCon AU",
        "PyCon Namibia",
        "PyCon US",
        "Python Brasil",
    ],
)
def fixture_event(request, variables):
    map_to_month = {month.name: month for month in Months}
    event_name = request.param
    event_info = variables["events"][event_name]
    event_location = event_info["location"]
    event_month = map_to_month[event_info["month"]]
    return Event(event_name, event_location, event_month)

Использование метки с конкретными параметрами зафиксированного объекта

Однако, есть один пограничный случай (edge case), который не учитывается в нынешней реализации зафиксированного объекта. Нам нужно добавить специальную метку xfail к мероприятиям, которые проводятся в Европе, потому что посетителям придется приземлиться в TXL airport, чтобы добраться до мероприятия.

tests/test_earth.py
@pytest.fixture(
    name="event",
    params=[
        "EuroPython",
        "PyCon AU",
        "PyCon Namibia",
        "PyCon US",
        "Python Brasil",
    ],
)
def fixture_event(request, variables):
    map_to_month = {month.name: month for month in Months}
    event_name = request.param
    event_info = variables["events"][event_name]
    event_location = event_info["location"]
    event_month = map_to_month[event_info["month"]]

    # Apply marker for conferences in Europe
    if event_location == "Europe":
        request.applymarker(pytest.mark.txl)

    return Event(event_name, event_location, event_month)

Теперь, во время прогона тестов нам нужно будет указать файл, из которого будут загружаться переменные (variables):

pytest --variables conferences.json

Добавление файла переменных в конфигурацию pytest

Вместо того, чтобы каждый раз запускать pytest вручную с этой опцией командной строки, лучше указать опции, которые будут добавляться к нашей конфигурации pytest по умолчанию:

pytest.ini
[pytest]
markers =
    slow: tests that take a long time to complete.
    txl: tests that involve TXL airport.
addopts = --variables conferences.json

Плагин для кэширования времени выполнения тестов

Предыдущую часть данной инструкции я закончил словами о том, что мы сделаем что-нибудь по-настоящему крутое с помощью pytest. У нас нет никакой возможности узнать, сколько времени займет тест, до его запуска. Однако, мы, все же, знаем, сколько времени он занял в прошлый прогон. Мы можем отслеживать данную информацию на следующем запуске тестов.

Что должен делать наш плагин:

  • отслеживать время работы теста в точке добавления кода (hook) pytest_runtest_logreport
  • записывать данные о времени работы теста в кэш через точку добавления кода pytest_sessionfinish.

Кроме того, нам нужно, чтобы плагин:

  • попытался загрузить данные о времени работы теста из кэша при запуске
  • добавил метку (marker) для медленных тестов в точке добавления кода pytest_collection_modifyitems

Мы можем зарегистрировать локальные плагины в файле conftest.py:

tests/conftest.py
class Turtle:
    """Plugin for adding markers to slow running tests."""

    def __init__(self, config):
        self.config = config
        self.durations = defaultdict(dict)
        self.durations.update(
            self.config.cache.get("cache/turtle", defaultdict(dict))
        )
        self.slow = 5.0

    def pytest_runtest_logreport(self, report):
        self.durations[report.nodeid][report.when] = report.duration

    @pytest.mark.tryfirst
    def pytest_collection_modifyitems(self, session, config, items):
        for item in items:
            duration = sum(self.durations[item.nodeid].values())
            if duration > self.slow:
                item.add_marker(pytest.mark.turtle)

    def pytest_sessionfinish(self, session):
        cached_durations = self.config.cache.get(
            "cache/turtle", defaultdict(dict)
        )
        cached_durations.update(self.durations)
        self.config.cache.set("cache/turtle", cached_durations)

    def pytest_configure(self, config):
        config.addinivalue_line(
            "markers", "turtle: marker for slow running tests"
        )


def pytest_configure(config):
    config.pluginmanager.register(Turtle(config), "turtle")

Обновление точки добавления кода (hook) для исключения тестов

Теперь нам нужно обновить свою реализацию точки добавления кода pytest_collection_modifyitems, чтобы дополнительно исключать тесты с помощью новой метки turtle.

tests/conftest.py
def pytest_collection_modifyitems(items, config):
    """Deselect tests marked as with "slow" or "turtle" by default."""

    if config.option.slow is True:
        return

    selected_items = []
    deselected_items = []

    for item in items:
        if item.get_closest_marker(
            "slow"
        ) or item.get_closest_marker("turtle"):
            deselected_items.append(item)
        else:
            selected_items.append(item)

    config.hook.pytest_deselected(items=deselected_items)
    items[:] = selected_items

Плагин для запуска тестов со специальным зафиксированным объектом (fixture)

При работе с зафиксированными объектами нередко прогоняют тесты, чтобы проверить, что зафиксированные объекты работают как ожидается, меняют код зафиксированного объекта и запускают тесты еще раз. Для таких ситуаций есть смысл запускать только те тесты, которые используют новый зафиксированный объект, и исключить все остальные тесты.

Давайте напишем для этого плагин!

tests/conftest.py
def pytest_addoption(parser):
    group = parser.getgroup("earth")
    group.addoption(
        "--slow",
        action="store_true",
        default=False,
        help="Include slow tests in test run",
    )

    group.addoption(
        "--owl",
        action="store",
        type=str,
        default=None,
        metavar="fixture",
        help="Run test using the fixture",
    )
class Owl:
    """Plugin for running tests using a specific fixture."""

    def __init__(self, config):
        self.config = config

    def pytest_collection_modifyitems(self, items, config):
        if not config.option.owl:
            return

        selected_items = []
        deselected_items = []

        for item in items:
            if config.option.owl in getattr(item, "fixturenames", ()):
                selected_items.append(item)
            else:
                deselected_items.append(item)

        config.hook.pytest_deselected(items=deselected_items)
        items[:] = selected_items
def pytest_configure(config):
    config.pluginmanager.register(Turtle(config), "turtle")
    config.pluginmanager.register(Owl(config), "owl")

Заключение

Прекрасно, вы добрались до конца этой части!

Мы посмотрели, когда и как запускать тесты с различными тестовыми параметрами, как исключить отдельные тесты, как загрузить тестовые данные из файла JSON, как использовать кэш pytest для хранения и загрузки времени работы теста и, наконец, как запускать тесты, которые используют определенный зафиксированный объект.