Legan Studio
Все статьи
~ 15 мин чтения

Автоматическое тестирование бота в MAX

Как тестировать бота MAX: юнит-тесты сценариев, интеграционные тесты с mock Bot API, сценарные тесты, нагрузочное тестирование.

  • MAX
  • разработка
  • тестирование

Без автотестов любой бот сложнее «эхо-чата» через несколько месяцев превращается в чёрный ящик: разработчик боится менять код, потому что не уверен, что не сломает воронку оплаты или регистрацию. Для бота в MAX (мессенджер от VK Tech) ситуация осложняется тем, что апдейты приходят асинхронно, состояние FSM живёт в Redis, ответы уходят во внешний сервис, а пользователь может нажать любую кнопку в любой момент. Разберём пирамиду тестов для MAX-бота — от юнитов на handlers до E2E через тестовый аккаунт MAX и нагрузки на webhook.

Пирамида тестов для MAX-бота

Классическая пирамида адаптируется под специфику чат-бота так:

  1. Unit (60–70% от объёма) — handlers как функции, валидаторы, парсеры команд, форматтеры, чистая бизнес-логика. Запускаются за миллисекунды, моков минимум.
  2. Integration (20–30%) — handler плюс диспетчер плюс мок MAX Bot API плюс реальное хранилище (Redis/SQLite в Docker). Проверяют связку «нажал кнопку → сменилось состояние → отправилось сообщение».
  3. 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 testtest_ ключи, тестовые карты с разными сценариями (успех, отказ банка, 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. Реальные походы в тестах — медленно и нестабильно. Подходы:

  • Моки CRMresponses или httpx_mock (Python), nock (Node) перехватывают HTTP. Записываете ожидаемый запрос/ответ, проверяете, что бот отправил правильный payload.
  • Record/replayVCR.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 решает это:

  1. Бот пишет контракт-консьюмер: «отправляю POST в эндпоинт лидов с полями name, phone, source».
  2. Pact-broker хранит контракт в общем хранилище.
  3. CRM прогоняет провайдер-тест: «принимаю запрос с этими полями, отвечаю 201».
  4. Если 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-asyncioPythonоснова для тестов async-кода
pytest-httpx, respx, responsesPythonHTTP-моки для MAX Bot API, CRM, LLM
aiogram test utilitiesPythonfeed_update и моки диспетчера
VCR.pyPythonrecord/replay HTTP для LLM/CRM
HypothesisPythonproperty-based и fuzz
pytest-covPythonпокрытие кода
pytest-snapshot, syrupyPythonsnapshot-тесты сообщений
pytest-benchmarkPythonмикробенчмарки горячих функций
freezegunPythonзаморозка времени в тестах
testcontainersPython/Go/JavaPostgres/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% это либо тривиальный код, либо нетестируемое.