Раздел 06 — Автотесты API: основы

Тот же запрос, что в Bruno, только проверку выполняет код

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

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

6 академических часов: 2 теории, 4 практики. Что такое HTTP, коды ответов и токен, разобрано в разделе 09 курса ручного тестирования — «Тестирование API». Документация API «Задачника» — по адресу /api/openapi.json запущенной программы.
01

Запросы из Python: requests

Библиотека 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 — итоговый адрес.

02

Первые API-тесты

Превратить запрос в тест — добавить 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 в работу, тест упадёт, хотя программа исправна.

03

Клиент и токен в фикстурах

Все три недостатка снимает стартовый проект из раздела 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 ==============================
04

Что проверять в ответе

У ответа API три части, и проверять стоит каждую — дефекты встречаются во всех:

ЧастьЧто проверитьПример проверки
КодСовпадает с документацией для этой операцииassert r.status_code == 401
ЗаголовкиТип содержимого, кодировка; для 201 — иногда Locationassert 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) не должно трогать другие поля.

05

Ожидания — из документации

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

Источник ожиданий для API «Задачника» — его документация OpenAPI (/api/openapi.json; удобнее смотреть в Bruno или в любом просмотрщике OpenAPI) и требования Т1–Т9. В документации для каждой операции перечислены коды ответов и описания тел:

ОперацияГде в OpenAPI смотреть
Какие коды возможныpaths → /api/tasks → post → responses
Какие поля обязательны и их ограниченияrequestBody → … → schema: required, minLength, maximum
Как выглядит задача и ошибкаcomponents → schemas → Task и Error

Порядок работы: прочитать операцию в документации → записать ожидания в тест → запустить. Если тест упал — сравнить с документацией ещё раз: неправ тест или программа. Расхождение программы с документацией — дефект, даже если «так удобнее».

Коллекция Bruno из курса ручного тестирования и API-тесты на pytest проверяют одно и то же. Разница — в том, что pytest-тесты лежат в том же репозитории, что и код, запускаются той же командой, что и модульные, и попадают в общий отчёт CI.

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

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

  • requests: метод, адрес, params, json, headers — и всегда timeout
  • Адрес, запуск приложения и вход — в фикстурах; повторяющиеся вызовы — в клиенте
  • Тест, который меняет данные, работает со своим пользователем; общие учебные данные — только читать
  • Проверяют код, заголовки, тело и побочный эффект — отдельным запросом
  • Ожидания берут из OpenAPI и требований, а не из ответа программы
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 05: Модульные тесты и заглушки Все разделы Раздел 07: Автотесты API — данные и негативные случаи