Прочтите оригинальную статью в моем блоге

В этой второй части серии Hypermodern Python я собираюсь обсудить, как добавить автоматическое тестирование в ваш проект и как научить генератор случайных фактов иностранным языкам. ¹ Ранее мы обсуждали Как настроить Python проект". (Если вы начнете читать здесь, вы также можете «скачать код из предыдущей главы.)

Вот темы, затронутые в этой главе о тестировании в Python:

Вот полный список статей из этой серии:

У этого руководства есть дополнительный репозиторий: cjolowicz / hypermodern-python. Каждая статья в руководстве соответствует набору коммитов в репозитории GitHub:

Модульное тестирование с помощью pytest

Никогда не рано добавлять модульные тесты в проект.

Модульные тесты, как следует из названия, проверяют функциональность единицы кода, например отдельной функции или класса. В то время как фреймворк unittest является частью стандартной библиотеки Python, pytest стал чем-то вроде стандарта де-факто.

Давайте добавим этот пакет как зависимость для разработки, используя параметр Poetry --dev:

poetry add --dev pytest

Организуйте тесты в отдельной файловой иерархии рядом с src с именем tests:

.
├── src
└── tests
    ├── __init__.py
    └── test_console.py
2 directories, 2 files

Файл __init__.py пуст и служит для объявления набора тестов как пакета. Хотя в этом нет строгой необходимости, это позволяет вашему набору тестов отражать макет исходного кода тестируемого пакета, даже если модули в разных частях исходного дерева имеют одинаковое имя. Кроме того, он дает вам возможность импортировать модули из вашего пакета тестов.

Файл test_console.py содержит тестовый пример для модуля console, проверяющий, завершается ли программа с нулевым кодом состояния.

# tests/test_console.py
import click.testing
from hypermodern_python import console

def test_main_succeeds():
    runner = click.testing.CliRunner()
    result = runner.invoke(console.main)
    assert result.exit_code == 0

Click testing.CliRunner может вызывать интерфейс командной строки из тестового примера. Поскольку это может понадобиться большинству тестовых примеров в этом модуле, давайте превратим его в приспособление для тестирования. Тестовые инструменты - это простые функции, объявленные с помощью декоратора pytest.fixture. В тестовых примерах можно использовать инструмент тестирования, включив параметр функции с тем же именем, что и у инструмента тестирования.

# tests/test_console.py
import click.testing
import pytest
from hypermodern_python import console

@pytest.fixture
def runner():
    return click.testing.CliRunner()

def test_main_succeeds(runner):
    result = runner.invoke(console.main)
    assert result.exit_code == 0

Вызовите pytest, чтобы запустить набор тестов:

$ poetry run pytest
====================== test session starts =======================
platform linux -- Python 3.8.2, pytest-5.3.4, py-1.8.1, pluggy-0.13.0
rootdir: /hypermodern-python
collected 1 item
tests/test_console.py .                                                 [100%]
======================= 1 passed in 0.03s ========================

Покрытие кода с помощью Coverage.py

Покрытие кода - это мера степени, в которой исходный код вашей программы выполняется во время выполнения ее набора тестов. Покрытие кода программ Python можно определить с помощью инструмента под названием Coverage.py. Установите его вместе с плагином pytest-cov, который интегрирует Coverage.py с pytest:

poetry add --dev coverage[toml] pytest-cov

Вы можете настроить Coverage.py, используя pyproject.toml файл конфигурации, при условии, что он был установлен с toml extra, как показано выше. Обновите этот файл, чтобы сообщить инструменту имя вашего пакета и структуру дерева исходных текстов. Конфигурация также позволяет анализировать ответвления и отображать номера строк для отсутствующего покрытия:

# pyproject.toml
[tool.coverage.paths]
source = ["src", "*/site-packages"]
[tool.coverage.run]
branch = true
source = ["hypermodern_python"]
[tool.coverage.report]
show_missing = true

Чтобы включить отчеты о покрытии, вызовите pytest с параметром --cov:

$ poetry run pytest --cov
====================== test session starts =====================
platform linux -- Python 3.8.2, pytest-5.3.4, py-1.8.1, pluggy-0.13.0
rootdir: /hypermodern-python
plugins: cov-2.8.1
collected 1 item
tests/test_console.py .                                                 [100%]
------- coverage: platform linux, python 3.8.2-final-0 ---------
Name                                 Stmts   Miss Branch BrPart  Cover   Missing
----------------------------------------------------------------
src/hypermodern_python/__init__.py       1      0      0      0   100%
src/hypermodern_python/console.py        6      0      0      0   100%
----------------------------------------------------------------
TOTAL                                    7      0      0      0   100%
====================== 1 passed in 0.09s =======================

Сообщаемое покрытие кода составляет 100%. Это число не означает, что в вашем наборе тестов есть значимые тестовые примеры для всех видов использования и неправильного использования вашей программы. Покрытие кода только говорит вам, что все строки и ветки в вашей кодовой базе были поражены. (Фактически, в нашем тестовом примере было достигнуто полное покрытие без проверки функциональности программы, только ее статус выхода.)

Тем не менее стремление к 100% покрытию кода - хорошая практика, особенно для свежей кодовой базы. Все, что меньше этого, означает, что некоторая часть вашей кодовой базы определенно не протестирована. И процитирую Брюса Экеля: Если не протестировать, значит, он сломан. Позже мы увидим некоторые инструменты, которые помогут вам добиться обширного покрытия кода.

Вы можете настроить Coverage.py так, чтобы он требовал полного тестового покрытия (или любого другого целевого процента), используя опцию fail_under:

# pyproject.toml
[tool.coverage.report]
fail_under = 100

Автоматизация тестирования с помощью Nox

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

Установите Nox через pip или pipx:

pip install --user --upgrade nox

В отличие от tox, Nox использует для настройки стандартный файл Python:

# noxfile.py
import nox

@nox.session(python=["3.8", "3.7"])
def tests(session):
    session.run("poetry", "install", external=True)
    session.run("pytest", "--cov")

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

Nox создает виртуальные среды для перечисленных версий Python (3.8 и 3.7) и запускает сеанс внутри каждой среды:

$ nox
nox > Running session tests-3.8
nox > Creating virtual environment (virtualenv) using python3.8 in .nox/tests-3-8
nox > poetry install
...
nox > pytest --cov
...
nox > Session tests-3.8 was successful.
nox > Running session tests-3.7
nox > Creating virtual environment (virtualenv) using python3.7 in .nox/tests-3-7
nox > poetry install
...
nox > pytest --cov
...
nox > Session tests-3.7 was successful.
nox > Ran multiple sessions:
nox > * tests-3.8: success
nox > * tests-3.7: success

Nox воссоздает виртуальные среды с нуля при каждом вызове (разумное значение по умолчанию). Вы можете ускорить процесс, передав параметр - reuse-existing-virtualenvs (-r):

nox -r

Иногда вам нужно передать дополнительные параметры в pytest, например, чтобы выбрать определенные тестовые примеры. Измените сеанс, чтобы разрешить переопределение параметров, переданных в pytest, через переменную session.posargs:

# noxfile.py
import nox

@nox.session(python=["3.8", "3.7"])
def tests(session):
    args = session.posargs or ["--cov"]
    session.run("poetry", "install", external=True)
    session.run("pytest", *args)

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

nox -- tests/test_console.py

Мокинг с помощью pytest-mock

Модульные тесты должны быть быстрыми, изолированными и повторяемыми. Тест на console.main не является ни одним из этих:

  • Это не быстро, потому что для завершения требуется полный обход API Википедии и обратно.
  • Он не работает в изолированной среде, потому что он отправляет фактический запрос по сети.
  • Его нельзя повторить, потому что его результат зависит от работоспособности, доступности и поведения API. В частности, тест не проходит всякий раз, когда сеть не работает.

Стандартная библиотека unittest.mock позволяет заменять части тестируемой системы фиктивными объектами. Используйте его через плагин pytest-mock, который интегрирует библиотеку с pytest:

poetry add --dev pytest-mock

Плагин предоставляет mocker фикстуру, которая функционирует как тонкая оболочка для стандартной библиотеки имитации. Используйте mocker.patch, чтобы заменить функцию requests.get на фиктивный объект. Мок-объект будет полезен для любого тестового примера с использованием Wikipedia API, поэтому давайте создадим для него тестовое приспособление:

# tests/test_console.py
@pytest.fixture
def mock_requests_get(mocker):
    return mocker.patch("requests.get")

Добавьте приспособление к параметрам функции тестового примера:

def test_main_succeeds(runner, mock_requests_get):
    ...

Если вы запустите Nox сейчас, тест завершится неудачно, потому что click ожидает передачи строки для вывода на консоль, а вместо этого получает фиктивный объект. Недостаточно просто «выбить» requests.get. Мок-объект также должен возвращать что-то значимое, а именно ответ с допустимым объектом JSON.

Когда вызывается фиктивный объект или когда осуществляется доступ к атрибуту, он возвращает другой фиктивный объект. Иногда этого достаточно, чтобы пройти тестовый пример. Если это не так, вам необходимо настроить фиктивный объект. Чтобы настроить атрибут, вы просто устанавливаете для атрибута желаемое значение. Чтобы настроить возвращаемое значение при вызове фиктивного объекта, вы устанавливаете return_value для фиктивного объекта, как если бы он был атрибутом.

Давайте еще раз посмотрим на пример:

with requests.get(API_URL) as response:
    response.raise_for_status()
    data = response.json()

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

context = requests.get(API_URL)
response = context.__enter__()
try:
    response.raise_for_status()
    data = response.json()
finally:
    context.__exit__(...)

Итак, у вас есть, по сути, цепочка вызовов функций:

data = requests.get(API_URL).__enter__().json()

Перепишите прибор и отразите эту цепочку вызовов при настройке макета:

@pytest.fixture
def mock_requests_get(mocker):
    mock = mocker.patch("requests.get")
    mock.return_value.__enter__.return_value.json.return_value = {
        "title": "Lorem Ipsum",
        "extract": "Lorem ipsum dolor sit amet",
    }
    return mock

Снова вызовите Nox, чтобы убедиться, что набор тестов прошел успешно. 🎉

$ nox -r
...
nox > Ran multiple sessions:
nox > * tests-3.8: success
nox > * tests-3.7: success

Мокинг не только ускоряет набор тестов, но и позволяет выполнять взлом в автономном режиме в самолете или поезде. Благодаря наличию фиксированного или детерминированного возвращаемого значения макет также позволяет писать повторяемые тесты. Это означает, что вы можете, например, проверить, что заголовок, возвращаемый API, выводится на консоль:

def test_main_prints_title(runner, mock_requests_get):
    result = runner.invoke(console.main)
    assert "Lorem Ipsum" in result.output

Кроме того, макеты можно проверить на предмет их вызова с помощью атрибута called макета. Это дает вам возможность проверить, что requests.get был вызван для отправки запроса к API:

# tests/test_console.py
def test_main_invokes_requests_get(runner, mock_requests_get):
    runner.invoke(console.main)
    assert mock_requests_get.called

Мок-объекты также позволяют вам проверять аргументы, с которыми они были вызваны, используя атрибут call_args. Это позволяет вам проверить URL-адрес, переданный на requests.get:

# tests/test_console.py
def test_main_uses_en_wikipedia_org(runner, mock_requests_get):
    runner.invoke(console.main)
    args, _ = mock_requests_get.call_args
    assert "en.wikipedia.org" in args[0]

Вы можете настроить макет для создания исключения вместо возврата значения, назначив экземпляр или класс исключения атрибуту side_effect макета. Давайте проверим, что программа завершается с кодом состояния 1 при ошибках запроса:

# tests/test_console.py
def test_main_fails_on_request_error(runner, mock_requests_get):
    mock_requests_get.side_effect = Exception("Boom")
    result = runner.invoke(console.main)
    assert result.exit_code == 1

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

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

Пример интерфейса командной строки: рефакторинг

Хороший набор тестов хорош тем, что он позволяет вам реорганизовать код, не опасаясь его сломать. Давайте перенесем клиент Wikipedia в отдельный модуль. Создайте файл src/hypermodern-python/wikipedia.py со следующим содержанием:

# src/hypermodern-python/wikipedia.py
import requests

API_URL = "https://en.wikipedia.org/api/rest_v1/page/random/summary"

def random_page():
    with requests.get(API_URL) as response:
        response.raise_for_status()
        return response.json()

Модуль console теперь может просто вызывать wikipedia.random_page:

# src/hypermodern-python/console.py
import textwrap
import click
from . import __version__, wikipedia

@click.command()
@click.version_option(version=__version__)
def main():
    """The hypermodern Python project."""
    data = wikipedia.random_page()
    title = data["title"]
    extract = data["extract"]
    click.secho(title, fg="green")
    click.echo(textwrap.fill(extract))

Наконец, вызовите Nox, чтобы убедиться, что ничего не сломалось:

$ nox -r
...
nox > Ran multiple sessions:
nox > * tests-3.8: success
nox > * tests-3.7: success

Пример интерфейса командной строки: изящная обработка исключений

Если вы запустите пример приложения без подключения к Интернету, ваш терминал будет заполнен длинной трассировкой. Вот что происходит, когда интерпретатор Python завершается необработанным исключением. Для таких распространенных ошибок, как эта, было бы лучше вывести на экран дружественное информативное сообщение.

Давайте представим это как тестовый пример, настроив фиктивный объект на получение RequestException. (В библиотеке requests есть более конкретные классы исключений, но для целей этого примера мы будем иметь дело только с базовым классом.)

# tests/test_console.py
import requests

def test_main_prints_message_on_request_error(runner, mock_requests_get):
    mock_requests_get.side_effect = requests.RequestException
    result = runner.invoke(console.main)
    assert "Error" in result.output

Самый простой способ пройти этот тест - преобразовать RequestException в ClickException. Когда click встречает это исключение, он печатает сообщение об исключении для стандартной ошибки и выходит из программы с кодом состояния 1. Вы можете повторно использовать сообщение об исключении, преобразовав исходное исключение в строку.

Вот обновленный модуль wikipedia:

# src/hypermodern-python/wikipedia.py
import click
import requests

API_URL = "https://en.wikipedia.org/api/rest_v1/page/random/summary"

def random_page():
    try:
        with requests.get(API_URL) as response:
            response.raise_for_status()
            return response.json()
    except requests.RequestException as error:
        message = str(error)
        raise click.ClickException(message)

Пример интерфейса командной строки: выбор языковой версии Википедии

В этом разделе мы добавляем параметр командной строки для выбора языковой версии Википедии.

Редакции Википедии идентифицируются по коду языка, который используется в качестве поддомена ниже wikipedia.org. Обычно это двухбуквенный или трехбуквенный код языка, присвоенный языку стандартами ISO 639–1 и ISO 639–3. Вот некоторые примеры:

В качестве первого шага давайте добавим необязательный параметр для кода языка в функцию wikipedia.random_page. Когда передается альтернативный язык, запрос API должен быть отправлен в соответствующую версию Википедии. Тестовый пример помещается в новый тестовый модуль с именем test_wikipedia.py:

# tests/test_wikipedia.py
from hypermodern_python import wikipedia

def test_random_page_uses_given_language(mock_requests_get):
    wikipedia.random_page(language="de")
    args, _ = mock_requests_get.call_args
    assert "de.wikipedia.org" in args[0]

Приспособление mock_requests_get теперь используется двумя тестовыми модулями. Вы можете переместить его в отдельный модуль и импортировать оттуда, но Pytest предлагает более удобный способ: фикстуры, помещенные в файл conftest.py, обнаруживаются автоматически, и тестовые модули на том же уровне каталога могут использовать их без явного импорта. Создайте новый файл на верхнем уровне вашего пакета тестов и переместите туда приспособление:

# tests/conftest.py
import pytest

@pytest.fixture
def mock_requests_get(mocker):
    mock = mocker.patch("requests.get")
    mock.return_value.__enter__.return_value.json.return_value = {
        "title": "Lorem Ipsum",
        "extract": "Lorem ipsum dolor sit amet",
    }
    return mock

Чтобы пройти тест, мы превращаем API_URL в строку формата и интерполируем указанный код языка в URL-адрес с помощью str.format:

# src/hypermodern-python/wikipedia.py
import click
import requests

API_URL = "https://{language}.wikipedia.org/api/rest_v1/page/random/summary"

def random_page(language="en"):
    url = API_URL.format(language=language)
    try:
        with requests.get(url) as response:
            response.raise_for_status()
            return response.json()
    except requests.RequestException as error:
        message = str(error)
        raise click.ClickException(message)

На втором этапе мы делаем новые функции доступными из командной строки, добавляя параметр --language. Тестовый пример имитирует функцию wikipedia.random_page и использует метод assert_called_with на макете, чтобы проверить, что язык, указанный пользователем, передается функции:

# tests/test_console.py
@pytest.fixture
def mock_wikipedia_random_page(mocker):
    return mocker.patch("hypermodern_python.wikipedia.random_page")

def test_main_uses_specified_language(runner, mock_wikipedia_random_page):
    runner.invoke(console.main, ["--language=pl"])
    mock_wikipedia_random_page.assert_called_with(language="pl")

Теперь мы готовы реализовать новую функциональность с помощью декоратора click.option. Без лишних слов, вот последняя версия модуля console:

# src/hypermodern-python/console.py
import textwrap
import click
from . import __version__, wikipedia

@click.command()
@click.option(
    "--language",
    "-l",
    default="en",
    help="Language edition of Wikipedia",
    metavar="LANG",
    show_default=True,
)
@click.version_option(version=__version__)
def main(language):
    """The hypermodern Python project."""
    data = wikipedia.random_page(language=language)
    title = data["title"]
    extract = data["extract"]
    click.secho(title, fg="green")
    click.echo(textwrap.fill(extract))

Теперь у вас есть многоязычный генератор случайных фактов и интересный способ проверить свои языковые навыки (и навыки Unicode вашего эмулятора терминала).

Использование подделок

Моки помогают тестировать блоки кода в зависимости от громоздких подсистем, но это не единственный метод для этого. Например, если ваша функция требует подключения к базе данных, может быть проще и эффективнее передать базу данных в памяти, чем фиктивный объект. Поддельные реализации - хорошая альтернатива имитирующим объектам, которые могут быть слишком снисходительными, когда сталкиваются с неправильным использованием, и слишком тесно связаны с деталями реализации тестируемой системы (обратите внимание на приспособление mock_requests_get). Большие объекты данных могут быть сгенерированы фабриками тестовых объектов вместо того, чтобы быть замененными имитирующими объектами (посмотрите отличный пакет factoryboy).

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

class FakeAPI:
    url = "http://localhost:5000/"
    @classmethod
    def create(cls):
        ...
    
    def shutdown(self):
        ...

Следующее не сработает:

@pytest.fixture
def fake_api():
    return FakeAPI.create()

После использования API необходимо выключить, чтобы освободить такие ресурсы, как порт TCP и поток, на котором запущен сервер. Вы можете сделать это, написав прибор как генератор:

@pytest.fixture
def fake_api():
    api = FakeAPI.create()
    yield api
    api.shutdown()

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

@pytest.fixture(scope="session")
def fake_api():
    api = FakeAPI.create()
    yield api
    api.shutdown()

Сквозное тестирование

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

Давайте восстановим исходный тестовый пример и воспользуемся маркерами Pytest, чтобы применить пользовательскую метку. Это позволит вам выбрать или пропустить их позже, используя параметр Pytest -m.

# tests/test_console.py
@pytest.mark.e2e
def test_main_succeeds_in_production_env(runner):
    result = runner.invoke(console.main)
    assert result.exit_code == 0

Зарегистрируйте маркер e2e с помощью крючка pytest_configure, как показано ниже. Ловушка помещается в файл conftest.py на верхнем уровне вашего пакета тестов. Это гарантирует, что Pytest сможет обнаружить модуль и использовать его для всего набора тестов.

# tests/conftest.py
def pytest_configure(config):
    config.addinivalue_line("markers", "e2e: mark as end-to-end test.")

Наконец, исключите сквозные тесты из автоматического тестирования, передав -m "not e2e" в Pytest:

# noxfile.py
import nox

@nox.session(python=["3.8", "3.7"])
def tests(session):
    args = session.posargs or ["--cov", "-m", "not e2e"]
    session.run("poetry", "install", external=True)
    session.run("pytest", *args)

Теперь вы можете запускать сквозные тесты, передав -m e2e в сеанс Nox, используя двойной дефис (--), чтобы отделить их от собственных параметров Nox. Например, вот как вы бы запускали сквозные тесты внутри тестовой среды для Python 3.8:

nox -rs tests-3.8 -- -m e2e

Спасибо за прочтение!

Следующая глава - о линтинге вашего проекта. Он будет опубликован 15 января 2020 года.

  1. Изображения в этой главе взяты из иллюстраций Эмиля-Антуана Баяра к фильму С Земли на Луну (De la terre à la lune) Жюля Верна (1870 г.) (источник: Internet Archive через The Обзор общественного достояния ).