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

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

Источник: Customizing your pytest test suite (part 1) (Mar 25, 2019)

Содержание

  • Введение
  • Требования к тестам
  • Запускаем тесты
  • Пропустим один тест
  • Как пропустить целый тестовый модуль
  • Прогон примера из README
  • Тест по методу счастливого пути (happy path test)
  • Запуск только нашего теста
  • Подключим зафиксированные объекты (fixtures) pytest
  • Генерация отчета о покрытии в формате HTML
  • Не доработанное покрытие
  • Указание имен для зафиксированных объектов
  • Вывод медленных тестов
  • Добавление документации для метки (marker)
  • Вывод результата (output)
  • Напишем еще один тест
  • Выбор теста через выражение с меткой
  • Метка на нестабильных тестах
  • Проверка покрытия кода
  • Следующие этапы

Введение

PyBerlin - новый форум для проведения встреч в Берлине (Германия), который старается быть открытым и содержательным, проводить выступления на самые разные темы для всех, кого интересует Python.

Организаторы PyBerlin пригласили меня сделать открывающее выступление на первом мероприятии, которое проходило в феврале 2019 года, и предложили поговорить об автоматизированном тестировании (automated testing) с помощью pytest. Я был очень рад и понимал, что мне оказана большая честь стать первым лектором перед сообществом. Хотелось подготовить что-нибудь особенное!

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

"Earth" библиотека для организации и проведения мероприятий. Можно приглашать любителей приключений со всего мира, следить за тем, как они пакуют рюкзаки и летят самолетом к месту проведения мероприятия. Наконец, их можно встречать и знакомить.

Текст проблемы на GitHub:
"Earth" очень важный проект, но меня беспокоит то, что без надлежащего тестового покрытия (test coverage) мы можем оставить его пользователей без защиты. Предлагается определить, какие функции еще не включены в тест, и обеспечить самое лучшее покрытие в дальнейшем.

Данный пост представляет собой текстовую версию выступления на PyBerlin под названием "Customizing your pytest test suite" (Модифицируем свой тестовый комплекс в Pytest). Если он показался вам полезным, прошу рассказать о нем коллегам и друзьям по питону! Еще одна версия данного выступления стримилась в прямом эфире на YouTube, и сейчас запись доступна на канале Mozilla YouTube, если предпочтительно посмотреть, а не читать этот довольно длинный пост.

Данный пост организован по модели инструкции. Мы научимся...

  • Основам написания автоматизированных тестов в pytest
  • Использованию зафиксированных объектов (fixtures) и меток (markers) pytest для структурирования тестов
  • Использованию различных опций командной строки для выбора тестов
  • Использованию мощных функций нескольких прекрасных плагинов для pytest
  • А также напишем свой плагин для модификации тестового комплекса

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

Хорошие инструкции писать трудно. Сделаю все возможное, чтобы дать вам достаточно информации без лишних подробностей для выполнения каждого этапа. Если найдете опечатки, баги в примерах когда или возникнут вопросы, прошу открыть новую проблему на GitHub.

Коммиты с изменениями кода для репозитория "earth" я буду отправлять на отдельную ветку (branch) под названием "increase-test-coverage" (расширение тестового покрытия), а также будут делать коммиты для каждого этапа в этой инструкции. Если где-то застрянете, прошу проверить коммиты на этой ветке и продолжить с того места.

Начнем!

Наша задача. Мы будем выявлять слепые пятна в проекте "earth" и расширять его тестовое покрытие с помощью разработки серии автоматизированных тестов. Нам потребуется локальная копия репозитория "earth" на GitHub, а также новое виртуальное окружение (virtual environment) Python 3.7 с установленным пакетом "attrs".

Требования к тестам

Также прошу установить следующие Python-пакеты в активированное виртуальное окружение, потому что мы будем использовать эти плагины в данной инструкции:

  • pytest
  • pytest-cov
  • pytest-emoji
  • pytest-html
  • pytest-md
  • pytest-repeat
  • pytest-variables

Посмотрим на проект. В папке "tests" проекта "earth" есть несколько тестов. Осмотритесь и откройте несколько файлов в тестовом редакторе, чтобы разобраться в том, что делают тесты.

tests/
├── __init__.py
├── adventurers
│   ├── __init__.py
│   ├── test_adventurers_01.py
│   ├── test_adventurers_02.py
│   └── test_adventurers_03.py
├── earth
│   ├── __init__.py
│   ├── test_earth_01.py
│   └── test_earth_02.py
├── events
│   ├── __init__.py
│   ├── test_events_01.py
│   ├── test_events_02.py
│   ├── test_events_03.py
│   └── test_events_04.py
├── old
│   ├── __init__.py
│   └── stuff
│       ├── __init__.py
│       ├── test_stuff_01.py
│       ├── test_stuff_02.py
│       └── test_stuff_03.py
├── travel
│   ├── __init__.py
│   ├── test_travel_01.py
│   ├── test_travel_02.py
│   └── test_travel_03.py
└── year
    ├── __init__.py
    ├── test_year_01.py
    └── test_year_02.py

7 папок, 25 файлов

Вы поймете, что только файл tests/year/test_year_02.py импортирует библиотеку "earth"; это означает, что большинство имеющихся тестов не используют библиотеку "earth" и не могут генерировать покрытие кода.

Запускаем тесты

Начнем с прогона имеющихся тестов как есть:

pytest --verbose

E     File "earth/tests/old/stuff/test_stuff_03.py", line 6
E       print "hello world"
E                         ^
E   SyntaxError: Missing parentheses in call to 'print'. Did you mean
    print("hello world")?

Вот так… ошибка синтаксиса SyntaxError еще до того как pytest смог прогнать хоть один тест?

Пропустим один тест

Когда pytest попытался собрать тесты, он не смог импортировать следующий файл, в котором есть тест с оператором print (print statement) из Python 2. Он не поддерживается в Python 3, и в результате этого появляется ошибка SyntaxError!

tests/old/stuff/test_stuff_03.py
def test_numbers():
    assert 1234 == 1234


def test_hello_world():
    print "hello world"


def test_foobar():
    assert True

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

tests/old/stuff/test_stuff_03.py
import pytest


def test_numbers():
    assert 1234 == 1234


@pytest.mark.skip(reason="Outdated Python syntax")
def test_hello_world():
    # TODO: Can we remove this test?
    # print "hello world"
    pass


def test_foobar():
    assert True

Прогоним тесты (еще раз). Снова запустим pytest и внимательно следим за событиями:

pytest --verbose

Вы увидите, что ошибка перед нами больше не возникнет, но выполнение четырех тестов в tests/old/stuff/test_stuff_02.py занимает довольно много времени.

Как пропустить целый тестовый модуль

Давайте создадим отчет о тестовом покрытии с помощью плагина pytest-cov, чтобы посмотреть, будет ли модуль с медленными тестами генерировать тестовое покрытие:

pytest --cov earth/
Name                   Stmts   Miss  Cover
------------------------------------------
earth/__init__.py          3      0   100%
earth/adventurers.py      71     41    42%
earth/events.py           24     12    50%
earth/travel.py           31     14    55%
earth/year.py             14      0   100%
------------------------------------------
TOTAL                    143     67    53%

Затем пропустим тестовый модуль целиком:

tests/old/stuff/test_stuff_02.py
import time
import unittest

import pytest

pytest.skip("This does not generate code coverage", allow_module_level=True)


class TestStringMethods(unittest.TestCase):
    def setUp(self):
        # A long time ago in a galaxy far, far away...
        time.sleep(2)

    def test_upper(self):
        self.assertEqual("foo".upper(), "FOO")

    def test_upper_bar(self):
        self.assertEqual("foo".upper(), "BAR")

    def test_isupper(self):
        self.assertTrue("FOO".isupper())
        self.assertFalse("Foo".isupper())

    def test_split(self):
        s = "hello world"
        self.assertEqual(s.split(), ["hello", "world"])

        # check that s.split fails when the separator is not a string
        with self.assertRaises(TypeError):
            s.split(2)

Создадим новый отчет о тестовом покрытии. Вы увидите, что файл с тестами пропущен, а тестовое покрытие осталось на уровне 53%. Это означает, что тест не генерировал никакого покрытия кода, и, если его пропустить, то сложностей не будет.

Прогон примера из README

Файл README в репозитории "earth" содержит пример использования библиотеки, и тот же самый пример также скопирован в скрипт example1.py.

example1.py
from earth import adventurers, Event, Months


def main():
    print("Hello adventurers! ?")
    print("-" * 40)

    friends = [
        adventurers.new_frog("Bruno"),
        adventurers.new_lion("Michael"),
        adventurers.new_koala("Brianna"),
        adventurers.new_tiger("Julia"),
    ]

    event = Event("PyCon US", "North America", Months.MAY)

    for adventurer in friends:
        event.invite(adventurer)

    print("-" * 40)

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

    print("-" * 40)

    event.start()


if __name__ == "__main__":
    main()

Запустим пример и посмотрим, что он делает:

python example1.py
Hello adventurers! ?
----------------------------------------
Bruno accepted our invite! ?
Michael accepted our invite! ?
Brianna accepted our invite! ?
Julia accepted our invite! ?
----------------------------------------
? Bruno is packing ?
? Bruno is travelling: South America ✈️  North America
? Michael is packing ?
? Michael is travelling: Africa ✈️  North America
? Brianna is packing ?
? Brianna is travelling: Australia ✈️  North America
? Julia is travelling: Asia ✈️  North America
----------------------------------------
Welcome to PyCon US in North America! ?
Let's start with introductions...?
? Hello, my name is Bruno!
? Hello, my name is Michael!
? Hello, my name is Brianna!
? Hello, my name is Julia!

Тест по методу счастливого пути (happy path test)

Пример в README очень важен для для того, чтобы наш проект "earth" получил признание. Если этот фрагмент кода не будет работать сразу, вполне возможно, что пользователи будут разочарованы и поищут другие библиотеки. Мы точно хотим включить его в тестовое покрытие.

Давайте создадим новый файл теста и напишем тест на основе примера. Обратите внимание, что мы не будем включать в тест утверждения (assertions). Будем тестировать только наличие необработанных исключений (exceptions).

Также рекомендую добавить индивидуальные метки (pytest markers) к тестам, над которыми мы сейчас работаем, потому что так гораздо легче их выбрать. Будем использовать слово "happy", потому что наш новый тест будет работать по методу счастливого пути, и "wip" (work in progress) для обозначения того, что данный тест является работой в процессе выполнения.

import pytest

from earth import adventurers, Event, Months


@pytest.mark.wip
@pytest.mark.happy
def test_earth():
    print("Hello adventurers! ?")
    print("-" * 40)

    friends = [
        adventurers.new_frog("Bruno"),
        adventurers.new_lion("Michael"),
        adventurers.new_koala("Brianna"),
        adventurers.new_tiger("Julia"),
    ]

    event = Event("PyCon US", "North America", Months.MAY)

    for adventurer in friends:
        event.invite(adventurer)

    print("-" * 40)

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

    print("-" * 40)

    event.start()

Запуск только нашего теста

Теперь мы можем использовать метки, чтобы выбрать свой тест и проверить его выполнение:

pytest -m wip
============================ test session starts =============================
collected 50 items / 49 deselected / 1 skipped

tests/test_earth.py .

============= 1 passed, 1 skipped, 49 deselected in 0.11 seconds =============

Круто, он работает!

Подключим зафиксированные объекты (fixtures) pytest

Теперь мы знаем, что тест выполняется, поэтому давайте проведем рефакторинг нашего кода и разделим тестовые зависимости (test dependencies) и реализацию теста (test implementation) с помощью зафиксированных объектов, а также удалим все функции print, потому что они не нужны для тестирования.

tests/test_earth.py
import pytest

from earth import adventurers, Event, Months


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


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


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

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

    event.start()

Генерация отчета о покрытии в формате HTML

Хорошей идеей будет проверить, как изменится покрытие кода после добавления этого теста. На этот раз мы снова прогоним тесты и создадим отчет о покрытии в формате HTML, чтобы увидеть не только процентное количество строк, охваченных тестами, но строки, не исполненные во время прогона тестов.

pytest --cov earth/ --cov-report html

В результате появился новый файл htmlcov/index.html

Открыв данный отчет о покрытии в веб-браузере, вы увидите, что наше тестовое покрытие стало заметно лучше. Теперь оно на уровне 84%. Круто!

Не доработанное покрытие

Отчет о покрытии в формате HTML также покажет покрытие кода в каждом файле. Мы можем воспользоваться данной информацией, чтобы поработать над еще более широким покрытием кода для "earth". Покрытие первого файла в нашем отчете adventurers.py составляет 80%. Если кликнуть по ссылке в HTML, то вы увидете несколько красных линий. Это означает, что они не включены в покрытие.

Ни в одном из наших тестов не вызываются следующие функции:

  • new_panda()
  • new_bear()
  • new_fox()

Давайте что-нибудь с этим сделаем! Напишем еще один тест.

Не будем менять тест, сделанный для примера в README, и напишем новый тест с более широкой аудиторией искателей приключений. Переименуем зафиксированный объект "friends" в "small_group" и переименуем тест из "test_earth" в "test_small_group".

Создадим новый зафиксированный объект "large_group" на основе "small_group", но будем возвращать полный список искателей приключений и копировать "test_small_group" в тест "test_large_group".

tests/test_earth.py
import pytest

from earth import adventurers, Event, Months


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


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


@pytest.fixture
def 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.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.happy
def test_large_group(event, large_group):
    for adventurer in small_group:
        event.invite(adventurer)

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

    event.start()

Повторный запуск теста

Прогоним наши тесты из набора "работа на этапе выполнения" (work in progress) и посмотрим, удается ли выполнить новый тест:

pytest -m wip
============================ test session starts =============================
collected 51 items / 49 deselected / 1 skipped / 1 selected

tests/test_earth.py .F                                                  [100%]

================================== FAILURES ==================================
______________________________ test_large_group ______________________________

    @pytest.mark.wip
    @pytest.mark.happy
    def test_large_group(event, large_group):
>       for adventurer in small_group:
E       TypeError: 'function' object is not iterable

tests/test_earth.py:55: TypeError
======== 1 failed, 1 passed, 1 skipped, 49 deselected in 0.12 seconds ========

Подождите, как это? Наш тест не выполнен с ошибкой типизации TypeError?!

Указание имен для зафиксированных объектов

Мы забыли переименовать "small_group", который находится в цикле for (for loop) в "test_large_group".

Но, поскольку мы создали функцию с именем "small_group" на уровне модуля (module scope), а в Python существуют только объекты, то Python пытается осуществить итерацию (iterate) по нашей функции, созданной в зафиксированном объекте, вместо того, чтобы выдать ошибку присвоения имени NameError.

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

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.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.happy
def test_large_group(event, large_group):
    for adventurer in small_group:
        event.invite(adventurer)

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

    event.start()

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

pytest -m wip
============================ test session starts =============================
collected 51 items / 49 deselected / 1 skipped / 1 selected

tests/test_earth.py .F                                                  [100%]

================================== FAILURES ==================================
______________________________ test_large_group ______________________________

    @pytest.mark.wip
    @pytest.mark.happy
    def test_large_group(event, large_group):
>       for adventurer in small_group:
E       NameError: name 'small_group' is not defined

tests/test_earth.py:55: NameError
======== 1 failed, 1 passed, 1 skipped, 49 deselected in 0.13 seconds ========

Давайте это исправим и обновим имя переменной в "test_large_group" перед повторным прогоном тестов.

Вывод медленных тестов

После запуска теста мы замечаем, что теперь тестовому комплексу нужно много времени. Среди функций pytest есть по-настоящему замечательный флаг для командной строки, с помощью которого можно прогонять тесты и показывать отсортированный список (sorted list) медленных тестов. Давайте выведем на экран только два самых медленных теста.

pytest -m wip --durations 2
============================ test session starts =============================
collected 51 items / 49 deselected / 1 skipped / 1 selected

tests/test_earth.py .F                                                  [100%]

================================== FAILURES ==================================
______________________________ test_large_group ______________________________

    @pytest.mark.wip
    @pytest.mark.happy
    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()

tests/test_earth.py:62:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
earth/events.py:34: MissingAttendee
--------------------------- Captured stdout call ----------------------------
Bruno accepted our invite! ?
Po accepted our invite! ?
Dave accepted our invite! ?
Michael accepted our invite! ?
Brianna accepted our invite! ?
Julia accepted our invite! ?
Raphael accepted our invite! ?
Caro accepted our invite! ?
Chris is not available in May! ?
Danny accepted our invite! ?
Audrey accepted our invite! ?
? Bruno is packing ?
? Bruno is travelling: South America ✈️  North America
? Po is eating... ?
? Po is eating... ?
? Po is eating... ?
? Po is eating... ?
? Po is packing ?
? Po is travelling: Asia ✈️  North America
? Dave is packing ?
? Dave's flight was cancelled ? Problems at Berlin Airport ?
? Michael is packing ?
? Michael is travelling: Africa ✈️  North America
? Brianna is packing ?
? Brianna is travelling: Australia ✈️  North America
? Julia is travelling: Asia ✈️  North America
? Raphael is packing ?
? Raphael's flight was cancelled ? Problems at Berlin Airport ?
? Caro is packing ?
? Caro is travelling: Europe ✈️  North America
? Danny is packing ?
? Audrey is packing ?
Welcome to PyCon US in North America! ?
Let's start with introductions...?
? Hello, my name is Bruno!
? Hello, my name is Po!
========================== slowest 2 test durations ==========================
20.01s call     tests/test_earth.py::test_large_group

(0.00 durations hidden.  Use -vv to show these durations.)
======= 1 failed, 1 passed, 1 skipped, 49 deselected in 20.16 seconds ========

В конечном итоге новый тест не выполнился, сперва потратив больше 20 секунд на работу.

Пускай даже флаг командной строки --durations может не принести большой пользы всего лишь с двумя тестами, да и мы уже знали, что первый из новых тестов был выполнен менее чем за секунду во время предыдущего прогона, этот флаг очень полезен для более крупных тестовых комплексов.

Добавление документации для метки (marker)

Давайте добавим новую специальную метку pytest под именем "slow" для "test_large_group". Всегда хорошо предусмотреть документацию для специальных меток и дать для каждой метки короткое описание, объясняющее параметры использующих данную метку тестов.

Добавим файл конфигурации pytest с разделом для меток:

pytest.ini
[pytest]
markers =
    slow: tests that take a long time to complete.

Теперь, если запустить pytest --markers, то можно увидеть информацию о метках pytest, в том числе и нашу специальную метку.

Вывод результата (output)

Мы знаем, какой тест оказался медленным, но не знаем - почему. По умолчанию, pytest захватывает (captures) только тот вывод, который отправляется на стандартный вывод (stdout) и стандартный вывод ошибок (stderr). Давайте еще раз прогоним медленный тест с отключенным захватом, чтобы увидеть выводимую информацию во время выполнения теста.

pytest -s -m slow
============================ test session starts =============================
collected 51 items / 50 deselected / 1 skipped

tests/test_earth.py Bruno accepted our invite! ?
Po accepted our invite! ?
Dave accepted our invite! ?
Michael accepted our invite! ?
Brianna accepted our invite! ?
Julia accepted our invite! ?
Raphael accepted our invite! ?
Caro accepted our invite! ?
Chris is not available in May! ?
Danny accepted our invite! ?
Audrey accepted our invite! ?
? Bruno is packing ?
? Bruno is travelling: South America ✈️  North America
? Po is eating... ?
? Po is eating... ?
? Po is eating... ?
? Po is eating... ?
? Po is packing ?
? Po is travelling: Asia ✈️  North America
? Dave is packing ?
? Dave's flight was cancelled ? Problems at Berlin Airport ?
? Michael is packing ?
? Michael is travelling: Africa ✈️  North America
? Brianna is packing ?
? Brianna is travelling: Australia ✈️  North America
? Julia is travelling: Asia ✈️  North America
? Raphael is packing ?
? Raphael's flight was cancelled ? Problems at Berlin Airport ?
? Caro is packing ?
? Caro is travelling: Europe ✈️  North America
? Danny is packing ?
? Audrey is packing ?
Welcome to PyCon US in North America! ?
Let's start with introductions...?
? Hello, my name is Bruno!
? Hello, my name is Po!
F

================================== FAILURES ==================================
______________________________ test_large_group ______________________________

    @pytest.mark.wip
    @pytest.mark.slow
    @pytest.mark.happy
    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()

tests/test_earth.py:63:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

 def start(self):
     print(f"Welcome to {self.name} in {self.location}! ?")
     print(f"Let's start with introductions...?")

     for attendee in self.attendees:
         if attendee.location != self.location:
>            raise MissingAttendee(f"Oh no! {attendee.name} is not here! ?")
E            earth.events.MissingAttendee: Oh no! Dave is not here! ?

earth/events.py:34: MissingAttendee
============ 1 failed, 1 skipped, 50 deselected in 20.17 seconds =============

Наш медленный тест не выполнен, но на данный момент давайте сосредоточимся на времени выполнения теста. Если попробуете сами, то увидите, что Po ест ("Po is eating...") несколько раз в течение нескольких секунд!

Напишем еще один тест

У нас есть один тест, в котором используются не все функции из adventurers.py, но он по-настоящему быстрый. У нас есть один тест, в котором используются все функции из adventurers.py, но он довольно медленный. Идеальным будет тест, который использует как можно больше функций из adventurers.py и остается по-настоящему быстрым.

Давайте напишем новый зафиксированный объект pytest "no_pandas_group" и еще один тест под именем "test_no_pandas_group":

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
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
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()

Выбор теста через выражение с меткой

Теперь у нас три разных теста с добавленными к ним специальными метками. Давайте прогоним все тесты под именем "wip", которые не относятся к медленным.

pytest -m "wip and not slow"
============================ test session starts =============================
collected 52 items / 50 deselected / 1 skipped / 1 selected

tests/test_earth.py ..                                                  [100%]

============= 2 passed, 1 skipped, 50 deselected in 0.19 seconds =============

Вот так.. оба теста выполнены.

Повторный запуск тестов

Если помните, в предыдущем прогоне тестов медленный тест не был выполнен в результате необработанного исключения (unhandled exception). Как было написано в сообщении об ошибке наш искатель приключений "Dave" не добрался до PyCon.

earth.events.MissingAttendee: Oh no! Dave is not here!

Довольно подозрительно, что "test_no_pandas_group" был выполнен, учитывая, что единственным отличием от теста "test_large_group" является отсутствие всяких панд. Причиной этого может быть то, что тесты с лисами были капризными и не выполнялись только при определенных условиях.

Мы можем проверить это, прогнав наши тесты несколько раз с помощью плагина pytest-repeat. Давайте прогоним наши быстрые тесты под меткой "wip" десять раз.

pytest -v -m "wip and not slow" --count 10
============================ test session starts =============================
collecting ... collected 520 items / 500 deselected / 1 skipped / 19 selected

tests/test_earth.py::test_small_group[1/10] PASSED                      [  5%]
tests/test_earth.py::test_small_group[2/10] PASSED                      [ 10%]
tests/test_earth.py::test_small_group[3/10] PASSED                      [ 15%]
tests/test_earth.py::test_small_group[4/10] PASSED                      [ 20%]
tests/test_earth.py::test_small_group[5/10] PASSED                      [ 25%]
tests/test_earth.py::test_small_group[6/10] PASSED                      [ 30%]
tests/test_earth.py::test_small_group[7/10] PASSED                      [ 35%]
tests/test_earth.py::test_small_group[8/10] PASSED                      [ 40%]
tests/test_earth.py::test_small_group[9/10] PASSED                      [ 45%]
tests/test_earth.py::test_small_group[10/10] PASSED                     [ 50%]
tests/test_earth.py::test_no_pandas_group[1/10] FAILED                  [ 55%]
tests/test_earth.py::test_no_pandas_group[2/10] FAILED                  [ 60%]
tests/test_earth.py::test_no_pandas_group[3/10] FAILED                  [ 65%]
tests/test_earth.py::test_no_pandas_group[4/10] FAILED                  [ 70%]
tests/test_earth.py::test_no_pandas_group[5/10] FAILED                  [ 75%]
tests/test_earth.py::test_no_pandas_group[6/10] PASSED                  [ 80%]
tests/test_earth.py::test_no_pandas_group[7/10] FAILED                  [ 85%]
tests/test_earth.py::test_no_pandas_group[8/10] FAILED                  [ 90%]
tests/test_earth.py::test_no_pandas_group[9/10] FAILED                  [ 95%]
tests/test_earth.py::test_no_pandas_group[10/10] FAILED                 [100%]

======= 9 failed, 11 passed, 1 skipped, 500 deselected in 0.30 seconds =======

Отчет в командной строке показывает нам, что тест "test_small_group" выполнел в 10 случаях из 10, а тест "test_no_pandas_group" выполнен только 1 раз из 10 прогонов. Возможно, нам не известна в точности причина, но благодаря стандартному выводу в отчете из командной строки мы можем сказать, что, судя по всему, есть проблемы в "TXL airport"… Звучит знакомо?

Метка на нестабильных тестах

Мы можем воспользоваться встроенной меткой xfail из pytest, чтобы пометить свои нестабильные тесты: ожидается, что тесты с меткой xfail не будут выполняться в связи с указанной причиной. Если они не выполняются, в отчете они будут указаны как "XFAIL", но если они будут выполняться, против ожиданий, то в отчете они будут указаны как "XPASS".

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()

Прогоним тесты еще раз:

pytest -v -m "wip and not slow" --count 10
============================ test session starts =============================
collecting ... collected 520 items / 500 deselected / 1 skipped / 19 selected

tests/test_earth.py::test_small_group[1/10] PASSED                      [  5%]
tests/test_earth.py::test_small_group[2/10] PASSED                      [ 10%]
tests/test_earth.py::test_small_group[3/10] PASSED                      [ 15%]
tests/test_earth.py::test_small_group[4/10] PASSED                      [ 20%]
tests/test_earth.py::test_small_group[5/10] PASSED                      [ 25%]
tests/test_earth.py::test_small_group[6/10] PASSED                      [ 30%]
tests/test_earth.py::test_small_group[7/10] PASSED                      [ 35%]
tests/test_earth.py::test_small_group[8/10] PASSED                      [ 40%]
tests/test_earth.py::test_small_group[9/10] PASSED                      [ 45%]
tests/test_earth.py::test_small_group[10/10] PASSED                     [ 50%]
tests/test_earth.py::test_no_pandas_group[1/10] XFAIL                   [ 55%]
tests/test_earth.py::test_no_pandas_group[2/10] XFAIL                   [ 60%]
tests/test_earth.py::test_no_pandas_group[3/10] XPASS                   [ 65%]
tests/test_earth.py::test_no_pandas_group[4/10] XFAIL                   [ 70%]
tests/test_earth.py::test_no_pandas_group[5/10] XFAIL                   [ 75%]
tests/test_earth.py::test_no_pandas_group[6/10] XPASS                   [ 80%]
tests/test_earth.py::test_no_pandas_group[7/10] XFAIL                   [ 85%]
tests/test_earth.py::test_no_pandas_group[8/10] XFAIL                   [ 90%]
tests/test_earth.py::test_no_pandas_group[9/10] XFAIL                   [ 95%]
tests/test_earth.py::test_no_pandas_group[10/10] XFAIL                  [100%]

= 10 passed, 1 skipped, 500 deselected, 8 xfailed, 2 xpassed in 0.24 seconds =

Проверка покрытия кода

Теперь давайте прогоним все наши тесты и проверим, какое сейчас покрытие кода.

pytest --cov earth/
Name                   Stmts   Miss  Cover
------------------------------------------
earth/__init__.py          3      0   100%
earth/adventurers.py      71      0   100%
earth/events.py           24      0   100%
earth/travel.py           31      3    90%
earth/year.py             14      0   100%
------------------------------------------
TOTAL                    143      3    98%

Круто, у нас покрытие кода 98% в проекте "earth"! Фантастика!

Следующие этапы

Мы выполнили задачу в части расширения покрытия кода с помощью автоматизированных тестов, но у нас еще есть работа!

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

Также, вы ведь заметили, что у нас три тестовых пакета (test cases), но если не учитывать метки (markers) и разные зафиксированные объекты (fixtures), сами по себе тестовые функции идентичны? Здесь много избыточного.

Хорошая новость: в pytest есть метка, которая решает как раз эту проблему. Тесты с параметрами (parametrized tests) и специальные плагины будут темой обсуждения в следующей части этой инструкции!

Надеюсь, вам понравилось изучать эту инструкцию так же сильно, как мне - выступать и писать первую часть этого поста. В следующей части этой инструкции мы с делаем нечто по-настоящему крутое с помощью pytest.