Содержание

Введение в тестирование кода с помощью pytest

Легкое введение в тестирование с pytest

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

Все вокруг assert

Предположим, что вы написали функцию, которая проверяет адрес электронной почты. Обратите внимание, что мы упрощаем задачу и не используем регулярные выражения или проверку DNS для проверки адресов электронной почты. Вместо этого мы просто убедимся, что в проверяемой строке есть только один знак @ и только латинские символы, цифры, а также символы ., - и _.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import string

def is_valid_email_address(s):
    s = s.lower()
    parts = s.split('@')
    if len(parts) != 2:
      # Not exactly one at-sign
      return False
    allowed = set(string.ascii_lowercase + string.digits + '.-_')
    for part in parts:
        if not set(part) <= allowed:
          # Characters other than the allowed ones are found
          return False
    return True

Теперь у нас есть некоторые утверждения в нашем коде. Например, мы утверждаем, что эти адреса электронной почты действительны:

С другой стороны, мы ожидаем, что наша функция вернет False для таких адресов электронной почты, как:

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

1
2
3
4
5
6
print(is_valid_email_address('[email protected]'))               # True
print(is_valid_email_address('[email protected]'))  # True
print(is_valid_email_address('[email protected]'))     # True
print(is_valid_email_address('not [email protected]'))          # False
print(is_valid_email_address('john.doe'))                       # False
print(is_valid_email_address('john,[email protected]'))           # False

Эти примеры адресов электронной почты, которые мы придумываем, называются тестовыми случаями. Для каждого тестового случая мы ожидаем определенный результат. Такой инструмент, как pytest, может помочь автоматизировать тестирование этих утверждений. Запись этих утверждений может помочь вам:

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def test_regular_email_validates():
    assert is_valid_email_address('[email protected]')
    assert is_valid_email_address('[email protected]')
    assert is_valid_email_address('[email protected]')

def test_valid_email_has_one_at_sign():
    assert not is_valid_email_address('john.doe')

def test_valid_email_has_only_allowed_chars():
    assert not is_valid_email_address('john,[email protected]')
    assert not is_valid_email_address('not [email protected]')

Проведение тестов

Простой пример

Итак, у нас есть два файла в каталоге нашего проекта: validator.py и test_validator.py.

Теперь мы можем просто запустить pytest из командной строки. Его вывод должен выглядеть примерно так:

1
2
3
4
5
6
7
8
======================= test session starts =========================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 3 items

test_validator.py ...                                          [100%]

======================== 3 passed in 0.01s ==========================

Здесь pytest сообщает нам, что он нашел три тестовые функции внутри test_validator.py и что все эти функции были пройдены (на что указывают три точки ...).

Индикатор 100% дает нам хорошее чувство, поскольку мы уверены, что наш валидатор работает так, как ожидалось. Однако, как было сказано во введении, функция валидатора далека от совершенства. Так же как и наши тестовые примеры. Даже без тестирования DNS мы бы отметили адрес электронной почты типа [email protected] как действительный, в то время как адрес типа [email protected] был бы отмечен как недействительный.

Давайте теперь добавим эти тестовые случаи в наш test_validator.py.

1
2
3
4
5
6
...
def test_valid_email_can_have_plus_sign():
    assert is_valid_email_address('[email protected]')

def test_valid_email_must_have_a_tld():
    assert not is_valid_email_address('[email protected]')

Если мы снова запустим pytest, то увидим неудачные тесты:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items

test_validator.py ...FF                                                  [100%]

=================================== FAILURES ===================================
_____________________ test_valid_email_can_have_plus_sign ______________________

    def test_valid_email_can_have_plus_sign():
>       assert is_valid_email_address('[email protected]')
E       AssertionError: assert False
E        +  where False = is_valid_email_address('[email protected]')

test_validator.py:17: AssertionError
_______________________test_valid_email_must_have_a_tld_______________________

    def test_valid_email_must_have_a_tld():
>       assert not is_valid_email_address('[email protected]')
E       AssertionError: assert not True
E        +  where True = is_valid_email_address('[email protected]')

test_validator.py:20: AssertionError
=========================== short test summary info ============================
FAILED test_validator.py::test_valid_email_can_have_plus_sign - AssertionErro...
FAILED test_validator.py::test_valid_email_must_have_a_tld - AssertionError: ...
========================= 2 failed, 3 passed in 0.05s ==========================

Обратите внимание, что мы получили два FF в дополнение к трем ... точкам, указывающим на то, что две тестовые функции не сработали.

Кроме того, мы получаем новый раздел FAILURES в выводе, который подробно объясняет, в какой момент наш тест потерпел неудачу. Это очень полезно для отладки.

Designing Tests

Наш небольшой пример с валидатором является подтверждением важности разработки тестов.

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

Возможно, вы слышали о Test Driven Development (TDD), которая выступает за прямо противоположное: Сначала нужно правильно сформулировать требования, написав тестовые примеры, и не приступать к реализации функции до того, как вы почувствуете, что охватили все тестовые случаи. Такой образ мышления всегда был хорошей идеей, но со временем он приобрел еще большее значение, поскольку сложность программных проектов возросла.

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

Конфигурация

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

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

Fixtures

Вы могли использовать термин фикстура в разных контекстах. Например, для Django web framework, фикстуры относятся к коллекции начальных данных, загружаемых в базу данных. Однако, в контексте pytest, фикстуры относятся только к функциям, выполняемым pytest до и/или после фактических тестовых функций.

Установка и снос

Мы можем создавать такие функции с помощью декоратора pytest.fixture(). Пока что мы делаем это внутри файла test_validator.py.

1
2
3
4
5
6
7
import pytest

@pytest.fixture()
def database_environment():
    setup_database()
    yield
    teardown_database()

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

Чтобы фикстура действительно использовалась одним из ваших тестов, вы просто добавляете имя фикстуры в качестве аргумента, как показано ниже (по-прежнему в test_validator.py):

1
2
def test_world(database_environment):
    assert 1 == 1

Getting Data from Fixtures

Вместо использования yield, функция приспособления может также возвращать произвольные значения:

1
2
3
4
5
import pytest

@pytest.fixture()
def my_fruit():
    return "apple"

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

1
2
def test_fruit(my_fruit):
    assert my_fruit == "apple"

Configuration Files

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

  • pytest.ini
  • tox.ini
  • setup.cfg

Какой файл использовать, зависит от того, какие другие инструменты вы можете использовать в своем проекте. Если вы упаковали свой проект, вам следует использовать файл setup.cfg. Если вы используете tox для тестирования вашего кода в различных средах, вы можете поместить конфигурацию pytest в файл tox.ini. Файл pytest.ini можно использовать, если вы не хотите использовать никаких дополнительных инструментов, кроме pytest.

Конфигурационный файл выглядит одинаково для каждого из этих трех типов файлов:

Using pytest.ini and tox.ini

1
2
[pytest]
addopts = ​-rsxX -l --tb=short --strict​

Если вы используете файл setup.cfg, единственное отличие заключается в том, что вы должны префикс секции [pytest] с tool:, как показано ниже:

1
2
[tool:pytest]
addopts = ​-rsxX -l --tb=short --strict​

conftest.py

Каждая папка с тестовыми файлами может содержать файл conftest.py, который читается pytest. Это хорошее место для размещения ваших пользовательских фикстур, так как они могут быть общими для разных тестовых файлов.

Файл(ы) conftest.py может(ют) изменять поведение pytest в зависимости от проекта.

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

CLI / PDB

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

Мы рассмотрим наиболее полезные опции командной строки.

Running Only One Test

Если вы хотите запустить только один конкретный тест, вы можете сослаться на него через файл test_, в котором он находится, и имя функции:

1
pytest test_validator.py::test_regular_email_validates

Collect Only

Иногда вы просто хотите иметь список тестовой коллекции, а не выполнять все тестовые функции.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
pytest --collect-only

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items

<Module test_validator.py>
  <Function test_regular_email_validates>
  <Function test_valid_email_has_one_at_sign>
  <Function test_valid_email_has_only_allowed_chars>
  <Function test_valid_email_can_have_plus_sign>
  <Function test_valid_email_must_have_a_tld>

========================== 5 tests collected in 0.01s ==========================

Exit on the first error

Вы можете заставить pytest прекратить выполнение последующих тестов после неудачного:

1
pytest -x

Run the last failed test only

Если вы хотите запустить только те тесты, которые не прошли в прошлый раз, вы можете сделать это с помощью флага --lf:

1
pytest --lf

Run all tests, but run the last failed ones first

1
pytest --ff

Show values of local variables in the output

Если мы создадим более сложную тестовую функцию с некоторыми локальными переменными, мы можем указать pytest отобразить эти локальные переменные с помощью флага -l.

Давайте перепишем нашу тестовую функцию следующим образом:

1
2
3
4
5
...
def test_valid_email_can_have_plus_sign():
    email = '[email protected]'
    assert is_valid_email_address('[email protected]')
...

Тогда,

1
pytest -l

даст нам такой результат:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example
collected 5 items

test_validator.py ...FF                                                  [100%]

=================================== FAILURES ===================================
_____________________ test_valid_email_can_have_plus_sign ______________________

    def test_valid_email_can_have_plus_sign():
        email = '[email protected]'
>       assert is_valid_email_address('[email protected]')
E       AssertionError: assert False
E        +  where False = is_valid_email_address('[email protected]')

email      = '[email protected]'

test_validator.py:18: AssertionError
_______________________test_valid_email_must_have_a_tld_______________________

    def test_valid_email_must_have_a_tld():
>       assert not is_valid_email_address('[email protected]')
E       AssertionError: assert not True
E        +  where True = is_valid_email_address('[email protected]')

test_validator.py:21: AssertionError
=========================== short test summary info ============================
FAILED test_validator.py::test_valid_email_can_have_plus_sign - AssertionErro...
FAILED test_validator.py::test_valid_email_must_have_a_tld - AssertionError: ...
========================= 2 failed, 3 passed in 0.09s ==========================

Using pytest with a debugger

Существует отладчик командной строки, называемый pdb, который встроен в Python. Вы можете использовать pytest для отладки кода вашей тестовой функции.

Если вы запустите pytest с --pdb, он запустит сессию отладки pdb сразу после того, как в вашем тесте возникнет исключение. В большинстве случаев это не очень полезно, поскольку вы можете захотеть проверить каждую строку кода, предшествующую возникшему исключению.

Другой вариант - флаг --trace для pytest, который будет устанавливать точку останова в первой строке каждой тестовой функции. Это может стать немного неудобным, если у вас много тестов. Поэтому для целей отладки хорошей комбинацией будет --lf --trace, которая запустит сессию отладки с pdb в начале последнего неудачного теста:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pytest --lf --trace

============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-7.0.1, pluggy-1.0.0
rootdir: /Users/bascodes/Code/blogworkspace/pytest-example, configfile: pytest.ini
collected 2 items
run-last-failure: rerun previous 2 failures

test_validator.py
>>>>>>>>>>>>>>>>>>>> PDB runcall (IO-capturing turned off) >>>>>>>>>>>>>>>>>>>>>
> /Users/bascodes/Code/blogworkspace/pytest-example/test_validator.py(17)test_valid_email_can_have_plus_sign()
-> email = '[email protected]'
(Pdb)

CI / CD

В современных программных проектах программное обеспечение разрабатывается в соответствии с принципами Test Driven Development и доставляется через конвейер непрерывной интеграции / непрерывного развертывания, включающий автоматизированное тестирование.

Типичная установка заключается в том, что коммиты в ветку main/master отклоняются, если не пройдены все тестовые функции.

Если вы хотите узнать больше об использовании pytest в среде CI/CD, оставайтесь с нами, так как я планирую новую статью на эту тему.

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

Официальная документация по pytest находится здесь: https://docs.pytest.org

Небольшая заметка:

Это перепост оригинальной статьи Баса Штайнса, сделанный с его разрешения. Посетите его сайт, чтобы почитать другие его статьи и/или подпишись на него в Twitter: @bascodes