Урок курса

Первый тест 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 с понятным сообщением. Остальные маршруты и сценарии с записью в базу этими тестами не покрыты.

Задача 1

Файл test_rooms.py содержит функцию check_rooms(). Будет ли она автоматически найдена pytest как тест, и как её нужно назвать по соглашению курса?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

demo_api поднимает HTTPException(404, detail="Room not found") для GET /api/rooms/999. Что получит тест при вызове client.get("/api/rooms/999")?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Вместо карточки комнаты сервер вернул JSON null со статусом 200. Что вернёт response.json() и пройдёт ли assert response.json() == {"id":1,"name":"Альфа","capacity":6}?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

TestClient создан заново в каждом тесте, но обработчик изменяет общий глобальный словарь rooms. Гарантирует ли пересоздание клиента пустой словарь для следующего теста?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 5

Проверка карточки переговорной

Добавьте автоматические проверки готового просмотра карточки комнаты в Roomly. Для существующей комнаты проверяйте ожидаемую карточку целиком, для отсутствующей — ответ об отсутствии.

Успешного статуса без правильных данных недостаточно. Проверки должны замечать нарушение каждого ожидания. Само приложение не меняйте: этот пример только читает заранее заданные сведения.

Что уже дано

В этой браузерной лабе один редактор содержит только ваши тесты. Перед ними уже выполнен следующий готовый код читающего приложения без базы. app и настоящий TestClient доступны на верхнем уровне; импортировать несуществующий main.py или demo_api.py не нужно. В локальном примере теории приложение и тесты остаются двумя отдельными файлами.

from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient

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

Служебный runner создаёт собственную временную папку с test_submission.py, которая предоставляет pytest именно ваши определённые функции. Затем он запускает настоящий pytest: обнаружение, setup, вызов теста и teardown. Он не вызывает тестовые функции вручную и не дописывает assertions за вас. Для проверки чувствительности runner по очереди меняет HTTP-статус или JSON одного ответа в собственной копии поведения и восстанавливает исходное. Скрытая проверка получает только JSON-отчёт pytest и наблюдения реальных запросов. Файлы runner и управление ресурсами писать не нужно.

Что нужно сделать

Напишите два обнаруживаемых pytest теста: один для GET /api/rooms/1, второй для GET /api/rooms/999. Имена могут отличаться от стартовых, но должны начинаться с test_. В каждом используйте with TestClient(app) as client. Первый проверяет статус200 и точную карточку {"id":1,"name":"Альфа","capacity":6}, включая отсутствие лишних полей. Второй проверяет статус404 и ровно {"detail":"Room not found"}. Используйте обычные assert.

На исправном приложении оба теста должны пройти. Изменение статуса, значения, удаление или добавление поля в проверяемом JSON должно вызвать AssertionError соответствующего теста; ошибка импорта, отсутствие собранных тестов или ошибка setup не считается проверкой контракта. Не изменяйте приложение, каталог или runner и не скрывайте провал через skip/xfail. Дополнительные сценарии, база и сервер здесь не нужны.

Ввод и вывод

stdin не используется. Печатать через print в stdout ничего не нужно. После вашего кода проверка собирает верхнеуровневые тестовые функции настоящим pytest. Ввод через stdin не используется; print/stdout не требуется. Запускать Uvicorn, subprocess или pytest.main самостоятельно не нужно — runner сделает это после определения ваших тестов.

Для готового приложения pytest собирает два теста, оба проходят. Если ответ существующей комнаты сохраняет200, но capacity становится7, тест этой карточки должен упасть на assert; тест отсутствующей комнаты остаётся успешным.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задачи к уроку

Все задачи курса →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

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