Практика 9. Практическая работа 9. Тестирование REST API
Цель
- Научиться тестировать приложение FastAPI с помощью
TestClient(starlette/httpx) иpytest. - Покрыть тестами CRUD-эндпойнты сервиса «Книги»: проверять статус-коды и тело ответа.
- Освоить фикстуры pytest для подготовки клиента и тестовых данных.
- Научиться тестировать «негативные» сценарии: ошибки
404(не найдено) и422(ошибка валидации). - Запускать набор тестов командой
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 pytestfrom fastapi.testclient import TestClientfrom main import app
@pytest.fixturedef 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.fixturedef 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 ========.
Критерии оценки
| Критерий | Баллы |
|---|---|
Окружение настроено, тесты запускаются командой pytest | 10% |
| Задание 1: фикстура клиента и базовые тесты | 15% |
Задания 2–3: тесты POST/GET, фикстура created_book | 20% |
Задание 4: негативные сценарии 404 и 422 | 20% |
| Задание 5: тесты PATCH и DELETE (204) | 15% |
| Задание 6: E2E-тест жизненного цикла | 15% |
| Все тесты проходят, код оформлен аккуратно | 5% |
| Итого | 100% |
Вопросы для самопроверки
- Чем
TestClientудобнее тестирования через запущенный сервер uvicorn? На какой библиотеке он построен? - Как pytest узнаёт, какие функции являются тестами?
- Что такое фикстура и как pytest передаёт её в тест? Зачем выносить
TestClientв фикстуру? - Как из объекта ответа получить статус-код, тело в виде словаря и заголовки?
- Какой статус-код должен вернуть
POSTпри успешном создании ресурса, а какой —DELETE? - В каком случае FastAPI возвращает
422, а в каком —404? Кто формирует ошибку422? - Почему важно тестировать не только «счастливый путь», но и ошибки?
- В чём разница между unit-, integration- и E2E-тестами? К какому типу относится тест из задания 6?
- Как запустить только часть тестов по подстроке в имени?
- Почему при проверке ответа
DELETE(204) мы убеждаемся, что тело пустое?