Тестирование и деплой FastAPI: pytest, Docker и хостинг
pytest и TestClient для HTTP-тестов, CORS для фронтенда, переменные окружения, Dockerfile на восемь строк и выбор хостинга — выводим сервис задач в продакшен.
Редакция Питоники
Пятница, 18:47. Вы добавили в API новую ручку, проверили её в Swagger, задеплоили и ушли домой. В понедельник фронтендер пишет в чат: «У нас всё сломалось». Вы открываете код и понимаете, что проверяли только новую ручку — а три старых никто не открывал, и в одной из них вы вчера переименовали поле ответа. Автоматические тесты существуют ровно для этого: они прогоняют все эндпоинты за секунды и не дают уехать в релиз тихой поломке. Вторая половина урока — про второй навык, без которого учебный проект не станет настоящим: доставка приложения с ноутбука на сервер через Docker, переменные окружения и uvicorn workers.
Это предпоследний урок курса, и всё разбираем на знакомом приложении — мини-сервисе задач с CRUD. Тесты напишем на pytest, контейнер соберём из восьмистрочного Dockerfile, а в конце — честное сравнение хостингов и чеклист перед продом.
Зачем API нужны тесты: три аргумента
Тест — это код, который проверяет другой код. Ручная проверка в Swagger — тоже тест, но такой, который вы выполняете руками, каждый раз забываете шаг и откладываете «до после релиза». Автотесты живут в репозитории, запускаются одной командой и делают три вещи. Первое: ловят регрессии — ситуации, когда новая ручка сломала старую. Второе: документируют поведение кодом — «создание возвращает 201 и тело с id» написано так, что не устареет. Третье: дают смелость рефакторить. Без тестов любое изменение кода — русская рулетка: где-то обязательно лопнет то, о чём вы уже забыли.
TestClient: HTTP-запросы без сети и сервера
Для тестов у FastAPI есть готовый инструмент — TestClient из библиотеки Starlette, которая уже стоит внутри fastapi. Он оборачивает приложение и передаёт запросы напрямую, из рук в руки: сокет не открывается, uvicorn не нужен, сеть не задействована. Отсюда скорость — сотни запросов в секунду даже на ноутбуке. Тест читается как проза:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
def test_create_task():
response = client.post("/tasks", json={"title": "Купить кофе"})
assert response.status_code == 201
assert response.json()["title"] == "Купить кофе"
.. 2 passed in 0.06s
Два assert'а на эндпоинт — разумный минимум: статус и тело. Этого хватает, чтобы поймать большинство поломок: неверный статус-код, переименованное поле, пропавшее значение. И заметьте — тесты здесь обычные функции с assert, никакой магии. Магией занимается pytest, и он же сам их находит.
pytest: первый прогон и чтение отчёта
pytest — стандарт де-факто для тестов на Python: сканирует проект, сам находит тесты, запускает их и печатает отчёт. Ставим pytest вместе с httpx — он нужен TestClient для сборки запросов, — и запускаем:
pip install pytest httpx
pytest -v
============================= test session starts ============================== platform linux -- Python 3.12.4, pytest-8.3.3, pluggy-1.5.0 rootdir: /srv/tasks collected 3 items test_main.py::test_health PASSED [ 33%] test_main.py::test_create_task PASSED [ 66%] test_main.py::test_read_missing PASSED [100%] ============================== 3 passed in 0.09s ===============================
Что в отчёте. «collected 3 items» — pytest нашёл три тестовые функции. PASSED — все assert'ы внутри прошли. Проценты — прогресс по файлу. Внизу — итог: три прошло, ноль упало, 0.09 секунды. Сотню эндпоинтов вы прогоните за секунды — против минут ручного кликанья в Swagger. Поэтому CI-системы вроде GitHub Actions запускают тесты на каждый pull request: секундная проверка против возможного часа отката релиза.
Что pytest собирает, а что молча пропускает
У pytest есть жёсткие правила поиска: файлы test_*.py или *_test.py, классы на TestSomething, функции на test_*. Файл check_api.py с самыми идеальными тестами будет пропущен без предупреждения: pytest отчитается «collected 0 items» и завершится. Когда «тесты не запускаются», первым делом проверяйте имена файлов и функций, а вторым — из какой папки вы запускаете команду: pytest ищет тесты, начиная с текущего каталога.
Тесты CRUD: счастливый путь, 404 и 422
Покрытие CRUD-сервиса складывается из трёх групп. Первая — happy path: создать, прочитать, обновить, удалить, и всё возвращается ровно по контракту. Вторая — ошибки: несуществующий id обязан давать 404, кривое тело — 422. Третья — граничные случаи: пустой заголовок, повторное удаление, дубли. Пишем первые две группы:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create_and_read():
created = client.post("/tasks", json={"title": "Купить кофе"})
assert created.status_code == 201
task_id = created.json()["id"]
read = client.get(f"/tasks/{task_id}")
assert read.status_code == 200
assert read.json()["done"] is False
def test_read_missing():
response = client.get("/tasks/999")
assert response.status_code == 404
assert response.json()["detail"] == "Задача не найдена"
def test_create_without_title():
response = client.post("/tasks", json={})
assert response.status_code == 422
errors = response.json()["detail"]
assert errors[0]["loc"] == ["body", "title"]
.... 4 passed in 0.08s
Третий тест интереснее первых: FastAPI на 422 возвращает список ошибок, и в каждой — loc (где проблема), msg (что не так) и type (какой класс нарушения). Мы проверяем, что ошибка указывает на поле title — это часть контракта: фронтендер по loc подсвечивает конкретное поле формы, тестировщик пишет кейс на эту строку. Тестируйте не только «что работает», но и то, как API отказывает: отказ — тоже интерфейс.
CORS: почему фронтенд получает ошибку на ровном месте
API почти никогда не живёт один: рядом — фронтенд на React или Vue, и во время разработки они соседствуют на одной машине: сервер на localhost:8000, страница на localhost:5173. Вы открываете сайт, fetch идёт к API — и браузер выбрасывает has been blocked by CORS policy, хотя тот же запрос из curl отвечает 200 и JSON. Это не баг сервера. Браузер по политике same-origin не разрешает JavaScript читать ответы другого origin, пока сервер явно не разрешит — заголовками Access-Control-Allow-Origin.
Разрешение выдаёт middleware. Если запрос «непростой» — например, POST с JSON-телом, — браузер сначала отправляет preflight: OPTIONS-запрос, который спрашивает «а можно?», и лишь получив согласие, шлёт настоящий запрос. FastAPI умеет отвечать на OPTIONS сам, если добавить CORSMiddleware — три строки настройки:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Сервис задач")
app.add_middleware(
CORSMiddleware,
# Origin, с которых браузеру разрешено читать ответы
allow_origins=[
"http://localhost:5173",
"https://tasks.example.com",
],
allow_methods=["*"],
allow_headers=["*"],
)
Перечисляйте домены точно, а не звёздочкой. С allow_origins=["*"] ваш API разрешает читать ответы любому сайту в интернете — и тогда страница злоумышленника может гонять запросы к вашему API от имени залогиненного пользователя, пока тот её не закрыл. В dev-окружении — localhost-порты, в проде — домен фронтенда. Методы и заголовки тоже сужайте по мере сил: для CRUD хватает allow_methods=["GET", "POST", "PUT", "DELETE"].
Переменные окружения: секреты не едут в репозиторий
В восьмом уроке мы подписывали JWT ключом SECRET_KEY, в седьмом подключали базу. В первой версии эти значения лежали строками прямо в коде — и это утечка, как только репозиторий становится публичным: любой читатель GitHub подписывает токены известным ключом. Правило продакшена простое: код одинаков во всех средах, различаются только значения, и приходят они из переменных окружения — в терминале через export, в Docker через ключ environment или файл .env, на хостинге через панель настроек.
import os
DEBUG = os.getenv("DEBUG", "false") == "true"
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./tasks.db")
SECRET_KEY = os.getenv("SECRET_KEY", "dev-only")
print(DEBUG)
print(DATABASE_URL)
print(SECRET_KEY)
False sqlite:///./tasks.db dev-only
Второй аргумент os.getenv — значение по умолчанию. Для DEBUG оно удобно, для SECRET_KEY — опасно: дефолт «dev-only» годится только для разработки, а продакшен должен отказываться стартовать без ключа, а не молча подставлять известный всему интернету. Когда переменных становится больше пяти, переходите на pydantic-settings: класс Settings описывает переменные с типами и ограничениями, и конфигурация проверяется так же строго, как тела запросов в уроке про Pydantic.
Docker: восемь строк до переносимости
Классика деплоя — разговор «у меня же работало». У вас Python 3.12, на сервере 3.10; у вас свежие зависимости, у сервера — ошмётки от прошлого проекта. Docker закрывает вопрос радикально: образ содержит операционную основу, питон, зависимости и код. Собрали один раз — приложение запускается одинаково на ноутбуке, на VPS и в облаке. Dockerfile для нашего сервиса:
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Восемь строк — и по ним читается вся сборка: базовый slim-образ Python 3.12, рабочая папка, копия манифеста зависимостей, установка, копия кода, команда запуска. Порядок не случаен: COPY requirements.txt идёт раньше COPY . ., потому что Docker кэширует слои. Пока манифест зависимостей не изменился, pip не запускается заново — и сборка после правки кода занимает секунды, а не минуты ожидания установки пакетов.
Собираем и запускаем контейнер
docker build -t tasks-api .
docker run -p 8000:8000 tasks-api
INFO: Started server process [1] INFO: Waiting for application startup. INFO: Application startup complete. INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
Как задеплоить FastAPI на сервер?
Деплой: VPS или платформа
Два честных пути вынести API в интернет. Первый — VPS, арендованный сервер: полный контроль, свой домен, цена от нескольких сотен рублей в месяц, но настраивать и обновлять придётся самому. Второй — платформы вроде Railway, Render или Fly.io: они берут Dockerfile из репозитория и деплоят по git push, TLS-сертификат и домен выдают из коробки. Я советую первый продакшен показывать через платформу: сэкономленное на настройке время уйдёт на фичи. VPS стоит пройти один раз — ради понимания, что вообще происходит между git push и работающим сервисом.
| VPS | Платформа (Railway, Render) | |
|---|---|---|
| Цена | от 300 руб/мес, фиксированная | бесплатный тариф, далее по потреблению |
| Настройка | руками: Python, nginx, TLS, systemd | git push — и готово |
| Масштабирование | вручную: новый сервер, балансировщик | кнопкой в панели |
| Чему учит | кухне Linux и сети | скорости доставки продукта |
uvicorn workers: сколько процессов ставить
Один процесс uvicorn обрабатывает запросы в один поток: на четырёхъядерном сервере три ядра простаивают. Флаг --workers 4 поднимает четыре независимых процесса, каждый тянет своё ядро. В серьёзных инсталляциях uvicorn ставят под присмотр gunicorn — он управляет воркерами и перезапускает упавшие:
gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4 --bind 0.0.0.0:8000
[2025-07-14 09:00:01 +0300] [1823] [INFO] Starting gunicorn 22.0.0 [2025-07-14 09:00:01 +0300] [1823] [INFO] Booted child process (1825) [2025-07-14 09:00:01 +0300] [1823] [INFO] Booted child process (1826) [2025-07-14 09:00:01 +0300] [1823] [INFO] Booted child process (1827) [2025-07-14 09:00:01 +0300] [1823] [INFO] Booted child process (1828)
Заметьте: каждый воркер — отдельный процесс со своей памятью. Именно поэтому хранилище данных в памяти из уроков 5–6 в продакшене ломается: запрос «создать» попадёт в один воркер, «прочитать» — в другой. Решение вы уже знаете — настоящая база данных. TLS-сертификат и сжатие выносят на reverse proxy — nginx или Caddy перед приложением: итоговая схема прода — браузер, nginx с HTTPS, uvicorn workers, база.
Чеклист перед продом: шесть пунктов
- Тесты зелёные локально и в CI: команда
pytestпроходит на пустом окружении. - DEBUG=false, SECRET_KEY и DATABASE_URL приходят из переменных окружения, файл .env не в git.
- CORS перечисляет домен фронтенда, а не звёздочку.
- Запуск с
--host 0.0.0.0и workers по формуле 2 x ядра + 1. - Есть эндпоинт /health со статусом 200: по нему платформы и nginx понимают, что сервис жив.
- Логи пишутся в stdout: Docker и платформа собирают их автоматически и показывают одной кнопкой.
Каждый пункт — из реальных инцидентов: подписанный известным ключом токен, недоступный снаружи контейнер, полчаса на поиск «исчезнувших» данных между воркерами. Пройдитесь по списку перед каждым релизом: две минуты против часов расследования. А в следующем, финальном уроке соберём всё, что выучили, в один проект — REST API сервиса заметок с базой данных, поиском и JWT. Тот самый случай, когда курс заканчивается не конспектом, а ссылкой на работающий сервис. Девятый урок из десяти; весь курс по порядку — самоучитель FastAPI.
Сначала предскажи ответ в голове — это главный навык программиста.
import os
WORKERS = int(os.getenv("WEB_CONCURRENCY", "2"))
PORT = int(os.getenv("PORT", "8000"))
print(WORKERS * 2, PORT // 1000)
def add_discount(price: int, percent: int) -> int:
return price - price * percent // 100
def test_discount():
assert add_discount(1000, 20) == 800
try:
test_discount()
print("passed")
except AssertionError:
print("failed")
1. Что делает TestClient при вызове client.get("/tasks")?
2. Какой файл pytest подхватит автоматически?
3. fetch с localhost:5173 к API на localhost:8000 падает с CORS-ошибкой, хотя curl работает. Что не так?
4. Что означает флаг --workers 4 у uvicorn?
5. Зачем в Dockerfile прописывают --host 0.0.0.0?
Соберите мини-версию тестов эндпоинта прямо в браузере — без pytest и HTTP. Напишите функцию post_note(payload), которая имитирует POST /notes: валидирует словарь моделью NoteCreate (title обязателен, минимум 1 символ; done по умолчанию False) и возвращает кортеж (201, модель) при успехе либо (422, первый элемент errors) при ValidationError. Затем напишите два «теста» с assert — test_create_ok и test_create_bad_title — и запустите их, напечатав итоговый счёт.
Как тестировать API на FastAPI?
Ставим pytest и httpx, создаём файл test_main.py, заводим client = TestClient(app) и пишем обычные функции с assert на статус и тело ответа. Прогон — команда pytest (или pytest -v с подробностями). Тестируйте три группы: happy path, ошибки 404/422 и граничные случаи. Для изоляции тестов обнуляйте состояние фикстурой.
Как настроить CORS в FastAPI?
Добавьте CORSMiddleware через app.add_middleware и перечислите разрешённые origin в allow_origins — точными адресами, не звёздочкой. Без этого браузер блокирует чтение ответов с другого домена, хотя curl к тому же эндпоинту работает. Для непростых запросов (POST с JSON) FastAPI сам ответит на preflight OPTIONS.
Как развернуть FastAPI в продакшене?
Самый короткий путь — Dockerfile на восемь строк (python:3.12-slim, установка зависимостей, CMD uvicorn --host 0.0.0.0) и платформа вроде Railway или Render: деплой по git push. На VPS запускайте gunicorn с uvicorn-воркерами по числу ядер, TLS отдайте nginx или Caddy. Секреты — в переменных окружения, не в коде.
Чем uvicorn отличается от gunicorn?
uvicorn — ASGI-сервер, который запускает ваше приложение FastAPI и обрабатывает запросы. gunicorn — менеджер процессов: он порождает воркеры, следит за ними и перезапускает упавших. В продакшене их комбинируют: gunicorn -k uvicorn.workers.UvicornWorker -w 4 поднимает четыре процесса uvicorn под присмотром.
Почему pytest падает с "ImportError: httpx is required to use TestClient"?
Начиная с Starlette 0.21 TestClient построен на библиотеке httpx, и она не входит в зависимости FastAPI по умолчанию. Лечение одно: pip install httpx. В больших проектах тестовые зависимости выносят в requirements-dev.txt, чтобы продакшен-образ не таскал лишнее.
Почему curl работает, а браузер пишет "has been blocked by CORS policy"?
Политика same-origin действует только в браузере: он не разрешает JavaScript читать ответы другого origin, пока сервер не пришлёт заголовки Access-Control-Allow-*. curl — не браузер, поэтому тот же эндпоинт отвечает ему 200 и JSON. Лечится CORSMiddleware с точным списком allow_origins.
Понравился урок? Сошлитесь на него
«Без тестов любое изменение кода — русская рулетка: где-то обязательно лопнет то, о чём вы уже забыли.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
FastAPI · Урок 5
CRUD на FastAPI: POST, PUT, PATCH и DELETE методы
Строим полный CRUD на FastAPI: POST со статусом 201, PUT с защитой от 404, PATCH и DELETE — и собираем мини-сервис задач целиком.
FastAPI · Урок 8
Аутентификация по JWT: защищаем эндпоинты
Строим аутентификацию по JWT: хешируем пароли с солью, выдаём токен на /login и закрываем эндпоинты зависимостью get_current_user.
FastAPI · Урок 10
Проект: REST API сервиса заметок с базой данных
Сквозной проект курса: сервис заметок с тегами, поиском, SQLite и JWT. Структура по файлам, контракты Pydantic, тесты и README — портфолио-проект за пару вечеров.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
flask тестирование pytest test_clientтестирование flask api pytest
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
pytest flask фикстуры conftestфикстура test_client flask
FastAPI · Урок 2
Первое приложение на FastAPI: маршруты, uvicorn и Swagger
Устанавливаем FastAPI, пишем первый эндпоинт, запускаем сервер uvicorn и открываем Swagger — документацию, которая пишет себя сама.
fastapi первое приложениеuvicorn запуск
Проверьте знания по FastAPI
В челлендже — 20 задач по FastAPI, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.
Тест по FastAPI: 20 задач