Урок курса
Первый тест API через TestClient
FastAPI для начинающих: API с базой данных и тестамиВ уроке26 POST /api/rooms/{room_id}/bookings научился отвечать201 при сохранении и409 при конфликте. Первый автоматический тест начнём с отдельного читающего приложения без базы: проверим GET комнаты и404. Запись в SQLite и сценарии бронирования подключим в уроках28–29.
TestClient: запрос к ASGI-приложению без Uvicorn
Когда вы запускаете приложение через uvicorn main:app, Uvicorn открывает сокет, слушает порт и по HTTP передаёт входящие байты в FastAPI. TestClient делает принципиально другое: он вызывает приложение напрямую через ASGI-интерфейс в том же Python-процессе. Никакого сетевого сокета, никакого порта — запрос идёт в памяти.
Технически TestClient из FastAPI — реэкспорт клиента Starlette, который внутри использует библиотеку httpx. Поэтому перед первым тестом нужно установить оба пакета:
python -m pip install "pytest==8.3.4" "httpx==0.28.1"
Импорт и минимальное создание клиента выглядят так:
from fastapi.testclient import TestClient
from demo_api import app
client = TestClient(app)
Однако в тестах предпочтительнее контекстный менеджер:
with TestClient(app) as client:
response = client.get("/api/rooms/1")
Конструкция with делает две вещи: запускает lifespan-события приложения при входе в блок и корректно закрывает клиент при выходе. Это важно, если у приложения есть startup/shutdown обработчики — без with они просто не выполнятся.
Методы client.get(...), client.post(...), client.put(...) и остальные возвращают объект Response. Его можно сразу проверять: смотреть статус, разбирать тело.
Важный момент: отсутствие сети не означает изоляцию от данных. Если обработчик открывает файловую базу или меняет глобальный словарь, TestClient это не блокирует. Запрос просто идёт не через сокет, а через ASGI-вызов, но код обработчика выполняется полностью. Именно поэтому первый пример намеренно использует отдельное маленькое приложение с данными в памяти: никаких файлов, никаких побочных эффектов.
Как pytest находит файл и тестовую функцию
Когда вы запускаете python -m pytest -q из папки, pytest обходит её и ищет файлы по двум шаблонам: test_*.py и *_test.py. Файл tests.py или helpers.py по умолчанию игнорируются — pytest их не увидит, даже если внутри есть нужные функции.
Внутри подходящего файла pytest ищет функции, имя которых начинается с test. Подчёркивание после test не обязательно: testplain тоже будет найдена и запущена. Зато check_rooms — нет: она не начинается с test, и pytest пройдёт мимо.
В этом курсе используем соглашение test_имя: файлы называем test_rooms.py, функции — test_read_room, test_missing_room. Это читается ясно и соответствует общепринятой практике.
Запустить тесты из папки примера:
python -m pytest -q
Запустить конкретный файл:
python -m pytest -q test_rooms.py
Флаг -q уменьшает подробность вывода. При сбое pytest всё равно показывает ошибку и сравниваемые значения, а в конце — итог пройденных и упавших тестов.
Обратите внимание на строку no tests ran — это не успех. Это значит, что pytest ничего не нашёл и ничего не проверил. Например, файл rooms_checks.py не соответствует шаблонам поиска, а функция check_room_status не является тестовой. Если других тестов нет, pytest сообщит no tests ran. Имя rooms_test.py, напротив, соответствует шаблону *_test.py и допускается. Правильность кода в этом случае не подтверждена.
assert: статус и JSON существующей и отсутствующей комнаты
Создайте папку first_api_test и положите в неё два файла.
Первый — само маленькое приложение:
# demo_api.py
from fastapi import FastAPI, HTTPException
app = FastAPI()
rooms = {1: {"id": 1, "name": "Альфа", "capacity": 6}}
@app.get("/api/rooms/{room_id}")
def read_room(room_id: int):
room = rooms.get(room_id)
if room is None:
raise HTTPException(status_code=404, detail="Room not found")
return room
Словарь rooms задан явно и не меняется тестами. Маршрут возвращает карточку комнаты или поднимает HTTPException с кодом 404.
Второй файл — тесты:
# test_rooms.py
from fastapi.testclient import TestClient
from demo_api import app
def test_read_room():
with TestClient(app) as client:
response = client.get("/api/rooms/1")
assert response.status_code == 200
assert response.json() == {"id": 1, "name": "Альфа", "capacity": 6}
def test_missing_room():
with TestClient(app) as client:
response = client.get("/api/rooms/999")
assert response.status_code == 404
assert response.json() == {"detail": "Room not found"}
Запустите из папки first_api_test:
python -m pytest -q
Ожидаемый вывод:
2 passed in 0.XXs
response.status_code — это обычное целое число. response.json() разбирает тело ответа и возвращает соответствующий Python-объект: словарь, список, строку, число, bool или None. Обычный assert сравнивает их напрямую.
Проверка статуса 200 сама по себе недостаточна. Представьте, что обработчик вернул None вместо словаря — FastAPI сериализует это в null, статус останется 200, но assert response.json() == {"id": 1, ...} упадёт. Именно поэтому в test_read_room проверяются оба утверждения: статус и точное тело.
Когда assert не выполняется, pytest показывает само утверждение и фактическое значение — дополнительных assertion-библиотек для этого не нужно.
HTTPException является Python-исключением. Для показанного404 оно обрабатывается внутри приложения и не прерывает вызов TestClient ошибкой Python. После выхода из функции маршрута слой обработки исключений FastAPI/Starlette превращает HTTPException в Response с нужным статусом и телом {"detail": "..."}. Поэтому test_missing_room получает нормальный ответ и может проверить его содержимое.
Другая ситуация — если в обработчике возникает обычное Python-исключение, которое никто не поймал (например, ZeroDivisionError). При стандартных настройках TestClient такое исключение не превращается в ответ 500, а поднимается прямо в тесте. Тест упадёт с трейсбэком Python, а не с assert по статусу.
Для полноты: если маршрут отвечает 204 (No Content) и тело пустое, вызывать response.json() не стоит — там нечего разбирать. Проверяйте response.content == b"".
Два пройденных теста подтверждают два конкретных сценария: существующая комната возвращает точную карточку, несуществующая — 404 с понятным сообщением. Остальные маршруты и сценарии с записью в базу этими тестами не покрыты.
Попробуйте решить
Файл test_rooms.py содержит функцию check_rooms(). Будет ли она автоматически найдена pytest как тест, и как её нужно назвать по соглашению курса?
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
