Тот же запрос, что в Bruno, только проверку выполняет код
/api/openapi.json запущенной программы.Библиотека requests делает HTTP-запрос одной строкой. Сначала — без тестов, просто посмотрим на API «Задачника», запущенного командой python tasker.py --reset:
import requests
BASE = "http://127.0.0.1:8000"
r = requests.get(BASE + "/api/openapi.json", timeout=5)
print(r.status_code, r.headers["Content-Type"])
print(list(r.json()["paths"]))
r = requests.post(BASE + "/api/login", json={"login": "ivan", "password": "Ivan2026pass"}, timeout=5)
token = r.json()["token"]
print(r.status_code, "длина токена:", len(token))
r = requests.get(BASE + "/api/tasks", headers={"Authorization": f"Bearer {token}"},
params={"status": "in_progress"}, timeout=5)
print(r.status_code, r.url)
for task in r.json():
print(task["id"], task["title"], task["status"])
r = requests.get(BASE + "/api/tasks", timeout=5)
print(r.status_code, r.json())
$ python explore.py
200 application/json; charset=utf-8
['/api/login', '/api/logout', '/api/tasks', '/api/tasks/{id}', '/api/tasks/{id}/status', '/api/stats']
200 длина токена: 32
200 http://127.0.0.1:8000/api/tasks?status=in_progress
1 Подготовить отчёт за квартал in_progress
6 Проверить резервные копии in_progress
401 {'error': 'Требуется вход: заголовок Authorization: Bearer <токен>'}
| Запись | Что уходит на сервер |
|---|---|
requests.get(url), .post, .patch, .delete | Метод и адрес |
params={"status": "in_progress"} | Параметры в адресе: ?status=in_progress — requests сам кодирует пробелы и кириллицу |
json={…} | Тело в JSON и заголовок Content-Type: application/json |
data={…} | Тело как у HTML-формы (application/x-www-form-urlencoded) |
headers={"Authorization": "Bearer …"} | Свои заголовки, здесь — токен |
timeout=5 | Не ждать ответа дольше 5 секунд. Без него тест может зависнуть навсегда |
Из ответа: r.status_code — код, r.headers["Content-Type"] — заголовок (без учёта регистра имени), r.json() — тело, разобранное в словарь или список, r.text — тело строкой, r.url — итоговый адрес.
Превратить запрос в тест — добавить assert:
import requests
BASE = "http://127.0.0.1:8000"
def test_tasks_need_token():
r = requests.get(BASE + "/api/tasks", timeout=5)
assert r.status_code == 401
def test_ivan_in_progress():
r = requests.post(BASE + "/api/login", json={"login": "ivan", "password": "Ivan2026pass"}, timeout=5)
token = r.json()["token"]
r = requests.get(BASE + "/api/tasks", params={"status": "in_progress"},
headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert r.status_code == 200
assert [t["title"] for t in r.json()] == ["Подготовить отчёт за квартал", "Проверить резервные копии"]
$ pytest -q -p no:cacheprovider tests/test_raw.py
.. [100%]
2 passed in 0.11s
Тесты работают, но у них три недостатка. Адрес зашит, и тесты требуют, чтобы кто-то заранее запустил «Задачник» на 8000. Вход повторяется в каждом тесте. И главное — тест test_ivan_in_progress зависит от данных: если кто-то вручную возьмёт задачу ivan в работу, тест упадёт, хотя программа исправна.
Все три недостатка снимает стартовый проект из раздела 01. Фикстура base_url сама запускает «Задачник» на свободном порту со свежими данными. Класс Api в client.py помнит адрес и токен:
"""Клиент «Задачника» для автотестов: API с токеном, вход, регистрация через форму."""
import requests
TIMEOUT = 5
class Api:
"""Клиент API: адрес и токен один раз, дальше — короткие вызовы."""
def __init__(self, base_url, token=None):
self.base_url = base_url
self.http = requests.Session()
if token:
self.http.headers["Authorization"] = f"Bearer {token}"
def get(self, path, **kw):
return self.http.get(self.base_url + path, timeout=TIMEOUT, **kw)
def post(self, path, **kw):
return self.http.post(self.base_url + path, timeout=TIMEOUT, **kw)
def patch(self, path, **kw):
return self.http.patch(self.base_url + path, timeout=TIMEOUT, **kw)
def delete(self, path, **kw):
return self.http.delete(self.base_url + path, timeout=TIMEOUT, **kw)
def create(self, title="Задача из автотеста", **fields):
"""Создать задачу и вернуть её. Это подготовка данных, а не проверка: код 200 или 201."""
r = self.post("/api/tasks", json={"title": title, **fields})
assert r.status_code in (200, 201), r.text
return r.json()
def login_token(base_url, login, password):
r = requests.post(base_url + "/api/login", json={"login": login, "password": password}, timeout=TIMEOUT)
assert r.status_code == 200, r.text
return r.json()["token"]
def register(base_url, login, password):
"""Регистрация есть только в веб-интерфейсе — отправляем ту же форму, что и браузер."""
return requests.post(base_url + "/register", data={"login": login, "password": password},
allow_redirects=False, timeout=TIMEOUT)
requests.Session() — сеанс: заголовки, заданные один раз, уходят с каждым запросом, а соединение с сервером переиспользуется. Метод create — не проверка, а подготовка данных: он лишь убеждается, что задача создалась, и возвращает её.
Фикстуры раздаются по назначению: api — новый пользователь без задач, его можно менять как угодно; ivan — учебные данные, только читать; anon — без токена. Тест получает уже готового клиента:
"""API «Задачника»: список, создание, чтение, изменение, удаление, смена статуса."""
import pytest
def test_tasks_need_token(anon):
r = anon.get("/api/tasks")
assert r.status_code == 401
assert r.headers["Content-Type"] == "application/json; charset=utf-8"
assert "error" in r.json()
def test_new_user_has_no_tasks(api):
r = api.get("/api/tasks")
assert r.status_code == 200
assert r.json() == []
def test_create_returns_task(api):
r = api.post("/api/tasks", json={"title": "Заказать бумагу", "priority": 2})
task = r.json()
assert task["title"] == "Заказать бумагу"
assert task["priority"] == 2
assert task["status"] == "new"
assert task["overdue"] is False
assert isinstance(task["id"], int)
def test_read_created(api):
task = api.create("Прочитать по id")
r = api.get(f"/api/tasks/{task['id']}")
assert r.status_code == 200
assert r.json()["title"] == "Прочитать по id"
def test_patch_priority(api):
task = api.create("Сменить приоритет", priority=3)
r = api.patch(f"/api/tasks/{task['id']}", json={"priority": 1})
assert r.status_code == 200
assert r.json()["priority"] == 1
assert r.json()["title"] == "Сменить приоритет"
def test_delete(api):
task = api.create("Удалить меня")
assert api.delete(f"/api/tasks/{task['id']}").status_code == 204
assert api.get(f"/api/tasks/{task['id']}").status_code == 404
def test_take_in_work(api):
task = api.create("Взять в работу")
r = api.post(f"/api/tasks/{task['id']}/status", json={"status": "in_progress"})
assert r.status_code == 200
assert r.json()["status"] == "in_progress"
@pytest.mark.parametrize("status, count", [("new", 4), ("in_progress", 2), ("done", 2)])
def test_ivan_filter_by_status(ivan, status, count):
tasks = ivan.get("/api/tasks", params={"status": status}).json()
assert len(tasks) == count
assert {t["status"] for t in tasks} == {status}
$ pytest -v tests/test_api_basics.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 10 items
tests/test_api_basics.py::test_tasks_need_token PASSED [ 10%]
tests/test_api_basics.py::test_new_user_has_no_tasks PASSED [ 20%]
tests/test_api_basics.py::test_create_returns_task PASSED [ 30%]
tests/test_api_basics.py::test_read_created PASSED [ 40%]
tests/test_api_basics.py::test_patch_priority PASSED [ 50%]
tests/test_api_basics.py::test_delete PASSED [ 60%]
tests/test_api_basics.py::test_take_in_work PASSED [ 70%]
tests/test_api_basics.py::test_ivan_filter_by_status[new-4] PASSED [ 80%]
tests/test_api_basics.py::test_ivan_filter_by_status[in_progress-2] PASSED [ 90%]
tests/test_api_basics.py::test_ivan_filter_by_status[done-2] PASSED [100%]
============================== 10 passed in 0.34s ==============================
У ответа API три части, и проверять стоит каждую — дефекты встречаются во всех:
| Часть | Что проверить | Пример проверки |
|---|---|---|
| Код | Совпадает с документацией для этой операции | assert r.status_code == 401 |
| Заголовки | Тип содержимого, кодировка; для 201 — иногда Location | assert r.headers["Content-Type"] == "application/json; charset=utf-8" |
| Тело | Значения полей, которые мы задали; поля, которые сервер вычислил сам; формат ошибки | assert task["status"] == "new", assert "error" in r.json() |
| Побочный эффект | Изменение действительно сохранилось: другим запросом | после DELETE — GET того же id даёт 404 |
Последняя строка — то, что чаще всего забывают. Ответ «204 удалено» — это слова сервера; проверка — запросить задачу ещё раз. То же для изменения: в test_patch_priority проверено не только новое значение приоритета, но и то, что название осталось прежним — частичное изменение (PATCH) не должно трогать другие поля.
Самая опасная ошибка автоматизатора — записать в тест то, что программа ответила, а не то, что она должна отвечать. Такой тест закрепляет дефект: он будет зелёным, пока дефект на месте, и упадёт, когда дефект исправят.
Источник ожиданий для API «Задачника» — его документация OpenAPI (/api/openapi.json; удобнее смотреть в Bruno или в любом просмотрщике OpenAPI) и требования Т1–Т9. В документации для каждой операции перечислены коды ответов и описания тел:
| Операция | Где в OpenAPI смотреть |
|---|---|
| Какие коды возможны | paths → /api/tasks → post → responses |
| Какие поля обязательны и их ограничения | requestBody → … → schema: required, minLength, maximum |
| Как выглядит задача и ошибка | components → schemas → Task и Error |
Порядок работы: прочитать операцию в документации → записать ожидания в тест → запустить. Если тест упал — сравнить с документацией ещё раз: неправ тест или программа. Расхождение программы с документацией — дефект, даже если «так удобнее».
params, json, headers — и всегда timeout