Раздел 07 — Автотесты API: данные и негативные случаи

Сервер обязан отказать — и отказ тоже надо проверить

Прогресс курса Раздел 7 из 12

Что вы освоите в этом разделе

6 академических часов: 2 теории, 4 практики. Продолжаем проект «Задачника» из раздела 06: фикстуры api, ivan, maria, anon — в conftest.py стартового проекта.
01

Негативные проверки параметризацией

У создания задачи одно позитивное поведение и десятки негативных: нет поля, пустое, слишком длинное, другого типа, вне диапазона, не в том формате. Каждая проверка устроена одинаково — отправить тело, ждать 400 и ответ-ошибку, — поэтому это один тест с параметрами:

"""Негативные проверки создания задачи: сервер обязан отказать кодом 400 и ответом-ошибкой."""
import pytest

TOO_LONG = "я" * 101


@pytest.mark.parametrize("body", [
    pytest.param({}, id="no-title"),
    pytest.param({"title": ""}, id="title-empty"),
    pytest.param({"title": TOO_LONG}, id="title-101"),
    pytest.param({"title": 42}, id="title-number"),
    pytest.param({"title": "Т", "priority": 6}, id="priority-6"),
    pytest.param({"title": "Т", "priority": "2"}, id="priority-string"),
    pytest.param({"title": "Т", "due": "2020-01-01"}, id="due-past"),
    pytest.param({"title": "Т", "due": "31.12.2030"}, id="due-format"),
])
def test_create_rejected(api, body):
    r = api.post("/api/tasks", json=body)
    assert r.status_code == 400
    assert isinstance(r.json()["error"], str)


def test_rejected_task_not_created(api):
    api.post("/api/tasks", json={"title": TOO_LONG})
    assert api.get("/api/tasks").json() == []


def test_unknown_task_404(api):
    assert api.get("/api/tasks/999999").status_code == 404


def test_foreign_task_not_editable(api, maria):
    foreign = maria.get("/api/tasks").json()[0]
    r = api.patch(f"/api/tasks/{foreign['id']}", json={"title": "Чужая"})
    assert r.status_code == 404
    assert maria.get(f"/api/tasks/{foreign['id']}").json()["title"] == foreign["title"]
$ pytest -v tests/test_negative.py
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0 -- ~/qa/.venv/bin/python3
cachedir: .pytest_cache
metadata: {'Python': '3.13.5', 'Platform': 'Linux-6.12.95+deb13-amd64-x86_64-with-glibc2.41', 'Packages': {'pytest': '9.2.0', 'pluggy': '1.7.0'}, 'Plugins': {'metadata': '3.1.1', 'playwright': '0.10.0', 'locust': '2.47.0', 'html': '4.2.0'}}
rootdir: ~/qa
configfile: pytest.ini
plugins: metadata-3.1.1, playwright-0.10.0, locust-2.47.0, html-4.2.0
collecting ... collected 11 items

tests/test_negative.py::test_create_rejected[no-title] PASSED            [  9%]
tests/test_negative.py::test_create_rejected[title-empty] PASSED         [ 18%]
tests/test_negative.py::test_create_rejected[title-101] PASSED           [ 27%]
tests/test_negative.py::test_create_rejected[title-number] PASSED        [ 36%]
tests/test_negative.py::test_create_rejected[priority-6] PASSED          [ 45%]
tests/test_negative.py::test_create_rejected[priority-string] PASSED     [ 54%]
tests/test_negative.py::test_create_rejected[due-past] PASSED            [ 63%]
tests/test_negative.py::test_create_rejected[due-format] PASSED          [ 72%]
tests/test_negative.py::test_rejected_task_not_created PASSED            [ 81%]
tests/test_negative.py::test_unknown_task_404 PASSED                     [ 90%]
tests/test_negative.py::test_foreign_task_not_editable PASSED            [100%]

============================== 11 passed in 0.37s ==============================

Откуда взять варианты — техники из курса ручного тестирования, применённые к каждому полю OpenAPI:

ЧтоВарианты
НаличиеПоле отсутствует; null; пустая строка
ТипЧисло вместо строки, строка вместо числа, true/false, список, объект
ГраницыМинимум − 1, максимум + 1 (и сами минимум с максимумом — в позитивных тестах)
ФорматДата не в ISO, несуществующая дата, лишние пробелы
Тело целикомНе JSON, пустое тело, массив вместо объекта
СостояниеЗапрещённый переход статуса, объект удалён, объект чужой

В ответе на ошибку проверяют и код, и форму ответа: по OpenAPI это JSON с полем error. Ответ 400 текстом или HTML-страницей ошибки — тоже расхождение с документацией. Тест test_rejected_task_not_created проверяет побочный эффект: отказ ничего не создал. Сервер, который ответил 400, но задачу всё-таки сохранил, встречается чаще, чем кажется.

Тип true для числового поля — особый случай в Python: isinstance(True, int) даёт True, потому что bool — подтип int. Серверы на Python часто пропускают такую ошибку. Обязательно включайте этот вариант в проверки числовых полей.
02

Структура ответа: JSON Schema

Проверять каждое поле задачи отдельным assert долго, а пропустить поле легко. JSON Schema описывает ожидаемую структуру целиком: какие поля обязательны, их типы, границы, допустимые значения. Библиотека jsonschema сверяет ответ со схемой одной строкой — validate(ответ, схема):

from jsonschema import validate

TASK = {
    "type": "object",
    "required": ["id", "title", "due", "priority", "status", "overdue", "created", "updated"],
    "properties": {
        "id": {"type": "integer"},
        "title": {"type": "string", "minLength": 1, "maxLength": 100},
        "due": {"type": ["string", "null"], "pattern": r"^\d{4}-\d{2}-\d{2}$"},
        "priority": {"type": "integer", "minimum": 1, "maximum": 5},
        "status": {"enum": ["new", "in_progress", "done"]},
        "overdue": {"type": "boolean"},
        "created": {"type": "string"},
        "updated": {"type": "string"},
    },
    "additionalProperties": False,
}


def test_created_task_schema(api):
    validate(api.create("Проверить схему", due="2030-01-31"), TASK)


def test_maria_tasks_schema(maria):
    tasks = maria.get("/api/tasks").json()
    assert len(tasks) == 3
    for task in tasks:
        validate(task, TASK)
$ pytest -q tests/test_schema.py
..                                                                       [100%]
2 passed in 0.35s

Что даёт схема в тесте:

Когда ответ не подходит, jsonschema называет правило и показывает объект. Пример: схема требует поле owner, которого в задаче нет; --tb=line оставляет от трассировки одну строку:

from jsonschema import validate


def test_owner_in_task():
    task = {"id": 5, "title": "Купить картридж для принтера", "priority": 4}
    validate(task, {"type": "object", "required": ["id", "title", "owner"]})
$ pytest -q --tb=line demo
F                                                                        [100%]
=================================== FAILURES ===================================
E   jsonschema.exceptions.ValidationError: 'owner' is a required property
    
    Failed validating 'required' in schema:
        {'type': 'object', 'required': ['id', 'title', 'owner']}
    
    On instance:
        {'id': 5, 'title': 'Купить картридж для принтера', 'priority': 4}
~/qa/.venv/lib/python3.13/site-packages/jsonschema/validators.py:1332: jsonschema.exceptions.ValidationError: 'owner' is a required property
=========================== short test summary info ============================
FAILED demo/test_schema_fail.py::test_owner_in_task - jsonschema.exceptions.V...
1 failed in 0.09s

Схему можно взять прямо из OpenAPI (components → schemas → Task) — она написана на том же языке. Но помните правило раздела 06: схема в тесте — это ожидание. Если OpenAPI разрешает лишнее, тест вправе быть строже — по требованиям.

03

Тесты не должны зависеть друг от друга

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

def test_add_task_to_ivan(ivan):
    ivan.create("Временная задача")


def test_ivan_has_8_tasks(ivan):
    assert len(ivan.get("/api/tasks").json()) == 8
$ pytest -q order/test_order.py::test_ivan_has_8_tasks
.                                                                        [100%]
1 passed in 0.24s
$ pytest -q --tb=short order/test_order.py
.F                                                                       [100%]
=================================== FAILURES ===================================
____________________________ test_ivan_has_8_tasks _____________________________
order/test_order.py:6: in test_ivan_has_8_tasks
    assert len(ivan.get("/api/tasks").json()) == 8
E   AssertionError: assert 9 == 8
E    +  where 9 = len([{'id': 4, 'title': 'Отчёт по продажам за сентябрь', 'due': '2026-10-26', 'priority': 2, ...}, {'id': 1, 'title': 'Под...', 'priority': 1, ...}, {'id': 8, 'title': 'Заказать пропуск для гостя', 'due': '2026-11-02', 'priority': 3, ...}, ...])
E    +    where [{'id': 4, 'title': 'Отчёт по продажам за сентябрь', 'due': '2026-10-26', 'priority': 2, ...}, {'id': 1, 'title': 'Под...', 'priority': 1, ...}, {'id': 8, 'title': 'Заказать пропуск для гостя', 'due': '2026-11-02', 'priority': 3, ...}, ...] = json()
E    +      where json = <Response [200]>.json
E    +        where <Response [200]> = get('/api/tasks')
E    +          where get = <client.Api object at 0x7f45ae275090>.get
=========================== short test summary info ============================
FAILED order/test_order.py::test_ivan_has_8_tasks - AssertionError: assert 9 ...
1 failed, 1 passed in 0.29s

Один тест проходит, вместе с соседом — падает. В большом наборе такое ищут часами: падение «плавает» в зависимости от того, какие тесты запустили. Три способа защититься:

СпособКакВ нашем проекте
Свои данные у каждого тестаНовый пользователь, новая запись — чужое не трогаемФикстура user и клиент api
Уборка после тестаФикстура с yield удаляет то, что создалаНиже: temp_task
Чистое состояние на прогонПриложение или база с нуля перед прогономbase_url запускает «Задачник» с --reset
import pytest


@pytest.fixture
def temp_task(ivan):
    task = ivan.create("Временная задача")
    yield task
    ivan.delete(f"/api/tasks/{task['id']}")


def test_temp_task_visible(ivan, temp_task):
    titles = [t["title"] for t in ivan.get("/api/tasks").json()]
    assert "Временная задача" in titles


def test_ivan_has_8_tasks(ivan):
    assert len(ivan.get("/api/tasks").json()) == 8
$ pytest -q order/test_order_fixed.py
..                                                                       [100%]
2 passed in 0.25s

Уборка после yield выполнится, даже если тест упадёт. Если тесту по смыслу нужно менять учебные данные — пусть меняет и возвращает как было. Но надёжнее первый способ: проверки, которые меняют данные, работают со своим пользователем.

04

Доступ к чужим данным

Проверки прав — отдельный класс негативных тестов, и для безопасности самый важный. Требование Т8.2 «Задачника»: через API пользователь видит и меняет только свои задачи. Значит, для каждой операции с конкретной задачей нужен тест «чужая задача»: взять id задачи maria и обратиться к нему под другим пользователем.

Пример — изменение чужой задачи (тест test_foreign_task_not_editable выше): ожидаем 404 и проверяем у maria, что название не поменялось. Почему 404, а не 403 — так записано в OpenAPI «Задачника»: для чужого пользователя задача «не существует», и по коду ответа нельзя даже узнать, что такой id есть.

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

Такой дефект называют IDOR (небезопасная прямая ссылка на объект): перебирая id, злоумышленник читает чужие данные. Это одна из самых частых уязвимостей веб-приложений. Проверяйте только свои учебные приложения и стенды — проверка чужих сайтов без разрешения владельца незаконна.

Ключевые выводы раздела

Запомните главное

  • Негативные проверки одного поля — один параметризованный тест; варианты — из техник тест-дизайна
  • Проверяют код, форму ошибки и то, что отказ ничего не изменил
  • JSON Schema проверяет структуру целиком; additionalProperties: false ловит лишние поля
  • Каждый тест проходит один и в любом порядке: свои данные, уборка после yield, чистое приложение на прогон
  • Права доступа проверяют для каждой операции с объектом отдельно
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 06: Автотесты API — основы Все разделы Раздел 08: Автотесты UI — Playwright