Перейти к содержимому

Практика 9. Практическая работа 9. Тестирование REST API

Цель

  1. Научиться тестировать приложение FastAPI с помощью TestClient (starlette/httpx) и pytest.
  2. Покрыть тестами CRUD-эндпойнты сервиса «Книги»: проверять статус-коды и тело ответа.
  3. Освоить фикстуры pytest для подготовки клиента и тестовых данных.
  4. Научиться тестировать «негативные» сценарии: ошибки 404 (не найдено) и 422 (ошибка валидации).
  5. Запускать набор тестов командой pytest и читать отчёт.

Теория

TestClient

TestClient обращается к приложению напрямую, без запуска реального сервера (uvicorn). Он построен поверх httpx и имеет привычный интерфейс HTTP-клиента: client.get(), client.post(), client.put(), client.patch(), client.delete(). Это делает тесты быстрыми и не требующими сети.

Что проверяем в тесте

  • Статус-кодresponse.status_code (200, 201, 204, 404, 422 …);
  • Тело ответаresponse.json() (словарь/список) или response.text;
  • Заголовкиresponse.headers.

pytest и фикстуры

pytest находит функции, имена которых начинаются с test_, выполняет их и сообщает результат. Фикстура (@pytest.fixture) — это подготовленный ресурс (клиент, тестовая книга, чистая БД), который pytest сам передаёт в тест по имени аргумента. Фикстуры убирают дублирование и изолируют тесты друг от друга.

Уровни тестов

Unit — один эндпойнт изолированно; integration — связка шагов (создать → получить); E2E — полный жизненный цикл: создание → обновление → удаление → проверка 404.


Задание

Работаем с приложением main.py из практической работы 1 (сервис «Книги»). Все команды выполняем в активированном виртуальном окружении.

Шаг 0. Установка

Окно терминала
pip install pytest httpx

httpx нужен TestClient для работы, pytest — раннер тестов. Тесты кладём рядом с main.py в файл test_books.py.

Задание 1. Базовый тест и фикстура клиента

Создайте файл test_books.py. Вынесите TestClient в фикстуру, чтобы переиспользовать его во всех тестах.

import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def client():
return TestClient(app)
def test_root(client):
response = client.get("/")
assert response.status_code == 200
assert "msg" in response.json()
def test_list_books(client):
response = client.get("/books/")
assert response.status_code == 200
assert isinstance(response.json(), list)

Запустите тесты командой pytest -v.

Задание 2. Тест создания книги (POST → 201)

Проверьте, что POST /books/ возвращает статус 201 и корректное тело ответа с сгенерированным id.

def test_create_book(client):
payload = {"title": "Война и мир", "author": "Л.Н. Толстой",
"year": 1869, "price": 990.0}
response = client.post("/books/", json=payload)
assert response.status_code == 201
data = response.json()
assert data["title"] == payload["title"]
assert "id" in data # id сгенерирован сервером
assert data["in_stock"] is True # значение по умолчанию

Задание 3. Фикстура с тестовой книгой и проверка GET по id

Чтобы не создавать книгу в каждом тесте вручную, опишите фикстуру created_book, которая создаёт книгу и возвращает её данные.

@pytest.fixture
def created_book(client):
payload = {"title": "1984", "author": "Дж. Оруэлл", "year": 1949, "price": 500.0}
response = client.post("/books/", json=payload)
assert response.status_code == 201
return response.json()
def test_get_book_by_id(client, created_book):
book_id = created_book["id"]
response = client.get(f"/books/{book_id}")
assert response.status_code == 200
assert response.json()["id"] == book_id
assert response.json()["title"] == "1984"

Задание 4. Негативные сценарии: 404 и 422

Хорошие тесты проверяют не только «счастливый путь». Проверьте поведение API при ошибках.

def test_get_missing_book_404(client):
# валидный, но несуществующий UUID
fake_id = "00000000-0000-0000-0000-000000000000"
response = client.get(f"/books/{fake_id}")
assert response.status_code == 404
assert response.json()["detail"] == "Book not found"
def test_create_book_invalid_422(client):
# нет обязательного поля author, price отрицательный
bad_payload = {"title": "Без автора", "year": 2024, "price": -10}
response = client.post("/books/", json=bad_payload)
assert response.status_code == 422
assert "detail" in response.json() # список ошибок валидации Pydantic

Задание 5. Тест обновления (PATCH) и удаления (DELETE → 204)

Проверьте частичное обновление и удаление ресурса.

def test_patch_book(client, created_book):
book_id = created_book["id"]
response = client.patch(f"/books/{book_id}", json={"price": 750.0})
assert response.status_code == 200
assert response.json()["price"] == 750.0
assert response.json()["title"] == "1984" # остальные поля не изменились
def test_delete_book_204(client, created_book):
book_id = created_book["id"]
response = client.delete(f"/books/{book_id}")
assert response.status_code == 204
assert response.content == b"" # тело пустое
# повторное удаление — уже 404
assert client.delete(f"/books/{book_id}").status_code == 404

Задание 6. E2E-тест полного жизненного цикла

Объедините шаги в один сценарий: создание → получение → обновление (PUT) → удаление → проверка, что ресурс больше недоступен.

def test_book_lifecycle(client):
payload = {"title": "Тест", "author": "Автор", "year": 2024, "price": 100.0}
created = client.post("/books/", json=payload) # создание
assert created.status_code == 201
book_id = created.json()["id"]
assert client.get(f"/books/{book_id}").status_code == 200 # получение
new = {"title": "Тест 2", "author": "Автор", "year": 2025, "price": 200.0}
updated = client.put(f"/books/{book_id}", json=new) # полная замена
assert updated.status_code == 200
assert updated.json()["title"] == "Тест 2"
assert client.delete(f"/books/{book_id}").status_code == 204 # удаление
assert client.get(f"/books/{book_id}").status_code == 404 # больше нет

Запуск всех тестов

Окно терминала
pytest # короткий отчёт
pytest -v # подробный список тестов
pytest -k "create" # только тесты со словом create в имени

Успешный отчёт заканчивается строкой вида ======== 9 passed in 0.34s ========.


Критерии оценки

КритерийБаллы
Окружение настроено, тесты запускаются командой pytest10%
Задание 1: фикстура клиента и базовые тесты15%
Задания 2–3: тесты POST/GET, фикстура created_book20%
Задание 4: негативные сценарии 404 и 42220%
Задание 5: тесты PATCH и DELETE (204)15%
Задание 6: E2E-тест жизненного цикла15%
Все тесты проходят, код оформлен аккуратно5%
Итого100%

Вопросы для самопроверки

  1. Чем TestClient удобнее тестирования через запущенный сервер uvicorn? На какой библиотеке он построен?
  2. Как pytest узнаёт, какие функции являются тестами?
  3. Что такое фикстура и как pytest передаёт её в тест? Зачем выносить TestClient в фикстуру?
  4. Как из объекта ответа получить статус-код, тело в виде словаря и заголовки?
  5. Какой статус-код должен вернуть POST при успешном создании ресурса, а какой — DELETE?
  6. В каком случае FastAPI возвращает 422, а в каком — 404? Кто формирует ошибку 422?
  7. Почему важно тестировать не только «счастливый путь», но и ошибки?
  8. В чём разница между unit-, integration- и E2E-тестами? К какому типу относится тест из задания 6?
  9. Как запустить только часть тестов по подстроке в имени?
  10. Почему при проверке ответа DELETE (204) мы убеждаемся, что тело пустое?