Книга: FastAPI: веб-разработка на Python
Назад: Глава 11. Аутентификация и авторизация
Дальше: Глава 13. Запуск в эксплуатацию

Глава 12. Тестирование

Инженер по контролю качества заходит в бар. Заказывает пиво. Заказывает 0 пива. Заказывает 99 999 999 999 пива. Заказывает ящерицу. Заказывает –1 пива. Заказывает ueicbksjdhd.

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

Бренан Келлер (Twitter)

Обзор

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

Тестирование Web API

Вы уже видели несколько инструментов для тестирования API вручную по мере добавления конечных точек:

• HTTPie;

• Requests;

• HTTPX;

• браузер.

Существует и множество других инструментов для тестирования.

• Инструмент Curl (https://curl.se) очень хорошо известен, хотя в этой книге я использовал HTTPie, чтобы синтаксис был проще.

• Служба Httpbin (http://httpbin.org) написана автором библиотеки Requests. Она представляет собой бесплатный тестовый сервер, дающий возможность изучить множество представлений о вашем HTTP-запросе.

• Postman (https://www.postman.com) — полноценная платформа для тестирования API.

• Chrome DevTools (https://oreil.ly/eUK_R) — это богатый набор инструментов, входящий в состав браузера Chrome.

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

Но что, если внесенное позже изменение нарушит один из этих ранних тестов, выполняемых вручную (регрессия)? Вы же не хотите повторять десятки тестов после каждого изменения кода. Именно поэтому большое значение приобретают автоматизированные тесты. Остальная часть этой главы посвящена им и тому, как создавать их с помощью pytest.

Где тестировать

Я уже называл разновидности тестов:

модульные — внутри уровня, тестируются отдельные функции;

• интеграционные — различные уровни, тестирование взаимосвязей;

полные — тестирование полного API и стека под ним.

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

Рис. 12.1. Пирамида тестирования

Что тестировать

Что нужно тестировать в процессе написания кода? По сути, необходимо подтвердить, что при заданных входных данных вы получите правильные выходные данные.

Можно проверить следующие моменты:

• ошибочный ввод;

• дублирование ввода;

• неправильные типы ввода;

• неправильный порядок ввода;

• недопустимые значения ввода;

• огромные входные и выходные массивы данных.

Ошибки могут возникнуть где угодно.

Веб-уровень — Pydantic отловит любое несоответствие модели и вернет код состояния HTTP 422.

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

Любой уровень — могут быть допущены обычные ошибки и недочеты.

Главы 8–10 содержат некоторые из этих тестов:

полное тестирование вручную с использованием таких инструментов, как HTTPie;

• модульное тестирование вручную в виде фрагментов Python;

автоматизированные тесты с помощью скриптов pytest.

В следующих нескольких разделах мы подробно рассмотрим pytest.

Pytest

В Python уже давно существует стандартный пакет unittest (https://oreil.ly/3u0M_). Более поздний пакет стороннего производителя под названием nose (https://nose.readthedocs.io) представляет собой попытку улучшить его. Большинство разработчиков Python сейчас предпочитают фреймворк pytest (https://docs.pytest.org), который выполняет больше задач, чем любой из перечисленных ранее, и более прост в использовании. Он не встроен в Python, поэтому при необходимости нужно будет выполнить команду pip install pytest. Также запустите команду pip install pytestmock, чтобы получить автоматическую фикстуру mocker — вы увидите ее позже в этой главе.

Что предлагает pytest? Приятные автоматические функции включают следующее.

Обнаружение тестирования — тест для файла Python с префиксом test_ или суффиксом _test в имени будет запущен автоматически. Это сопоставление имен файлов переходит в подкаталоги, выполняя столько тестов, сколько в них содержится.

• Подробности отказа с конструкцией Assert — оператор выявления ошибок assert выводит то, что ожидалось, и то, что произошло на самом деле.

• Фикстуры — эти функции могут запускаться один раз для всего тестового скрипта или выполняться для каждого теста (его области видимости), предоставляя тестовым функциям такие параметры, как стандартные тестовые данные или инициализация базы данных. Фикстуры — это своего рода внедрение зависимости, подобное предлагаемому FastAPI для функций пути веб-приложения, — конкретные данные передаются общей тестовой функции.

Параметризация — обеспечивает несколько тестовых данных для тестовой функции.

Макет

Где разместить тесты? Похоже, что единого мнения нет, но вот два разумных варианта:

• каталог test на верхнем уровне с подкаталогами для тестируемой области кода, например web, service и т.д.;

• каталог test под каждым каталогом кода, например web, service и т.д.

Кроме того, в рамках конкретного подкаталога test/web следует создать дополнительные каталоги для различных типов тестов — модульных, интеграционных и полных. В книге я использую такую иерархию:

test

├── unit

│   ├── web

│   ├── service

│   └── data

├── integration

└── full

Отдельные тестовые скрипты находятся в каталогах нижнего уровня. Они рассматриваются в этой главе.

Автоматизированные модульные тесты

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

Модульные тесты требуют изоляции тестируемого кода. Если ее нет, значит, вы тестируете и еще какую-то часть кода. Итак, как же изолировать код для модульных тестов?

Макетирование

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

Модульное тестирование на первый взгляд выглядит просто. Для каждой функции, имеющейся в вашей кодовой базе, передаются тестовые аргументы, и требуется убедиться, что она возвращает ожидаемые значения. Это хорошо работает для чистой функции, принимающей входные аргументы и возвращающей ответы без обращения к какому-либо внешнему коду. Но большинство функций также вызывают другие функции. Каким же образом можно контролировать то, что делают другие функции? А как насчет данных, поступающих из внешних источников? Наиболее распространенным внешним фактором, требующим контроля, является доступ к базе данных, но на самом деле это может быть что угодно.

Один из методов заключается в том, чтобы создавать макет каждого вызова внешней функции. Поскольку функции в Python являются объектами первого класса, можно одну функцию заменить другой. В пакете unittest есть модуль mock, он выполняет эту операцию.

Многие разработчики считают, что макетирование — это лучший способ изолировать модульные тесты. Сначала я покажу примеры макетирования, а также приведу аргумент, что часто макеты требуют слишком много знаний о том, как работает ваш код, а не о результатах. Вам могут быть знакомы термины «структурное тестирование» (как в макетировании, где тестируемый код вполне нагляден) и «поведенческое тестирование» (внутренняя структура кода не нужна). Примеры 12.1 и 12.2 определяют модули mod1.py и mod2.py соответственно.

Пример 12.1. Вызываемый модуль (mod1.py)

def preamble() -> str:

    return "The sum is "

Пример 12.2. Вызывающий модуль (mod2.py)

import mod1

def summer(x: int, y:int) -> str:

    return mod1.preamble() + f"{x+y}"

Функция summer() вычисляет сумму своих аргументов и возвращает строку с результатом функции preamble и суммой. Пример 12.3 представляет собой минимальный скрипт pytest для проверки функции summer().

Пример 12.3. Скрипт Pytest test_summer1.py

import mod2

def test_summer():

    assert "The sum is 11" == mod2.summer(5,6)

В примере 12.4 тест выполняется успешно.

Пример 12.4. Запуск скрипта pytest

$ pytest -q test_summer1.py

.                                                                  [100%]

1 passed in 0.04s

(Аргумент -q выполняет тест тихо, не выводя лишних деталей.) Хорошо, он оказался успешным. Но функция summer() получила текст из функции preamble. А если нам просто требуется проверить, что добавление прошло успешно?

Можно написать новую функцию, возвращающую строковую сумму двух чисел, а затем переписать функцию summer(), чтобы она возвращала эту сумму, добавленную к строке в preamble(). Или можно создать макет функции preamble(), чтобы убрать ее влияние, как показано в примере 12.5.

Пример 12.5. Скрипт Pytest с макетом (test_summer2.py)

from unittest import mock

import mod1

import mod2

def test_summer_a():

    with mock.patch("mod1.preamble", return_value=""):

        assert "11" == mod2.summer(5,6)

def test_summer_b():

    with mock.patch("mod1.preamble") as mock_preamble:

        mock_preamble.return_value=""

        assert "11" == mod2.summer(5,6)

@mock.patch("mod1.preamble", return_value="")

def test_summer_c(mock_preamble):

    assert "11" == mod2.summer(5,6)

@mock.patch("mod1.preamble")

def test_caller_d(mock_preamble):

    mock_preamble.return_value = ""

    assert "11" == mod2.summer(5,6)

Эти тесты показывают, что макеты можно создавать более чем одним способом. Функция test_caller_a() использует mock.patch() в качестве менеджера контекста Python (оператор with). Его аргументы приведены далее:

"mod1.preamble" — полное строковое имя функции preamble() в модуле mod1;

return_value="" — указывает макетированной версии возвращать пустую строку.

Функция test_caller_b() представляет собой почти то же самое, но добавляет выражение as mock_preamble, чтобы использовать объект макета в следующей строке.

Функция test_caller_c() определяет макет с помощью декоратора Python. Объект макета передается в качестве аргумента в функции test_caller2().

Функция test_caller_d() подобна test_caller_b() и задает аргумент return_value в отдельном вызове к mock_preamble.

В каждом случае строковое имя объекта макета должно совпадать с тем, как он вызывается в тестируемом коде, — в данном случае summer(). Библиотека макетов преобразует это строковое имя в переменную, перехватывающую все ссылки на исходную переменную с таким именем. (Помните, что в Python переменные — это просто ссылки на реальные объекты.)

Таким образом, в примере 12.6 во всех четырех тестовых функциях summer() при вызове summer(5,6) вместо настоящей функции вызывается изменяющийся макет preamble(). В макетированной версии эта строка отбрасывается, поэтому тест может убедиться, что функция summer() возвращает строковую версию суммы двух своих аргументов.

Пример 12.6. Запуск макетированного скрипта pytest

$ pytest -q test_summer2.py

....                                                                 [100%]

4 passed in 0.13s

Это был выдуманный случай, для простоты. Макетирование может быть довольно сложным. Наглядные примеры можно изучить в таких статьях, как Understanding the Python Mock Object Library Алекса Ронкильо (https://oreil.ly/I0bkd). А пугающие подробности есть в официальной документации Python (https://oreil.ly/hN9lZ).

Тестовые дублеры и фиктивные объекты

Чтобы выполнить макетирование, вам нужно знать, что функция summer() импортирует функцию preamble() из модуля mod1. Это был структурный тест, требующий знания специфических имен переменных и модулей.

Есть ли способ провести поведенческий тест, в котором это не требуется?

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

В первую очередь переопределите файл mod2.py (пример 12.7).

Пример 12.7. Укажите файлу mod2.py импортировать дублер при модульном тестировании

import os

if os.get_env("UNIT_TEST"):

    import fake_mod1 as mod1

else:

    import mod1

def summer(x: int, y:int) -> str:

    return mod1.preamble() + f"{x+y}"

Пример 12.8 определяет этот модуль-двойник fake_mod1.py.

Пример 12.8. Дублер fake_mod1.py

def preamble() -> str:

    return ""

А пример 12.9 представляет собой тест…

Пример 12.9. Тестовый скрипт test_summer_fake.py

import os

os.environ["UNIT_TEST"] = "true"

import mod2

def test_summer_fake():

    assert "11" == mod2.summer(5,6)

…Запускаемый примером 12.10.

Пример 12.10. Запуск нового модульного теста

$ pytest -q test_summer_fake.py

.                                                                  [100%]

1 passed in 0.04s

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

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

Веб-уровень

Этот уровень реализует API сайта. В идеале для каждой функции пути (конечной точки) должен быть как минимум один тест, а то и больше, если функция может не сработать более чем одним способом. На веб-уровне обычно требуется проверить, существует ли конечная точка, работает ли она с правильными параметрами и возвращает ли правильный код состояния и данные.

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

Используя идею с реализацией конструкции import из предыдущего раздела, задействуйте переменную окружения CRYPTID_UNIT_TEST, чтобы импортировать фиктивный пакет, такой как service, вместо настоящего service. Это не позволяет веб-функциям вызывать сервисные функции, а вместо этого замыкает их на фиктивную (дублированную) версию. Тогда нижний уровень данных и база данных также не будут задействованы. Мы получаем то, что хотим, — модульные тесты. В примере 12.11 представлен измененный файл web/creature.py.

Пример 12.11. Измененный файл web/creature.py

import os

from fastapi import APIRouter, HTTPException

from model.creature import Creature

if os.getenv("CRYPTID_UNIT_TEST"):

    from fake import creature as service

else:

    from service import creature as service

from error import Missing, Duplicate

router = APIRouter(prefix = "/creature")

@router.get("/")

def get_all() -> list[Creature]:

    return service.get_all()

@router.get("/{name}")

def get_one(name) -> Creature:

    try:

        return service.get_one(name)

    except Missing as exc:

        raise HTTPException(status_code=404, detail=exc.msg)

@router.post("/", status_code=201)

def create(creature: Creature) -> Creature:

    try:

        return service.create(creature)

    except Duplicate as exc:

        raise HTTPException(status_code=409, detail=exc.msg)

@router.patch("/")

def modify(name: str, creature: Creature) -> Creature:

    try:

        return service.modify(name, creature)

    except Missing as exc:

        raise HTTPException(status_code=404, detail=exc.msg)

@router.delete("/{name}")

def delete(name: str) -> None:

    try:

        return service.delete(name)

    except Missing as exc:

        raise HTTPException(status_code=404, detail=exc.msg)

В примере 12.12 приведены тесты, использующие две фикстуры pytest:

sample() — новый объект Creature;

fakes() — список существ.

Фиктивные данные получаются из модуля нижнего уровня. Установив переменную окружения CRYPTID_UNIT_TEST, веб-модуль из примера 12.11 импортирует фиктивную версию сервиса (предоставляющую фиктивные данные, а не вызывающую базу данных) вместо настоящей. Это позволяет изолировать тесты, в чем и заключается смысл.

Пример 12.12. Модульные тесты веб-уровня для существ с использованием фикстур

from fastapi import HTTPException

import pytest

import os

os.environ["CRYPTID_UNIT_TEST"] = "true"

from model.creature import Creature

from web import creature

@pytest.fixture

def sample() -> Creature:

    return Creature(name="dragon",

        description="Wings! Fire! Aieee!",

        country="*")

@pytest.fixture

def fakes() -> list[Creature]:

    return creature.get_all()

def assert_duplicate(exc):

    assert exc.value.status_code == 404

    assert "Duplicate" in exc.value.msg

def assert_missing(exc):

    assert exc.value.status_code == 404

    assert "Missing" in exc.value.msg

def test_create(sample):

    assert creature.create(sample) == sample

def test_create_duplicate(fakes):

    with pytest.raises(HTTPException) as exc:

        _ = creature.create(fakes[0])

        assert_duplicate(exc)

def test_get_one(fakes):

    assert creature.get_one(fakes[0].name) == fakes[0]

def test_get_one_missing():

    with pytest.raises(HTTPException) as exc:

        _ = creature.get_one("bobcat")

        assert_missing(exc)

def test_modify(fakes):

    assert creature.modify(fakes[0].name, fakes[0]) == fakes[0]

def test_modify_missing(sample):

    with pytest.raises(HTTPException) as exc:

        _ = creature.modify(sample.name, sample)

        assert_missing(exc)

def test_delete(fakes):

    assert creature.delete(fakes[0].name) is None

def test_delete_missing(sample):

    with pytest.raises(HTTPException) as exc:

        _ = creature.delete("emu")

        assert_missing(exc)

Сервисный уровень

В некотором смысле сервисный уровень самый важный и может быть связан с различными уровнями данных и веб-уровнями. Пример 12.13 похож на пример 12.11, отличаясь в основном инструкцией import и использованием модуля данных нижнего уровня. Также он не перехватывает исключения, возникающие на уровне данных, оставляя их для обработки на веб-уровне.

Пример 12.13. Измененный файл service/creature.py

import os

from model.creature import Creature

if os.getenv("CRYPTID_UNIT_TEST"):

    from fake import creature as data

else:

    from data import creature as data

def get_all() -> list[Creature]:

    return data.get_all()

def get_one(name) -> Creature:

    return data.get_one(name)

def create(creature: Creature) -> Creature:

    return data.create(creature)

def modify(name: str, creature: Creature) -> Creature:

    return data.modify(name, creature)

def delete(name: str) -> None:

    return data.delete(name)

В примере 12.14 приведены соответствующие модульные тесты.

Пример 12.14. Сервисный тест в файле test/unit/service/test_creature.py

import os

os.environ["CRYPTID_UNIT_TEST"]= "true"

import pytest

from model.creature import Creature

from error import Missing, Duplicate

from data import creature as data

@pytest.fixture

def sample() -> Creature:

    return Creature(name="yeti",

        aka:"Abominable Snowman",

        country="CN",

        area="Himalayas",

        description="Handsome Himalayan")

def test_create(sample):

    resp = data.create(sample)

    assert resp == sample

def test_create_duplicate(sample):

    resp = data.create(sample)

    assert resp == sample

    with pytest.raises(Duplicate):

        resp = data.create(sample)

def test_get_exists(sample):

    resp = data.create(sample)

    assert resp == sample

    resp = data.get_one(sample.name)

    assert resp == sample

def test_get_missing():

    with pytest.raises(Missing):

        _ = data.get_one("boxturtle")

def test_modify(sample):

    sample.country = "CA" # Canada!

    resp = data.modify(sample.name, sample)

    assert resp == sample

def test_modify_missing():

    bob: Creature = Creature(name="bob", country="US", area="*",

        description="some guy", aka="??")

    with pytest.raises(Missing):

        _ = data.modify(bob.name, bob)

Уровень данных

Уровень данных проще тестировать изолированно, поскольку можно не беспокоиться о случайном вызове функции на еще более низком уровне. Модульные тесты должны охватывать как функции этого уровня, так и конкретные используемые запросы к базе данных. До сих пор SQLite был «сервером» баз данных, а SQL — языком запросов. Но вы можете решить работать с таким пакетом, как SQLAlchemy, и задействовать его возможности в виде SQLAlchemy Expression Language или ORM. Тогда потребуется полное тестирование. Пока что я придерживаюсь самого низкого уровня — DB-API Python и обычные SQL-запросы.

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

Пример 12.15. Модульные тесты данных для файла data/creature.py

import os

import pytest

from model.creature import Creature

from error import Missing, Duplicate

# Установите этот параметр перед импортом данных

os.environ["CRYPTID_SQLITE_DB"] = ":memory:"

from data import creature

@pytest.fixture

def sample() -> Creature:

    return Creature(name="yeti",

        aka="Abominable Snowman",

        country="CN",

        area="Himalayas",

        description="Hapless Himalayan")

def test_create(sample):

    resp = creature.create(sample)

    assert resp == sample

def test_create_duplicate(sample):

    with pytest.raises(Duplicate):

        _ = creature.create(sample)

def test_get_one(sample):

    resp = creature.get_one(sample.name)

    assert resp == sample

def test_get_one_missing():

    with pytest.raises(Missing):

        resp = creature.get_one("boxturtle")

def test_modify(sample):

    creature.country = "JP" # Япония!

    resp = creature.modify(sample.name, sample)

    assert resp == sample

def test_modify_missing():

    thing: Creature = Creature(name="snurfle",

        description="some thing", country="somewhere")

    with pytest.raises(Missing):

        _ = creature.modify(thing.name, thing)

def test_delete(sample):

    resp = creature.delete(sample.name)

    assert resp is None

def test_delete_missing(sample):

    with pytest.raises(Missing):

        _ = creature.delete(sample.name)

Автоматизированные интеграционные тесты

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

Чтобы полностью протестировать каждое соединение в конвейере A Б В, потребуется проверить следующие комбинации:

• А Б;

• Б В;

• А В.

А у вас будет полный колчан таких стрелок, если в системе более трех пересечений. Или интеграционные тесты должны быть по сути сквозными тестами, но с макетом самой последней части — хранения данных на диске?

До сих пор вы использовали SQLite в качестве базы данных и можете задействовать режим работы SQLite In-Memory в качестве дублера (подделки) для базы данных SQLite на диске. Если ваши запросы представляют собой стандартный SQL, то SQLite-In-Memory может быть подходящим вариантом макета и для других баз данных. Если нет, то для создания макетов конкретных баз данных адаптированы следующие модули:

PostgreSQL — pgmock (https://pgmock.readthedocs.io);

• MongoDB — Mongomock (https://github.com/mongomock/mongomock);

множество ресурсов Pytest Mock (https://pytest-mock-resources.readthedocs.io), которые запускают различные тестовые базы данных в контейнерах Docker, и они интегрированы с pytest.

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

Паттерн «Репозиторий»

Хотя я не использовал его в этой книге, но паттерн «Репозиторий» (https://oreil.ly/3JMKH) представляет собой интересный подход. Репозиторий — это простое промежуточное хранилище данных в оперативной памяти, подобное представленному ранее уровню фиктивных данных. Затем он связывается с подключаемыми бэкендами для реальных баз данных. Репозиторий сопровождается паттерном Unit of Work (https://oreil.ly/jHGV8), гарантирующим, что либо будет зафиксирована группа операций в одной сессии, либо выполнен откат, как для единого фрагмента.

До сих пор запросы к базе данных в этой книге были атомарными. На практике для работы с базами данных вам могут понадобиться многоэтапные запросы и определенная обработка сессий. Паттерн «Репозиторий» сочетается также с внедрением зависимостей (https://oreil.ly/0f0Q3), с которым вы уже сталкивались в других частях этой книги и которое, вероятно, уже немного оценили.

Автоматизированные полные тесты

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

Вы можете полностью протестировать каждую конечную точку в общем API двумя способами.

С помощью HTTP/HTTPS напишите отдельные обращающиеся к серверу тестовые клиенты Python. Во многих примерах, приведенных в книге, это сделано с помощью автономных клиентов, таких как HTTPie, или в скриптах, использующих Requests.

С помощью TestClient примените встроенный объект FastAPI/Starlette для получения прямого доступа к серверу, без открытого TCP-соединения.

Однако эти подходы требуют написания одного или нескольких тестов для каждой конечной точки. Это может превратиться в Средневековье, а мы уже на несколько веков ушли вперед. Более современный подход основан на тестировании на основе свойств (Property-Based Testing, PBT). При этом используется преимущество автоматически генерируемой документации FastAPI. Схема OpenAPI под названием openapi.json создается FastAPI каждый раз, когда вы изменяете функцию пути или декоратор пути на веб-уровне. В этой схеме подробно описано все о каждой конечной точке — аргументы, возвращаемые значения и т.д. Для этого и существует спецификация OpenAPI (OpenAPI Specification, OAS), приведенная на странице OpenAPI Initiative’s FAQ (https://www.openapis.org/faq): «OAS определяет стандартное, не зависящее от языка программирования описание интерфейса для REST API, которое позволяет людям и компьютерам обнаружить и понять возможности сервиса, не требуя доступа к исходному коду, дополнительной документации или изучения сетевого трафика».

Для работы потребуется два пакета:

• Hypothesis (https://hypothesis.works) — pip install hypothesis;

• Schemathesis (https://schemathesis.readthedocs.io) — pip install schemathesis.

Hypothesis — это базовая библиотека, а Schemathesis применяет ее к схеме OpenAPI 3.0, которую генерирует FastAPI. При запуске инструмент Schemathesis считывает эту схему, генерирует множество тестов с различными данными (и вам не нужно их придумывать!) и работает с pytest.

Чтобы не затягивать, в примере 12.16 сначала сократим код в файле main.py до базовых конечных точек существа и исследователя — creature и explorer.

Пример 12.16. Основа файла main.py

from fastapi import FastAPI

from web import explorer, creature

app = FastAPI()

app.include_router(explorer.router)

app.include_router(creature.router)

В примере 12.17 выполняются тесты.

Пример 12.17. Запуск тестов Schemathesis

$ schemathesis http://localhost:8000/openapi.json

===================== Schemathesis test session starts =====================

Schema location: http://localhost:8000/openapi.json

Base URL: http://localhost:8000/

Specification version: Open API 3.0.2

Workers: 1

Collected API operations: 12

GET /explorer/ .                                                      [  8%]

POST /explorer/ .                                                     [ 16%]

PATCH /explorer/ F                                                    [ 25%]

GET /explorer .                                                       [ 33%]

POST /explorer .                                                      [ 41%]

GET /explorer/{name} .                                                [ 50%]

DELETE /explorer/{name} .                                             [ 58%]

GET /creature/ .                                                      [ 66%]

POST /creature/ .                                                     [ 75%]

PATCH /creature/ F                                                    [ 83%]

GET /creature/{name} .                                                [ 91%]

DELETE /creature/{name} .                                             [100%]

Я получил две пометки F, обе при вызове PATCH (функций modify()). Как же неприятно.

За этой секцией вывода следует секция с пометкой FAILURES (отказы), содержащая подробные трассировки стека всех тестов, завершившихся неудачей. Их необходимо исправить. Заключительный раздел обозначен как SUMMARY (краткое описание):

Performed checks:

    not_a_server_error                    717 / 727 passed         FAILED

Hint: You can visualize test results in Schemathesis.io

by using `--report` in your CLI command.

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

Это еще одно неожиданное преимущество подсказок типов, которые поначалу казались просто приятными вещами: подсказки типа схема OpenAPI сгенерированная документация и тесты.

Тестирование безопасности

Безопасность — это не что-то одно, а все. Вам нужно защищаться не только от злого умысла, но и от обычных ошибок, и даже от неподвластных вам событий. Отложим вопросы масштабирования до следующего раздела, а здесь займемся в основном анализом потенциальных угроз.

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

Но теперь, когда вы знаете о Schemathesis, прочитайте его документацию (https://oreil.ly/v_O-Q) о тестировании на основе свойств для аутентификации. Так же как он значительно упростил тестирование большей части API, он может автоматизировать бо́льшую часть тестов для конечных точек, требующих аутентификации.

Нагрузочное тестирование

То, как ваше приложение справляется с большим трафиком, показывают нагрузочные тесты, проверяющие:

• вызовы API;

• чтение или запись в базу данных;

• использование памяти;

• использование дискового пространства;

• время ожидания и пропускную способность сети.

Некоторые из них могут представлять собой сквозные тесты, имитирующие армию пользователей, жаждущих воспользоваться вашим сервисом. Вам стоит быть готовыми к тому, что такой день наступит. Содержание этого раздела частично совпадает с содержанием разделов «Производительность» и «Устранение неполадок» главы 13.

Существует много хороших нагрузочных тестеров, но здесь я рекомендую инструмент под названием Locust (https://locust.io). При его использовании можно определить все тесты с помощью обычных скриптов на языке Python. Он может имитировать работу сотен тысяч пользователей, одновременно запрашивающих ваш сайт или даже несколько серверов.

Установите его локально с помощью команды pip install locust. Первым тестом может стать проверка возможного количества единовременных посетителей вашего сайта. Это похоже на проверку того, насколько экстремальные погодные условия может выдержать здание во время урагана, землетрясения, снежной бури или наступления другого страхового случая. Поэтому вам нужны структурные тесты сайта. В документации (https://docs.locust.io) Locust можно найти более подробную информацию.

Но, как говорят по телевизору, это еще не все! Недавно разработчики инструмента Grasshopper (https://github.com/alteryx/locust-grasshopper) расширили возможности Locust для выполнения таких задач, как измерение времени в нескольких HTTP-вызовах. Чтобы опробовать это расширение, установите его с помощью команды pip install locust-grasshopper.

Заключение

В этой главе были подробно рассмотрены типы тестирования, приведены примеры выполнения pytest автоматизированного тестирования кода на уровне модулей, интеграции и полного тестирования. Тесты API можно автоматизировать с помощью инструмента Schemathesis. Здесь также обсуждалось, как выявить проблемы безопасности и производительности до того, как они возникнут.

Назад: Глава 11. Аутентификация и авторизация
Дальше: Глава 13. Запуск в эксплуатацию