Сервер обязан отказать — и отказ тоже надо проверить
api, ivan, maria, anon — в conftest.py стартового проекта.У создания задачи одно позитивное поведение и десятки негативных: нет поля, пустое, слишком длинное, другого типа, вне диапазона, не в том формате. Каждая проверка устроена одинаково — отправить тело, ждать 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 часто пропускают такую ошибку. Обязательно включайте этот вариант в проверки числовых полей.Проверять каждое поле задачи отдельным 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
Что даёт схема в тесте:
required — нет поля → ошибка; additionalProperties: false — лишнее поле тоже ошибка (например, в ответ случайно попал пароль);type с массивом ["string", "null"] — срок может быть строкой или отсутствовать;pattern, minimum, maxLength, enum — значения, а не только типы.Когда ответ не подходит, 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 разрешает лишнее, тест вправе быть строже — по требованиям.
Главное требование к набору: каждый тест проходит один, в любом порядке и в любой компании. Нарушение выглядит так — первый тест добавляет задачу учебному пользователю и ничего не убирает:
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 выполнится, даже если тест упадёт. Если тесту по смыслу нужно менять учебные данные — пусть меняет и возвращает как было. Но надёжнее первый способ: проверки, которые меняют данные, работают со своим пользователем.
Проверки прав — отдельный класс негативных тестов, и для безопасности самый важный. Требование Т8.2 «Задачника»: через API пользователь видит и меняет только свои задачи. Значит, для каждой операции с конкретной задачей нужен тест «чужая задача»: взять id задачи maria и обратиться к нему под другим пользователем.
Пример — изменение чужой задачи (тест test_foreign_task_not_editable выше): ожидаем 404 и проверяем у maria, что название не поменялось. Почему 404, а не 403 — так записано в OpenAPI «Задачника»: для чужого пользователя задача «не существует», и по коду ответа нельзя даже узнать, что такой id есть.
Операций с задачей по id в API четыре: чтение, изменение, удаление, смена статуса; пятая проверка — что чужих задач нет в общем списке. Проверять нужно каждую: на практике дефект доступа часто есть в одной операции, а остальные защищены. Тест удобно параметризовать по методу и пути.
additionalProperties: false ловит лишние поляyield, чистое приложение на прогон