Без автотестов любой бот сложнее «эхо-чата» через несколько месяцев превращается в чёрный ящик: разработчик боится менять код, потому что не уверен, что не сломает воронку оплаты или регистрацию. Для бота в MAX (мессенджер от VK Tech) ситуация осложняется тем, что апдейты приходят асинхронно, состояние FSM живёт в Redis, ответы уходят во внешний сервис, а пользователь может нажать любую кнопку в любой момент. Разберём пирамиду тестов для MAX-бота — от юнитов на handlers до E2E через тестовый аккаунт MAX и нагрузки на webhook.
Пирамида тестов для MAX-бота
Классическая пирамида адаптируется под специфику чат-бота так:
- Unit (60–70% от объёма) — handlers как функции, валидаторы, парсеры команд, форматтеры, чистая бизнес-логика. Запускаются за миллисекунды, моков минимум.
- Integration (20–30%) — handler плюс диспетчер плюс мок MAX Bot API плюс реальное хранилище (Redis/SQLite в Docker). Проверяют связку «нажал кнопку → сменилось состояние → отправилось сообщение».
- E2E (5–10%) — настоящий бот в staging, второй MAX-аккаунт под автоматизацию шлёт реальные сообщения и проверяет ответы. Медленно, нестабильно, но единственный способ поймать проблемы с самим Bot API мессенджера.
Не пытайтесь перевернуть пирамиду. E2E соблазнительны, но 50 таких тестов будут падать через раз и блокировать релизы. Берите их только на критичные пути: оплата, регистрация, основной use-case.
Юнит-тесты handlers
Handler в современных фреймворках — это асинхронная функция, принимающая message или callback_query и state. Тестировать её можно как обычную функцию, подсунув моки:
import pytest
from unittest.mock import AsyncMock, MagicMock
from app.handlers.start import cmd_start
@pytest.mark.asyncio
async def test_cmd_start_greets_new_user():
message = MagicMock()
message.from_user.id = 12345
message.from_user.first_name = "Иван"
message.answer = AsyncMock()
state = AsyncMock()
await cmd_start(message, state)
message.answer.assert_called_once()
args, kwargs = message.answer.call_args
assert "Иван" in args[0]
assert kwargs.get("reply_markup") is not None
state.clear.assert_awaited_once()
Главный приём — структурировать код так, чтобы handler был тонкой обёрткой над сервисами. Тогда юнит-тестов на handlers нужно мало (smoke), а основная логика покрывается отдельно — без моков message. Это и быстрее, и стабильнее: при смене SDK MAX обновляются только тонкие обёртки, а тесты на сервисный слой остаются нетронутыми.
Интеграционные тесты с моком MAX Bot API
Для интеграции HTTP-клиент бота подменяется на запись вызовов. На Python удобны responses, respx, httpx_mock — они перехватывают исходящие запросы и отвечают подготовленными JSON-ами:
import pytest
from pytest_httpx import HTTPXMock
from app.bot import dispatcher, bot
from tests.factories import build_message_update
@pytest.mark.asyncio
async def test_help_command_sends_menu(httpx_mock: HTTPXMock):
httpx_mock.add_response(
url="https://botapi.max.ru/messages",
method="POST",
json={"message_id": 1, "ok": True},
)
update = build_message_update(text="/help", user_id=42)
await dispatcher.feed_update(bot, update)
requests = httpx_mock.get_requests(method="POST")
assert len(requests) == 1
payload = requests[0].read().decode()
assert "Доступные команды" in payload
assert "inline_keyboard" in payload
Принципиально важный момент — между бизнес-логикой и MAX Bot API всегда стоит наш интерфейс. Тогда в тесте подменяем не транспорт, а свой класс заглушкой с предзаготовленными ответами. Это даёт ещё один бонус: легко проверить редкие сценарии (таймаут API, 429 rate limit, 500 от MAX), которые сложно воспроизвести с реальным сервисом.
E2E через тестовый аккаунт MAX
Когда нужна полная гарантия — заводим отдельный MAX-аккаунт на виртуальный номер и пишем сценарии, которые шлют реальные сообщения тестовому боту. Автоматизация регистрации в MAX делается через API мессенджера или через скриптинг клиента, ответы проверяются на текст и наличие кнопок:
import pytest
from tests.max_client import MaxTestClient
@pytest.mark.e2e
@pytest.mark.asyncio
async def test_registration_flow():
async with MaxTestClient(session="qa_session") as client:
await client.send_message("@bot_staging", "/start")
greeting = await client.wait_for_response(timeout=10)
assert "Добро пожаловать" in greeting.text
assert any(btn.text == "Зарегистрироваться" for row in greeting.buttons for btn in row)
await client.click_button("Зарегистрироваться")
ask_name = await client.wait_for_response(timeout=10)
assert "имя" in ask_name.text.lower()
await client.send_message("@bot_staging", "Иван Петров")
ask_phone = await client.wait_for_response(timeout=10)
assert "телефон" in ask_phone.text.lower()
Тестовый аккаунт должен быть строго отдельным, не личным. Сессия хранится локально и привязана к номеру — её утечка равна угону аккаунта. В CI E2E запускается ночью или вручную перед релизом: сервис мессенджера может расценить высокий темп как подозрительную активность, поэтому ставьте задержки и не гоняйте такие тесты на каждый PR.
Тестирование FSM
FSM — самая хрупкая часть бота. Регрессии в воронке регистрации или заказа находят пользователи, и это плохо. Покрывайте каждый переход через все ветки:
import pytest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.base import StorageKey
from app.flows.lead import LeadFlow
from app.bot import dispatcher, bot
from tests.factories import build_message_update
@pytest.mark.asyncio
async def test_lead_flow_full_path():
storage = MemoryStorage()
state = FSMContext(
storage=storage,
key=StorageKey(bot_id=1, chat_id=1, user_id=1),
)
await state.set_state(LeadFlow.waiting_name)
await dispatcher.feed_update(bot, build_message_update(text="Иван Петров"))
assert await state.get_state() == LeadFlow.waiting_phone
await dispatcher.feed_update(bot, build_message_update(text="+79991234567"))
assert await state.get_state() == LeadFlow.waiting_confirmation
data = await state.get_data()
assert data["name"] == "Иван Петров"
assert data["phone"] == "+79991234567"
Edge cases, которые легко забыть и которые ломаются чаще всего:
- Команда
/cancelв любом состоянии — должна сбрасывать FSM и возвращать в главное меню. - Невалидный ввод —
+7abcвместо телефона: остаёмся в том же состоянии, шлём подсказку. - Timeout — сессия живёт сутки, потом сбрасывается; проверяйте, что бот корректно реагирует на «вернувшегося» пользователя.
- Параллельные апдейты от одного пользователя — клик по двум кнопкам подряд должен обрабатываться идемпотентно.
- Старая клавиатура в истории — кнопка из позавчерашнего сообщения не должна ронять handler.
Тестирование платежей
Платежи нельзя проверять «на проде». Используйте sandbox-окружения провайдеров:
- ЮKassa test —
test_ключи, тестовые карты с разными сценариями (успех, отказ банка, 3DS, недостаток средств). - Tinkoff test — тестовый терминал и набор карт-сценариев в личном кабинете.
- CloudPayments test — публичный ID
test_api_*, карта4242 4242 4242 4242для успеха,4111 1111 1111 1112для отказа.
Сам тест проверяет два момента: бот корректно создал инвойс и корректно обработал webhook от платёжного провайдера (записал транзакцию в БД, выдал доступ, прислал подтверждение):
@pytest.mark.asyncio
async def test_successful_payment_grants_access(httpx_mock):
httpx_mock.add_response(
url="https://api.yookassa.ru/v3/payments",
method="POST",
json={
"id": "test_payment_1",
"status": "succeeded",
"amount": {"value": "490.00", "currency": "RUB"},
"metadata": {"user_id": "12345", "tariff": "basic"},
},
)
await payment_webhook_handler({
"event": "payment.succeeded",
"object": {
"id": "test_payment_1",
"status": "succeeded",
"metadata": {"user_id": "12345", "tariff": "basic"},
},
})
user = await users_repo.get(12345)
assert user.tariff == "basic"
assert user.paid_until > datetime.utcnow()
Обязательно проверяйте идемпотентность webhook: повторный вызов не должен начислять доступ дважды. Это типовой кейс — провайдер ретраит при таймауте, и наивная реализация удваивает срок подписки.
Тестирование интеграций
MAX-бот почти всегда ходит в CRM, LLM, платёжку, внешние API. Реальные походы в тестах — медленно и нестабильно. Подходы:
- Моки CRM —
responsesилиhttpx_mock(Python),nock(Node) перехватывают HTTP. Записываете ожидаемый запрос/ответ, проверяете, что бот отправил правильный payload. - Record/replay —
VCR.py(Python),nock recorder(Node). Первый запуск идёт в реальное API, ответ пишется в YAML/JSON. Дальше тест играет запись. - Контрактные тесты —
Pact. CRM публикует контракт, бот проверяет, что отправляет совместимый запрос. Защищает от того, что CRM-команда поменяет схему и сломает прод.
Для LLM (YandexGPT, GigaChat, OpenAI-совместимые) record/replay особенно полезен — иначе каждый прогон тестов стоит денег и зависит от настроения провайдера:
import vcr
import pytest
my_vcr = vcr.VCR(
cassette_library_dir="tests/cassettes",
record_mode="once",
filter_headers=["authorization", "x-api-key"],
)
@my_vcr.use_cassette("llm_summary.yaml")
@pytest.mark.asyncio
async def test_summarize_chat():
summary = await llm_client.summarize("Длинный диалог пользователя из MAX")
assert len(summary) < 500
assert "ключевые тезисы" in summary.lower()
Кассеты коммитятся в репозиторий — это часть тестов, без них прогон не воспроизводится. Не забывайте filter_headers, иначе ключи API утекут в git.
Snapshot-тесты сообщений
Для длинных текстов и сложной разметки кнопок удобны golden files. Сохраняем эталон ответа, при изменении тест падает — разработчик глазами проверяет, что новая версия лучше, и обновляет snapshot осознанно через ревью:
def test_order_summary_snapshot(snapshot):
order = Order(
id=42,
items=[OrderItem("Чизкейк", 2, 350), OrderItem("Латте", 1, 250)],
total=950,
address="Москва, Тверская, 1",
)
rendered = render_order_summary(order)
snapshot.assert_match(rendered.text, "order_summary.txt")
snapshot.assert_match(json.dumps(rendered.keyboard, ensure_ascii=False, indent=2), "order_keyboard.json")
Библиотеки: pytest-snapshot, syrupy. Особенно полезно для шаблонов, которые правят неразработчики (контент-менеджеры) — diff в PR сразу показывает, что именно поменялось в формулировке. И для длинных сообщений тоже: в assert "..." in text легко пропустить лишний перенос строки или съехавший emoji.
Property-based testing
Hypothesis (Python) генерирует сотни случайных входов и проверяет инварианты. Идеально для парсеров команд, валидаторов телефонов, форматтеров денег:
from hypothesis import given, strategies as st
from app.utils import normalize_username, parse_callback, CallbackParseError
@given(st.text(min_size=1, max_size=20))
def test_username_normalizer_idempotent(raw):
once = normalize_username(raw)
twice = normalize_username(once)
assert once == twice
@given(st.text())
def test_callback_data_parser_never_crashes(data):
try:
parse_callback(data)
except CallbackParseError:
pass
Свойства, которые имеет смысл проверять: идемпотентность нормализаторов, обратимость пары serialize/deserialize, инварианты «сумма после форматирования парсится обратно в ту же сумму», «нормализованный телефон проходит валидацию».
Fuzz-тестирование callback_data
Fuzz по callback_data особенно важен: пользователь может прислать что угодно — старую кнопку из истории чата, кнопку от другого бота, обрезанную строку из плохой сети. Парсер не должен падать с 500. Любой KeyError в обработчике callback — это потенциальный DoS:
from hypothesis import given, strategies as st, settings
from app.callbacks import dispatch_callback
@settings(max_examples=500)
@given(st.binary(max_size=64).map(lambda b: b.decode("latin-1", errors="ignore")))
async def test_callback_dispatch_handles_any_input(raw_data):
result = await dispatch_callback(raw_data, user_id=1)
assert result in {"handled", "ignored", "invalid"}
То же самое для пользовательского ввода в FSM-полях: длинные строки, emoji-zalgo, обрезанные UTF-8 байты, пустые строки, строки только из пробелов. Бот должен либо корректно валидировать, либо вежливо отказывать — но не падать.
CI: GitHub Actions / GitLab CI
На каждый PR — линт, типы, юниты, интеграция. E2E — отдельным nightly-джобом или вручную перед релизом. Минимальный pipeline на GitHub Actions:
name: tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
redis:
image: redis:7
ports: ["6379:6379"]
options: --health-cmd "redis-cli ping" --health-interval 10s
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: bot_test
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: ruff check .
- run: mypy app
- run: pytest tests/unit -v --cov=app --cov-report=xml --cov-fail-under=70
- run: pytest tests/integration -v
env:
REDIS_URL: redis://localhost:6379/0
DATABASE_URL: postgresql://postgres:test@localhost:5432/bot_test
- uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
Параллелим юниты и интеграцию в разных джобах. Кэшируем зависимости. Если CI идёт дольше 10 минут — разработчики начинают «авось пройдёт» и перестают ждать.
Покрытие кода
pytest-cov (Python), c8/nyc (Node), jest --coverage. Реалистичный таргет — 70–80%. Не гонитесь за 100%: последние 20% — это либо тривиальный код (геттеры, конфиги), либо нетестируемое без гигантских моков (стартап-инициализация). Время на 100% дешевле потратить на E2E критичных путей.
Полезный приём — --cov-fail-under=70 в CI и блок «покрытие может только расти» в правилах ревью. Если PR снижает покрытие — тест падает, разработчик дописывает тесты. Это удерживает планку без ритуальных напоминаний на ретро.
Performance и load тесты
Webhook MAX-бота должен отвечать быстро, иначе платформа ретраит апдейт. Под нагрузкой — десятки RPS на популярного бота, сотни на массовую рассылку. Проверяем:
locust— пишем сценарий «POST на webhook с типичным апдейтом», запускаем 100 виртуальных пользователей, смотрим p50/p95/p99 latency.bombardier,wrk,k6— простые HTTP-нагрузочные генераторы для разовых проверок.pytest-benchmark— локальные микробенчмарки горячих функций (парсер, форматтер, рендер клавиатуры).
Если бот не держит 50 RPS на webhook, ищите узкое место: синхронный поход в БД из handler, медленный LLM-вызов внутри обработки апдейта, отсутствие connection pool у Redis/Postgres. Хорошая практика — webhook принимает апдейт, кладёт в очередь и сразу отвечает 200; тяжёлая обработка идёт в воркере.
Регрессионные тесты на критичные сценарии
После каждой пойманной в проде ошибки — пишем регрессионный тест. Это не «лишняя бюрократия», а единственный способ гарантировать, что баг не вернётся:
@pytest.mark.regression
@pytest.mark.asyncio
async def test_regression_2026_03_15_double_charge():
"""
Баг: при двойном клике на «Оплатить» создавалось два инвойса.
Фикс: блокировка через Redis NX на 30 секунд по user_id.
"""
await pay_handler(message_user_42, state)
await pay_handler(message_user_42, state)
assert mock_payment_provider.create_invoice.call_count == 1
Регрессионные тесты помечаем тегом, чтобы при желании запускать отдельно. После года жизни проекта таких тестов накапливается 30–50 — и это бесценный актив, который позволяет рефакторить без страха.
Тестирование в pre-prod с ботом-двойником
Параллельно с боевым @your_bot держим @your_bot_staging. Тот же код, та же схема БД, отдельные Redis/Postgres, отдельные API-ключи MAX и платёжки. После каждого мерджа в main деплоится staging, прогоняются E2E через тестовый MAX-аккаунт, и только потом — прод.
Преимущества:
- Реальный MAX, реальные платежи (sandbox), реальные нагрузки от внутренних тестировщиков.
- Можно дать staging фокус-группе и собирать обратную связь до релиза.
- Гарантирует, что миграции БД и конфиг применяются корректно, а не «на проде в первый раз».
Недостаток — двойная инфраструктура. Но для бота с платежами или критичной бизнес-логикой это полностью оправдано.
Контрактные тесты с CRM
Если бот ходит в CRM (AmoCRM, Bitrix24, своя), контракт между ними — точка отказа. CRM-команда может поменять формат поля, и бот молча начнёт терять лиды. Pact решает это:
- Бот пишет контракт-консьюмер: «отправляю POST в эндпоинт лидов с полями
name,phone,source». - Pact-broker хранит контракт в общем хранилище.
- CRM прогоняет провайдер-тест: «принимаю запрос с этими полями, отвечаю 201».
- Если CRM меняет API — провайдер-тест падает в их CI раньше, чем сломается прод бота.
Для команд из 2 человек — оверкилл. Для команд по 5+ с разделением на бэкенд CRM и команду бота — обязательно.
QA процесс и runbook
Тесты — не вся история. Нужен и процесс:
- Smoke-тесты после деплоя — 5–10 ручных или E2E-проверок:
/startотвечает, оплата проходит, админка открывается, главная воронка завершается. Делает дежурный или автоматика сразу после успешного деплоя. - Runbook на критичные пути — markdown-документ с пошаговыми сценариями: «как проверить регистрацию», «как проверить оплату», «куда смотреть, если webhook не отвечает», «как откатить релиз». Хранится рядом с кодом, обновляется при изменениях воронок.
- Чек-лист релиза — миграции применены, env-переменные на месте, мониторинг настроен, откат подготовлен, фичефлаг закрыт по умолчанию.
Без runbook знание о боте живёт в голове одного разработчика. Уйдёт он в отпуск — команда неделю разбирается, как вообще проверить, что бот работает.
Антипаттерны
- Тесты, которые ходят в реальный MAX Bot API во время CI — флакают и медленно, должны использовать моки.
- Тесты, зависящие от текущего времени без
freeze_time— не воспроизводимы, падают по ночам и в новогоднюю ночь. - Один большой тест на весь сценарий вместо набора маленьких — упал и непонятно, где именно.
- Игнорирование интеграционных тестов «потому что юниты есть» — пропустите баги в работе с БД, Redis, транзакциями.
- 100% покрытие при кривых тестах — иллюзия безопасности, лучше 70% качественных.
- E2E на каждый PR — CI становится 40-минутным, разработчики мерджат вслепую.
Библиотеки тестирования
| Библиотека | Язык | Назначение |
|---|---|---|
pytest + pytest-asyncio | Python | основа для тестов async-кода |
pytest-httpx, respx, responses | Python | HTTP-моки для MAX Bot API, CRM, LLM |
aiogram test utilities | Python | feed_update и моки диспетчера |
VCR.py | Python | record/replay HTTP для LLM/CRM |
Hypothesis | Python | property-based и fuzz |
pytest-cov | Python | покрытие кода |
pytest-snapshot, syrupy | Python | snapshot-тесты сообщений |
pytest-benchmark | Python | микробенчмарки горячих функций |
freezegun | Python | заморозка времени в тестах |
testcontainers | Python/Go/Java | Postgres/Redis в Docker для интеграции |
locust, k6, bombardier | универсально | нагрузочное тестирование webhook |
Pact | универсально | контрактные тесты с CRM |
Итого
Пирамида тестов для бота в MAX: 60–70% юнитов на чистые функции и handlers с моками, 20–30% интеграции через pytest-httpx и responses с реальным Redis в Docker, 5–10% E2E через тестовый MAX-аккаунт против бота-двойника. Платежи — через sandbox ЮKassa/Tinkoff с обязательной проверкой идемпотентности webhook. LLM и CRM — через record/replay (VCR.py), кассеты в репозитории. FSM покрываем переход за переходом, не забывая edge cases (/cancel, timeout, невалидный ввод, параллельные клики, старая клавиатура). Property-based для парсеров и валидаторов, snapshot для длинных сообщений и клавиатур, fuzz для callback_data. CI на каждый PR — линт + типы + юниты + интеграция за ≤10 минут, E2E — nightly. Покрытие — 70–80% с --cov-fail-under, не гонитесь за 100%. Регрессионные тесты на каждый пойманный баг с тегом regression. Pre-prod с @bot_staging — обязателен для ботов с платежами. Контрактные тесты с CRM — для команд от 5 человек. И не забывайте про runbook — без него знания живут в голове одного разработчика.
Частые вопросы
Какая пирамида тестов нужна боту в MAX?
Классическая пирамида адаптируется под специфику чат-бота так. Unit (60–70% от объёма) — handlers как функции, валидаторы, парсеры команд, форматтеры, чистая бизнес-логика. Запускаются за миллисекунды, моков минимум. Integration (20–30%) — handler плюс диспетчер плюс мок MAX Bot API плюс реальное хранилище (Redis/SQLite в Docker); проверяют связку «нажал кнопку → сменилось состояние → отправилось сообщение». E2E (5–10%) — настоящий бот в staging, второй MAX-аккаунт автоматизированно шлёт реальные сообщения и проверяет ответы. Не пытайтесь перевернуть пирамиду: 50 E2E будут падать через раз и блокировать релизы. Берите их только на критичные пути: оплата, регистрация, основной use-case.
Как тестировать handlers MAX-бота через мок Bot API?
Handler в современных фреймворках — это асинхронная функция, принимающая message или callback_query и state. Тестировать её можно как обычную функцию, подсунув моки: MagicMock на message, AsyncMock на answer и state. Для интеграции HTTP-клиент бота подменяется на запись вызовов: на Python удобны pytest-httpx, respx, responses — они перехватывают исходящие запросы к MAX Bot API и отвечают подготовленными JSON-ами. Принципиально важный момент — между бизнес-логикой и MAX Bot API всегда стоит наш интерфейс. Тогда в тесте подменяем не транспорт, а свой класс заглушкой. Это даёт ещё бонус: легко проверить редкие сценарии (таймаут, 429, 500), которые сложно воспроизвести с реальным API.
Как организовать E2E-тесты MAX-бота через тестовый аккаунт?
Заводим отдельный MAX-аккаунт на виртуальный номер и пишем сценарии, которые шлют реальные сообщения тестовому боту. Автоматизация регистрации в MAX делается через API мессенджера или скриптинг клиента. Ответы проверяются на текст и наличие кнопок: send_message, wait_for_response с таймаутом, click_button, проверка содержимого. Тестовый аккаунт должен быть строго отдельным, не личным — сессия хранится локально и привязана к номеру, утечка равна угону аккаунта. В CI E2E запускается ночью или вручную перед релизом: сервис мессенджера может расценить высокий темп как подозрительную активность, поэтому ставьте задержки и не гоняйте такие тесты на каждый PR.
Как тестировать FSM-переходы и edge cases в боте?
FSM — самая хрупкая часть бота. Покрывайте каждый переход. Создаём FSMContext с MemoryStorage, ставим начальное состояние, шлём апдейт через dispatcher.feed_update, проверяем итоговое состояние через state.get_state и сохранённые данные через state.get_data. Edge cases, которые легко забыть. Команда /cancel в любом состоянии должна сбрасывать FSM. Невалидный ввод (+7abc вместо телефона) — остаёмся в том же состоянии, шлём подсказку. Timeout — сессия живёт сутки, проверяем поведение «вернувшегося» пользователя. Параллельные апдейты от одного пользователя — клик по двум кнопкам подряд, обработка должна быть идемпотентна. Старая клавиатура из истории чата не должна ронять handler.
Как тестировать платежи в MAX-боте?
Платежи нельзя проверять на проде. Используйте sandbox провайдеров. ЮKassa test — test-ключи и тестовые карты с разными сценариями (успех, отказ банка, 3DS, недостаток средств). Tinkoff test — тестовый терминал и набор карт-сценариев. CloudPayments test — публичный test-ID и набор карт. Сам тест проверяет два момента: бот корректно создал инвойс и корректно обработал webhook от провайдера (записал транзакцию в БД, выдал доступ, прислал подтверждение). Обязательно проверяйте идемпотентность webhook: повторный вызов не должен начислять доступ дважды. Это типовой кейс — провайдер ретраит при таймауте, и наивная реализация удваивает срок подписки.
Как тестировать интеграции с CRM и LLM?
Бот почти всегда ходит в CRM, LLM, платёжку. Реальные походы в тестах медленны и нестабильны. Подходы. Моки CRM — responses или httpx_mock (Python), nock (Node) перехватывают HTTP. Записываете ожидаемый запрос/ответ, проверяете правильность payload. Record/replay — VCR.py (Python), nock recorder (Node). Первый запуск идёт в реальное API, ответ пишется в YAML/JSON, дальше тест играет запись. Контрактные тесты — Pact: CRM публикует контракт, бот проверяет, что отправляет совместимый запрос. Для LLM (YandexGPT, GigaChat) record/replay особенно полезен — иначе каждый прогон тестов стоит денег. Используйте filter_headers со списком authorization и x-api-key, чтобы ключи не утекли в git вместе с кассетой.
Что должно быть в CI пайплайне MAX-бота?
На каждый PR — линт, типы, юниты, интеграция. E2E — отдельным nightly-джобом или вручную перед релизом. Минимальный GitHub Actions pipeline. Поднимаем services redis (image redis:7) и postgres (image postgres:16). Чекаут, setup-python с кэшем pip. ruff check, mypy app. pytest tests/unit с pytest-cov и cov-fail-under=70. pytest tests/integration с REDIS_URL и DATABASE_URL. Загрузка покрытия в codecov. Параллелим юниты и интеграцию в разных джобах. Кэшируем зависимости. Если CI идёт дольше 10 минут — разработчики начинают «авось пройдёт» и перестают ждать. Реалистичный таргет покрытия — 70–80%, не гонитесь за 100%: последние 20% это либо тривиальный код, либо нетестируемое.