Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 9 из 10 Продвинутый 45 мин 150 XP

Тестирование и деплой 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 не нужен, сеть не задействована. Отсюда скорость — сотни запросов в секунду даже на ноутбуке. Тест читается как проза:

test_smoke.py — первые два теста
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
Тестовые файлы запускаются командой pytest (ниже) — браузерная песочница не поднимает приложение и pytest, поэтому показан реальный вывод прогона: две точки — два прошедших теста.

Два 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. Третья — граничные случаи: пустой заголовок, повторное удаление, дубли. Пишем первые две группы:

test_main.py — сценарии против сервиса задач
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
Прогон против main.py из урока 5: четыре точки — четыре прошедших теста. Тесты на обновление и удаление дописываются по той же схеме.

Третий тест интереснее первых: 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 — три строки настройки:

main.py — включаем CORS
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=["*"],
)
Блок — часть main.py: в песочнице сервер не поднимается. После него fetch с localhost:5173 получает заголовок Access-Control-Allow-Origin и отрабатывает без ошибок.

Перечисляйте домены точно, а не звёздочкой. С allow_origins=["*"] ваш API разрешает читать ответы любому сайту в интернете — и тогда страница злоумышленника может гонять запросы к вашему API от имени залогиненного пользователя, пока тот её не закрыл. В dev-окружении — localhost-порты, в проде — домен фронтенда. Методы и заголовки тоже сужайте по мере сил: для CRUD хватает allow_methods=["GET", "POST", "PUT", "DELETE"].

Переменные окружения: секреты не едут в репозиторий

В восьмом уроке мы подписывали JWT ключом SECRET_KEY, в седьмом подключали базу. В первой версии эти значения лежали строками прямо в коде — и это утечка, как только репозиторий становится публичным: любой читатель GitHub подписывает токены известным ключом. Правило продакшена простое: код одинаков во всех средах, различаются только значения, и приходят они из переменных окружения — в терминале через export, в Docker через ключ environment или файл .env, на хостинге через панель настроек.

config.py — конфигурация из окружения (запускается в браузере)
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
В браузерной песочнице переменных окружения нет — сработали значения по умолчанию, и это видно в выводе. Локально те же строки вернут то, что вы задали через export.

Второй аргумент os.getenv — значение по умолчанию. Для DEBUG оно удобно, для SECRET_KEY — опасно: дефолт «dev-only» годится только для разработки, а продакшен должен отказываться стартовать без ключа, а не молча подставлять известный всему интернету. Когда переменных становится больше пяти, переходите на pydantic-settings: класс Settings описывает переменные с типами и ограничениями, и конфигурация проверяется так же строго, как тела запросов в уроке про Pydantic.

Docker: восемь строк до переносимости

Классика деплоя — разговор «у меня же работало». У вас Python 3.12, на сервере 3.10; у вас свежие зависимости, у сервера — ошмётки от прошлого проекта. Docker закрывает вопрос радикально: образ содержит операционную основу, питон, зависимости и код. Собрали один раз — приложение запускается одинаково на ноутбуке, на VPS и в облаке. Dockerfile для нашего сервиса:

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"]
Образ собирается утилитой docker, которой в песочнице нет. Строки — по слою на команду: от базы python:3.12-slim до команды запуска uvicorn.

Восемь строк — и по ним читается вся сборка: базовый 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)
Флаг -t даёт образу имя, -p 8000:8000 пробрасывает порт контейнера на порт машины. Утилита docker недоступна в песочнице — прогон локально.

Как задеплоить FastAPI на сервер?

Деплой: VPS или платформа

Два честных пути вынести API в интернет. Первый — VPS, арендованный сервер: полный контроль, свой домен, цена от нескольких сотен рублей в месяц, но настраивать и обновлять придётся самому. Второй — платформы вроде Railway, Render или Fly.io: они берут Dockerfile из репозитория и деплоят по git push, TLS-сертификат и домен выдают из коробки. Я советую первый продакшен показывать через платформу: сэкономленное на настройке время уйдёт на фичи. VPS стоит пройти один раз — ради понимания, что вообще происходит между git push и работающим сервисом.

VPSПлатформа (Railway, Render)
Ценаот 300 руб/мес, фиксированнаябесплатный тариф, далее по потреблению
Настройкаруками: Python, nginx, TLS, systemdgit push — и готово
Масштабированиевручную: новый сервер, балансировщиккнопкой в панели
Чему учиткухне Linux и сетискорости доставки продукта

uvicorn workers: сколько процессов ставить

Один процесс uvicorn обрабатывает запросы в один поток: на четырёхъядерном сервере три ядра простаивают. Флаг --workers 4 поднимает четыре независимых процесса, каждый тянет своё ядро. В серьёзных инсталляциях uvicorn ставят под присмотр gunicorn — он управляет воркерами и перезапускает упавшие:

Продакшен-запуск на VPS
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)
gunicorn ставится отдельно (pip install gunicorn) и работает супервизором процессов. Формула из документации: workers = 2 x ядра CPU + 1.

Заметьте: каждый воркер — отдельный процесс со своей памятью. Именно поэтому хранилище данных в памяти из уроков 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")
Проверь себя
0 / 5

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?

Карточки терминов
Запомнено: 0 / 6
Практика

Соберите мини-версию тестов эндпоинта прямо в браузере — без pytest и HTTP. Напишите функцию post_note(payload), которая имитирует POST /notes: валидирует словарь моделью NoteCreate (title обязателен, минимум 1 символ; done по умолчанию False) и возвращает кортеж (201, модель) при успехе либо (422, первый элемент errors) при ValidationError. Затем напишите два «теста» с assert — test_create_ok и test_create_bad_title — и запустите их, напечатав итоговый счёт.

practice.py
Вопросы и ответы по уроку

Как тестировать 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.

Проверьте знания по FastAPI

В челлендже — 20 задач по FastAPI, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по FastAPI: 20 задач