Урок курса

Первый тест 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 как тест, и как её нужно назвать по соглашению курса?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку