Справочник «Стандартизация HTTP API» · модуль 6 · Стандарт и его проверка
Глава 6.3
Контрактные
тесты
Чистый вывод линтера означает, что правильно составлено описание, но не что сервис ведёт себя так, как в нём обещано: описание и поведение задаются в разных местах и совпадают только тогда, когда совпадение проверено. Такую проверку и выполняют тесты — при каждом изменении кода.
- Категория
- Стандарт
- Уровень
- Средний
- Связанные главы
- 6.2 Линтер Spectral · 6.4 CI · 3.3 FastAPI и OpenAPI
- Зачем это знать
- Чтобы контракт оставался правдой, а не благим намерением
§1Три вида проверок
В учебном проекте 31 тест, разложенный по трём файлам. Каждый файл отвечает на свой вопрос.
tests/
operationId, теги, ответы, префикс версииLocation, права, формат ошибокETag, If-Match, идемпотентность, лимиты, устаревание, CORSПервый файл читает описание как данные; два других поднимают приложение и отправляют в него настоящие запросы.
§2Тесты описания
Описание в FastAPI — обычный словарь, который возвращает app.openapi(). Значит, правила стандарта про структуру контракта записываются обычным перебором.
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Тесты поведения
Второй вид проверок отправляет настоящие запросы. Каждый тест — это правило стандарта, переписанное кодом.
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.
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 проверяются сценариями из нескольких запросов — иначе их не воспроизвести.
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.
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.
@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 нужно убедиться, что ресурс не изменился. Иначе тест проверит только половину требования.§10Проверьте себя
- На какие три вопроса отвечают три файла тестов учебного проекта?
- Почему тест на структуру описания пишут циклом по всем операциям, а не по одной?
- Зачем в тесте на
412нужна последняя проверка, если код ответа уже сверен? - Что проверяет
test_committed_openapi_matches_codeи что этим предотвращается? - Почему лимит записи проверяют с временно сниженным пределом?
- Назовите две вещи, которые эти 31 тест не заметят, даже если все зелёные.
test_contract.py — описаниеtest_tickets.py — поведениеtest_http_features.py — механизмыкод ответазаголовкителосостояние послечистое хранилищетокенысниженный лимитсеть и прокси — нетсмысл текста — нетСправочник «Стандартизация HTTP API» · модуль 6 · глава 6.3Макаров Максим Николаевич