Типичная настройка CI для Telegram-бота: в секретах лежит токен, тесты ходят в реальный API, пайплайн занимает 10-15 минут и периодически падает на ровном месте. Таймауты, rate limits, протухший токен, который забыли обновить. Знакомая ситуация.

В этой статье разберём, как настроить CI/CD для Telegram-бота так, чтобы не нужны были ни токены, ни сеть, ни повторные запуски упавших тестов.

Скрытая цена «настоящих» интеграционных тестов

Давайте разберёмся, что происходит, когда вы тестируете против реального Telegram API в CI.

Проблема токенов. Чтобы общаться с Telegram, нужен токен бота. Откуда он берётся в CI? Обычно из секрета. Теперь у вас есть секрет, который нужно менеджить. Кто-то ротирует его и забывает обновить GitHub. Кто-то копирует workflow в новый репозиторий и не понимает, почему тесты падают. Кто-то случайно логирует токен в отладочном выводе, и вы судорожно его отзываете.

Управление секретами звучит просто, пока вы не дебажите, почему ночной билд красный уже неделю.

Проблема сети. CI-раннеры живут в дата-центрах. В дата-центрах есть файрволы, прокси и периодические сетевые проблемы. Серверы Telegram быстрые, но «быстро» это всё равно 50-200мс на запрос. Тест, который отправляет десять сообщений, тратит минимум полсекунды только на сетевые задержки. Тестовый набор из сотни таких тестов? Минута чистого ожидания, если ничего не затаймаутится.

А таймауты случаются. Я видел тесты, которые проходят локально и падают в CI, потому что раннер оказался в регионе с повышенной задержкой до серверов Telegram. Такие падения сводят с ума, потому что они не воспроизводятся. Перезапустите тот же тест и он может пройти.

Проблема rate limits. Telegram ограничивает скорость отправки сообщений ботами. Точные лимиты зависят от факторов, которые Telegram не полностью документирует, но примерно так: отправляешь слишком много сообщений слишком быстро и получаешь временный бан. Запустите пятьдесят тестов параллельно, каждый шлёт по несколько сообщений, и вы упрётесь в эти лимиты. Тесты падают не из-за багов, а из-за чрезмерного энтузиазма.

«Решение», к которому приходит большинство команд: запускать тесты последовательно и добавлять паузы между ними. Работает, пока тестовый набор не вырастет. Последовательные тесты с сетевой задержкой и искусственными паузами складываются быстро. То, что начиналось как двухминутный пайплайн, становится десятиминутным, потом двадцатиминутным.

Спираль нестабильности. Разработчики перестают запускать полный набор локально. Пушат и надеются. CI превращается из страховки в узкое место. Когда тест падает, первая реакция «перезапустить» вместо «разобраться». В итоге некоторые тесты маркируются как «известно flaky» и полностью игнорируются. Это не тестирование. Это имитация.

Решение: мокаем локально, тестируем всё

Суть в том, что teloxide-ботам на самом деле всё равно, кто на другом конце. Им важен HTTP-эндпоинт, который говорит на протоколе Telegram Bot API. По умолчанию это api.telegram.org. Но не обязательно.

teremock поднимает локальный HTTP-сервер, реализующий 40+ методов Telegram Bot API. Когда вы создаёте MockBot, он настраивает teloxide Bot на localhost вместо Telegram. Обработчики работают ровно как в продакшене: переходы состояний, запросы к базе, отправка ответов. Только HTTP-запрос не покидает машину.

Если вы ещё не знакомы с teremock, посмотрите вводную статью. Эта статья сфокусирована на настройке CI/CD.

Простейший CI-пайплайн

Начнём с бота без базы данных. Вот полный GitHub Actions workflow:

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Swatinem/rust-cache@v2
      - run: cargo test --workspace

Всё. Никакого блока secrets. Никаких переменных окружения. Никакой специальной сетевой конфигурации.

Сравните с тем, что нужно для тестов с реальным API:

# Старый способ - не делайте так
env:
  TELOXIDE_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
  # Плюс обработка rate limits, retry, таймаутов...

Подход с teremock устраняет целую категорию настроек CI. Никаких токенов для ротации. Никаких секретов для синхронизации между репозиториями. Никаких «почему эта переменная окружения не задана» сессий отладки.

Разница в скорости тоже впечатляет. Сетевые запросы, которые занимали 50-200мс каждый, теперь выполняются за микросекунды. Тестовый набор, который шёл пять минут против реального API, завершается за пятнадцать секунд.

Добавляем PostgreSQL для ботов с состоянием

Большинство продакшн-ботов не stateless. Они отслеживают диалоги, хранят пользовательские настройки, управляют заказами или подписками. Состояние обычно живёт в PostgreSQL.

GitHub Actions позволяет легко поднять сервисный контейнер с PostgreSQL. Вот полный workflow:

name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test_db
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4
      - uses: Swatinem/rust-cache@v2

      - name: Run tests
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost/test_db
        run: cargo test --workspace

Разберём, что тут происходит.

Блок services говорит GitHub Actions запустить контейнер PostgreSQL перед вашим джобом. Для контейнера настроен health check, так что джоб подождёт, пока PostgreSQL реально будет готов принимать соединения. Никаких гонок с «connection refused».

Маппинг ports пробрасывает PostgreSQL на стандартный порт 5432. С точки зрения тестового кода это обычный PostgreSQL-сервер на localhost.

Переменная окружения DATABASE_URL сообщает sqlx, куда подключаться. Та же переменная работает и для миграций, и для #[sqlx::test], который создаёт изолированные тестовые базы.

Важная деталь: опции health check. Без них тесты могут стартовать до того, как PostgreSQL закончит инициализацию, что приведёт к спорадическим ошибкам «connection refused». Опция --health-cmd pg_isready заставляет Docker убедиться, что PostgreSQL реально принимает соединения, прежде чем объявить контейнер здоровым.

Параллельные тесты с базой в CI

Если ваши тесты используют #[serial] для предотвращения конфликтов с базой, в CI они тоже будут выполняться последовательно. Это медленно. Подробный разбор параллельного тестирования с #[sqlx::test] есть в отдельной статье.

Коротко: #[sqlx::test] создаёт свежую базу для каждого теста, прогоняет миграции и удаляет базу после завершения. Тесты не могут мешать друг другу, потому что работают буквально с разными базами. Это даёт полную параллельность: на 8-ядерном CI-раннере 8 тестов выполняются одновременно.

Workflow выше уже поддерживает это. DATABASE_URL указывает на PostgreSQL-сервер, где sqlx может создавать временные базы. Дополнительная настройка не нужна.

Кеширование для быстрых сборок

Экшен Swatinem/rust-cache@v2 кеширует скомпилированные зависимости между запусками. Обычно это сокращает время сборки на 60-80% при повторных запусках.

- uses: Swatinem/rust-cache@v2
  with:
    cache-on-failure: true

Опция cache-on-failure: true полезна при разработке: даже если тесты упали, успешная компиляция всё равно закешируется. Это ускоряет цикл «поправил и перезапустил».

Для воркспейсов с несколькими крейтами кеш работает автоматически. Для монорепозиториев с отдельными Cargo.toml (например, директория examples/) может понадобиться дополнительная настройка:

- uses: Swatinem/rust-cache@v2
  with:
    workspaces: |
      .
      examples

Добавляем Clippy и проверку форматирования

Полноценный CI-пайплайн обычно включает не только тесты. Вот что использует репозиторий teremock:

name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4
      - uses: Swatinem/rust-cache@v2
        with:
          cache-on-failure: true

      - name: Check formatting
        run: cargo fmt --all -- --check

      - name: Clippy
        run: cargo clippy --workspace --all-targets -- -D warnings

      - name: Run tests
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost/postgres
        run: cargo test --workspace

Порядок важен. Проверка форматирования мгновенная: если кто-то забыл запустить cargo fmt, лучше упасть сразу, чем ждать компиляцию. Clippy идёт следующим, потому что ловит проблемы на этапе компиляции. Тесты последними, потому что занимают больше всего времени.

Флаг -D warnings для Clippy превращает предупреждения в ошибки. Это не даёт фразе «потом поправлю это предупреждение» превратиться в «это предупреждение висит полгода».

Переменные окружения и секреты

Один из главных выигрышей мок-тестирования: секреты в CI не нужны. Но переменные окружения могут понадобиться для других целей: URL базы, feature flags, конфигурация тестов.

Для несекретных значений задавайте их прямо в workflow:

env:
  DATABASE_URL: postgres://postgres:postgres@localhost/test_db
  RUST_LOG: debug
  MY_FEATURE_FLAG: enabled

Для значений, которые отличаются между окружениями, используйте переменные GitHub:

- name: Run tests
  env:
    DATABASE_URL: ${{ vars.DATABASE_URL }}
  run: cargo test

Обратите внимание на разницу: secrets.X для секретных значений, vars.X для несекретной конфигурации. При использовании vars значения видны в логах, что упрощает отладку.

Работа с падениями тестов

Когда тесты падают в CI, нужно достаточно информации для отладки без локального воспроизведения. Несколько полезных техник:

Сохранять вывод тестов. По умолчанию Cargo перехватывает вывод тестов и показывает его только для упавших. Флаг --nocapture показывает весь вывод, но обычно это слишком шумно. Лучше использовать RUST_LOG для управления подробностью:

- name: Run tests
  env:
    RUST_LOG: my_bot=debug
    DATABASE_URL: postgres://postgres:postgres@localhost/test_db
  run: cargo test --workspace

Загружать артефакты при падении. Если тесты генерируют логи или другие артефакты, загружайте их:

- name: Upload logs on failure
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: test-logs
    path: target/test-logs/

Матричное тестирование для нескольких версий Rust. Если поддерживаете несколько версий, тестируйте все:

strategy:
  matrix:
    rust: [stable, beta, 1.83]

steps:
  - uses: actions/checkout@v4
  - uses: dtolnay/rust-toolchain@master
    with:
      toolchain: ${{ matrix.rust }}

Полный прод��кшн-пайплайн

Собираем всё вместе. Вот проверенная CI-конфигурация для Telegram-бота с базой данных:

name: CI
on:
  push:
    branches: [main]
  pull_request:

env:
  CARGO_TERM_COLOR: always

jobs:
  ci:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: postgres
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - uses: Swatinem/rust-cache@v2
        with:
          cache-on-failure: true

      - name: Check formatting
        run: cargo fmt --all -- --check

      - name: Clippy
        run: cargo clippy --workspace --all-targets -- -D warnings

      - name: Run tests
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost/postgres
        run: cargo test --workspace

      - name: Upload test artifacts on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-output
          path: target/
          retention-days: 7

Этот пайплайн:

  • Запускает PostgreSQL до старта тестов

  • Кеширует зависимости для ускорения повторных запусков

  • Быстро падает на проблемах с форматированием

  • Ловит предупреждения линтера через Clippy

  • Запускает все тесты параллельно с изоляцией баз данных

  • Сохраняет артефакты, если что-то упало

Время выполнения на прогретом кеше: 2-3 минуты. Большая часть уходит на компиляцию. Сами тесты занимают секунды.

Что вам больше не нужно

Давайте проговорим явно, от чего этот подход избавляет:

  • Никакого секрета TELOXIDE_TOKEN. Mock-серверу реальный токен не нужен.

  • Никакой retry-логики для нестабильных сетевых тестов. Сети нет, нечему быть нестабильным.

  • Никаких rate limits. Mock-сервер не ограничивает запросы.

  • Никакого --test-threads=1 для предотвращения конфликтов. Изоляция баз это решает.

  • Никаких аннотаций «известно flaky». Тесты либо проходят, либо падают. Детерминированно.

  • Никакой специальной сетевой конфигурации. Всё работает на localhost.

На практике пайплайн, который раньше шёл пятнадцать минут со случайными падениями, после миграции на этот подход укладывается в три минуты и проходит стабильно.

Собственно, в этом и цель: CI, который помогает выпускать быстрее, а не CI, который сам становится объектом для отладки.


Ссылки:


Если настраиваете CI для Telegram-бота и столкнулись с проблемами, открывайте issue на GitHub. Я отладил больше пайплайнов, чем хотелось бы, и с удовольствием помогу.