Модуль 6 · Стандарт и его проверка

Справочник «Стандартизация HTTP API» · модуль 6 · Стандарт и его проверка

Глава 6.3

6.3

Контрактные
тесты

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

Категория
Стандарт
Уровень
Средний
Связанные главы
6.2 Линтер Spectral · 6.4 CI · 3.3 FastAPI и OpenAPI
Зачем это знать
Чтобы контракт оставался правдой, а не благим намерением

§1Три вида проверок

В учебном проекте 31 тест, разложенный по трём файлам. Каждый файл отвечает на свой вопрос.

tests/

conftest.pyподготовка: чистое хранилище, токены, пониженный лимит записи
test_contract.py · 9 тестовправильно ли устроено описание: метаданные, operationId, теги, ответы, префикс версии
test_tickets.py · 10 тестовправильно ли ведёт себя сервис: коды, Location, права, формат ошибок
test_http_features.py · 12 тестовработают ли механизмы из модуля 4: ETag, If-Match, идемпотентность, лимиты, устаревание, CORS

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

§2Тесты описания

Описание в FastAPI — обычный словарь, который возвращает app.openapi(). Значит, правила стандарта про структуру контракта записываются обычным перебором.

tests/test_contract.py · перебор всех операций
HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}

def operations(schema):
    for path, path_item in schema["paths"].items():
        for method, operation in path_item.items():
            if method in HTTP_METHODS:
                yield path, method, operation

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

девять правил, записанных тестами
test_openapi_contract_has_required_metadata   # title, version, description, contact
test_every_operation_has_operation_id          # operationId задан
test_operation_ids_are_unique                  # и не повторяется
test_every_operation_has_tag_and_summary       # группа и краткое описание
test_every_operation_defines_success_response  # успешный ответ описан
test_errors_use_problem_details                # 4xx и 5xx — application/problem+json
test_write_operations_require_bearer_token     # у операций записи есть security
test_paths_use_major_version_prefix            # все пути начинаются с /api/v1
test_committed_openapi_matches_code            # файл в репозитории равен описанию из кода
Зачем дублировать линтер

Часть этих правил проверяет и Spectral. Дублирование намеренное: тесты запускаются на каждом изменении кода и не требуют Node.js, а линтер ловит то, что удобнее выразить через JSONPath. Затраты на две проверки одного правила невелики, а пропущенное нарушение попадает в контракт.

§3Тесты поведения

Второй вид проверок отправляет настоящие запросы. Каждый тест — это правило стандарта, переписанное кодом.

«Создание MUST возвращать 201 с заголовком Location»
def test_create_returns_201_and_location(client, writer):
    response = client.post("/api/v1/tickets", json={"title": "Не работает VPN"}, headers=writer)
    assert response.status_code == 201
    ticket = response.json()
    assert ticket["status"] == "open"
    assert response.headers["Location"] == f"/api/v1/tickets/{ticket['id']}"

Проверяется не только код, но и Location, и начальный статус — всё, что обещано клиенту в главах 2.2 и 2.3.

«401 — не узнали, 403 — узнали, но нельзя»
def test_create_without_token_is_401(client):
    response = client.post("/api/v1/tickets", json={"title": "Не работает VPN"})
    assert response.status_code == 401
    assert response.headers["content-type"] == PROBLEM
    assert response.headers["WWW-Authenticate"] == "Bearer"

def test_viewer_cannot_delete_403(client, viewer):
    ticket_id = client.get("/api/v1/tickets").json()["items"][0]["id"]
    response = client.delete(f"/api/v1/tickets/{ticket_id}", headers=viewer)
    assert response.status_code == 403
    assert response.json()["title"] == "Operation forbidden"

Эти два теста закрепляют разницу кодов из главы 4.2: если операции начнут отвечать одинаково, сборка упадёт.

«Клиент не передаёт поля, которые формирует сервер»
def test_client_cannot_send_id(client, writer):
    response = client.post("/api/v1/tickets", json={"id": str(uuid4()), "title": "VPN"}, headers=writer)
    assert response.status_code == 422

Этот тест закрепляет настройку extra="forbid" из главы 3.3: если её убрать, сервис примет присланный клиентом id, и тест упадёт.

§4Проверка заголовков и условных запросов

Механизмы из модуля 4 проверяются сценариями из нескольких запросов — иначе их не воспроизвести.

потерянное изменение: должен быть 412
def test_patch_with_stale_if_match_is_412(client, writer):
    url = f"/api/v1/tickets/{first_ticket_id(client)}"
    old_etag = client.get(url).headers["ETag"]                      # 1. прочитали версию
    client.patch(url, json={"status": "in_progress"}, headers=writer)  # 2. кто-то изменил
    response = client.patch(url, json={"status": "closed"},
                            headers={**writer, "If-Match": old_etag})   # 3. пишем по старой версии
    assert response.status_code == 412
    assert response.headers["content-type"] == PROBLEM
    assert client.get(url).json()["status"] == "in_progress"        # 4. ничего не изменилось

Обратите внимание на последнюю строку. Мало вернуть 412: нужно ещё и не изменить ресурс. Тест проверяет и то, и другое, поэтому требование «при несовпадении сервер MUST ответить 412 и не менять ресурс» проверено полностью.

двенадцать механизмов под проверкой
test_request_id_is_generated                      test_idempotency_key_prevents_duplicate
test_request_id_from_client_is_returned           test_idempotency_key_with_other_body_is_422
test_unsafe_request_id_is_replaced                test_write_limit_returns_429_with_retry_after
test_get_returns_etag_and_304_for_same_version    test_deprecated_operation_sends_deprecation_and_sunset
test_patch_with_stale_if_match_is_412             test_cors_exposes_contract_headers
test_patch_with_current_if_match_succeeds         test_every_response_documents_request_id

Имена тестов составлены так, чтобы по одному их списку было видно, что именно сервис гарантирует.

§5Сверка выгруженного файла с кодом

Отдельно разберём проверку, которая решает задачу из главы 3.1.

tests/test_contract.py
def test_committed_openapi_matches_code():
    committed = json.loads((ROOT / "openapi" / "openapi.json").read_text(encoding="utf-8"))
    assert committed == app.openapi(), "run: python scripts/export_openapi.py"

Файл описания лежит в репозитории, чтобы его было видно в pull request и чтобы с ним мог работать oasdiff. Этот файл — копия, а источником остаётся код. Тест не даёт копии устареть; в сообщении об ошибке указано, что нужно сделать, — запустить выгрузку.

§6Подготовка: чистое состояние

Тесты, меняющие данные, мешают друг другу, если состояние между ними не сбрасывать. За это отвечает conftest.py.

tests/conftest.py
@pytest.fixture
def client():
    repository.reset()          # обращения — как в начале
    idempotency_store.reset()   # забыть использованные ключи
    write_limiter.reset()       # обнулить счётчик запросов
    return TestClient(app)

@pytest.fixture
def writer():
    return {"Authorization": "Bearer demo-token"}

@pytest.fixture
def low_write_limit():
    original = write_limiter.limit
    write_limiter.limit = 2    # временно снижаем предел
    yield
    write_limiter.limit = original

Разберём последнюю заготовку. Проверить лимит в 60 запросов в минуту «честно» означало бы отправить 61 запрос; вместо этого лимит на время теста снижается до двух, а после теста возвращается. Проверяется механизм, а не число.

§7Настоящий прогон

терминал
pytest -q
вывод
...............................                                          [100%]
31 passed in 0.07s

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

У этого есть обратная сторона: раз сеть не участвует, такие тесты не заметят ошибок настройки сервера, обратного прокси, срезающего заголовки, или CORS в рабочем контуре. Их проверяют на развёрнутом стенде.

§8Что писать тестом, а что нет

Обязательно тестом
  • каждое MUST из стандарта
  • коды ответов и заголовки, которые обещаны
  • разграничение прав: 401 и 403
  • сценарии из нескольких запросов: 412, идемпотентность, 304
  • совпадение выгруженного файла с кодом
Не тестом
  • осмысленность текста описания
  • удачность имени поля
  • то, что уже проверяет линтер строже и полнее
  • внутреннее устройство модулей, не видное клиенту

Ориентир тот же, что и в главе 6.1: тест пишется на обещание, данное клиенту. Если правило нельзя нарушить так, чтобы клиент это заметил, — тест на него не нужен.

§9Частые ошибки

Проверять только код ответаЗаголовки Location, ETag, WWW-Authenticate — тоже часть обещания.
Проверить отказ и не проверить последствиеПосле 412 нужно убедиться, что ресурс не изменился. Иначе тест проверит только половину требования.
Тесты, зависящие от порядка запускаСостояние между тестами сбрасывается заготовкой, иначе падения начинают «плавать».
Проверять лимит честными 61 запросомДолго и хрупко. Снизьте предел на время теста.
Сообщение об ошибке без следующего шагаКоллега должен из падения понять, что делать, а не идти читать исходники.
Считать, что зелёные тесты = рабочий APIСеть, прокси и настройки развёртывания они не проверяют.

§10Проверьте себя

  1. На какие три вопроса отвечают три файла тестов учебного проекта?
  2. Почему тест на структуру описания пишут циклом по всем операциям, а не по одной?
  3. Зачем в тесте на 412 нужна последняя проверка, если код ответа уже сверен?
  4. Что проверяет test_committed_openapi_matches_code и что этим предотвращается?
  5. Почему лимит записи проверяют с временно сниженным пределом?
  6. Назовите две вещи, которые эти 31 тест не заметят, даже если все зелёные.
Три файлаtest_contract.py — описаниеtest_tickets.py — поведениеtest_http_features.py — механизмы
Проверятькод ответазаголовкителосостояние после
Заготовкичистое хранилищетокенысниженный лимит
Границысеть и прокси — нетсмысл текста — нет

Справочник «Стандартизация HTTP API» · модуль 6 · глава 6.3Макаров Максим Николаевич

Кадр