Буквально на прошлой неделе я в очередной раз задеплоил бота в прод и тут же получил от пользователя скриншот с ошибкой. Кнопка «Подтвердить заказ» почему-то отправляла сообщение «Добро пожаловать!» вместо подтверждения. Классика.

При этом я «протестировал» бота — открыл Telegram, потыкал основные сценарии, убедился что /start работает. Но именно тот callback, который сломался, я проверять поленился. Знакомо?

После этого случая я решил разобраться, как нормально тестировать teloxide-ботов. Спойлер: оказалось, что готовых решений практически нет, а те что есть — либо медленные, либо требуют танцев с бубном. В итоге я написал свою библиотеку. Но обо всём по порядку.

Содержание

  1. Три способа тестировать ботов (и почему все они плохие)

  2. Что такое teremock и как он работает

  3. От простого к сложному: пишем первый тест

  4. Тестируем диалоги с состоянием

  5. Продвинутые техники

  6. Производительность: почему это быстро

  7. Ограничения (да, они есть)

Три способа тестировать ботов (и почему все они плохие)

Давайте честно посмотрим на то, как обычно тестируют Telegram-ботов.

Способ 1: «Я сам себе QA»

Открываем Telegram, пишем боту, кликаем кнопки, смотрим что происход��т. Если не упало — значит работает. Деплоим.

Проблема в том, что это не тестирование — это надежда. Надежда, что вы не забыли проверить какой-то edge case. Надежда, что после рефакторинга ничего не сломалось. Надежда, что в 3 часа ночи, когда вы вносили «маленький фикс», вы ничего не упустили.

У меня был бот для приёма заявок. 47 различных состояний диалога, куча callback-кнопок, валидация ввода. Каждый раз тестировать это вручную — минут 15-20. После третьего рефакторинга я просто перестал проверять «неважные» ветки. Угадайте, где потом были баги.

Способ 2: Тесты с реальным API

Логичная идея: создаём тестового бота, получаем токен, пишем тесты, которые реально отправляют запросы в Telegram.

#[tokio::test]
async fn test_start_command() {
    let bot = Bot::new("YOUR_TEST_TOKEN");
    // Как-то отправляем сообщение самому себе?
    // Как получить ответ бота?
    // Как дождаться его?
}

Тут начинаются проблемы:

Сеть. Тесты падают из-за таймаутов, нестабильного интернета, проблем на стороне Telegram. Flaky tests — это не тесты, это генератор ложных срабатываний.

Rate limits. Telegram ограничивает количество запросов. Запустили 50 тестов параллельно — получили бан на минуту. Запустили последовательно — ждёте 5 минут пока они пройдут.

CI/CD. Нужно хранить токены в секретах, настраивать сетевой доступ из раннера, обрабатывать случайные падения. Каждый второй пайплайн будет оранжевым.

Скорость. Каждый HTTP-запрос — это 50-200мс латентности. Тест из 10 взаимодействий выполняется секунды. Полный тестовый набор — минуты.

Способ 3: Пишем свои моки

Можно написать mock-сервер, который притворяется Telegram API. Теоретически.

На практике это означает:

  • Реализовать HTTP-сервер

  • Эмулировать структуру ответов Telegram (а там много нюансов)

  • Поддержать все методы API, которые использует ваш бот

  • Обновлять моки при изменениях в API

Это недели работы. Для большинства проектов — неоправданно.

Корень проблемы

Почему вообще так сложно? Дело в архитектуре teloxide.

Бот — это не просто набор функций. Это обработчик, которому нужен объект Bot для отправки ответов. А Bot — это HTTP-клиент, который ходит в реальный API. Нельзя просто вызвать функцию-обработчик в тесте — ей нужен весь контекст.

async fn handle_start(bot: Bot, msg: Message) -> HandlerResult {
    // bot.send_message() — это HTTP-запрос к api.telegram.org
    bot.send_message(msg.chat.id, "Привет!").await?;
    Ok(())
}

Нужно как-то подменить api.telegram.org на что-то локальное, сохранив при этом всю остальную инфраструктуру: dispatching, dependency injection, dialogue management.

Что такое teremock и как он работает

teremock (Telegram · Realistic · Mocking) — это библиотека, которая решает описанную проблему. Она поднимает локальный HTTP-сервер, имитирующий Telegram Bot API, и подменяет endpoint в объекте Bot.

┌─────────────────┐                     ┌─────────────────┐
│                 │  POST /sendMessage  │                 │
│   Ваш бот       │ ──────────────────▶ │    teremock     │
│   (teloxide)    │                     │ localhost:XXXX  │
│                 │ ◀────────────────── │                 │
└─────────────────┘  {"ok": true, ...}  └─────────────────┘
        │                                       │
        │ думает, что говорит                   │ записывает
        │ с Telegram                            │ все запросы
        ▼                                       ▼
┌─────────────────┐                     ┌─────────────────┐
│  handler_tree() │                     │ get_responses() │
│   ваша логика   │                     │  для проверок   │
└─────────────────┘                     └─────────────────┘

Ключевые особенности:

Персистентный сервер. Сервер создаётся один раз при старте теста и переиспользуется для всех dispatch-ов. Это критически важно для производительности.

Black-box тестирование. Вы взаимодействуете с ботом так же, как пользователь: отправляете сообщения, нажимаете кнопки, получаете ответы. Нет возможности напрямую менять состояние диалога — только через обработчики.

Полная типизация. Всё написано на Rust с использованием типов из teloxide. Компилятор не даст передать невалидные данные.

40+ методов API. sendMessage, sendPhoto, editMessageText, answerCallbackQuery — всё, что нужно для большинства ботов.

От простого к сложному: пишем первый тест

Начнём с минимального примера. Есть бот, который отвечает «Hello World!» на любое сообщение:

use teloxide::{
    dispatching::{UpdateFilterExt, UpdateHandler},
    prelude::*,
};

type HandlerResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;

async fn hello_world(bot: Bot, message: Message) -> HandlerResult {
    bot.send_message(message.chat.id, "Hello World!").await?;
    Ok(())
}

// Важно: handler tree ДОЛЖЕН быть вынесен в отдельную функцию
pub fn handler_tree() -> UpdateHandler<Box<dyn std::error::Error + Send + Sync + 'static>> {
    dptree::entry().branch(Update::filter_message().endpoint(hello_world))
}

Обратите внимание: handler_tree() — отдельная функция. Это не прихоть, а необходимость. В main() вы передаёте её в Dispatcher::builder(), в тестах — в MockBot::new(). Один и тот же код, разные контексты.

Теперь тест:

use teremock::{MockBot, MockMessageText};

#[tokio::test]
async fn test_hello_world() {
    // 1. Создаём mock-сообщение
    let mock_message = MockMessageText::new().text("Привет!");

    // 2. Создаём MockBot с нашим handler tree
    let mut bot = MockBot::new(mock_message, handler_tree()).await;

    // 3. Пропускаем сообщение через обработчики
    bot.dispatch().await;

    // 4. Проверяем результат
    let responses = bot.get_responses();
    let message = responses.sent_messages.last().expect("Бот ничего не отправил");
    assert_eq!(message.text(), Some("Hello World!"));
}

Что происходит под капотом:

  1. MockBot::new() запускает actix-web сервер на случайном свободном порту

  2. Создаётся Bot из teloxide, настроенный на этот локальный сервер

  3. Создаётся Dispatcher с вашим handler tree

  4. dispatch() отправляет mock-сообщение в dispatcher

  5. Бот вызывает send_message(), запрос уходит на mock-сервер

  6. Mock-сервер записывает запрос и возвращает валидный ответ

  7. get_responses() даёт доступ ко всем записанным запросам

Тест выполняется за миллисекунды. Без сети. Без токенов. Детерминированно.

Тестируем диалоги с состоянием

Hello World — это игрушечный пример. Реальные боты имеют состояние. Рассмотрим бота-калькулятор:

#[derive(Clone, Default)]
pub enum State {
    #[default]
    Start,
    AwaitingFirstNumber { operation: String },
    AwaitingSecondNumber { operation: String, first: i64 },
}

type MyDialogue = Dialogue<State, InMemStorage<State>>;

Пользовательский сценарий:

  1. Отправляет /start

  2. Видит кнопки «Сложить» и «Вычесть»

  3. Нажимает «Сложить»

  4. Получает запрос первого числа

  5. Вводит «5»

  6. Получает запрос второго числа

  7. Вводит «4»

  8. Получает «Результат: 9»

Как это протестировать? Вот так:

use teremock::{MockBot, MockCallbackQuery, MockMessageText};
use teloxide::dptree::deps;

#[tokio::test]
async fn test_full_addition_flow() {
    let mut bot = MockBot::new(
        MockMessageText::new().text("/start"),
        handler_tree()
    ).await;

    // Внедряем storage для диалогов
    bot.dependencies(deps![InMemStorage::<State>::new()]);

    // Шаг 1: /start
    bot.dispatch().await;
    assert_eq!(
        bot.get_responses().sent_messages.last().unwrap().text(),
        Some("Что вы хотите сделать?")
    );

    // Шаг 2: нажимаем кнопку
    bot.update(MockCallbackQuery::new().data("add"));
    bot.dispatch().await;
    assert_eq!(
        bot.get_responses().sent_messages.last().unwrap().text(),
        Some("Введите первое число")
    );

    // Шаг 3: вводим первое число
    bot.update(MockMessageText::new().text("5"));
    bot.dispatch().await;
    assert_eq!(
        bot.get_responses().sent_messages.last().unwrap().text(),
        Some("Введите второе число")
    );

    // Шаг 4: вводим второе число
    bot.update(MockMessageText::new().text("4"));
    bot.dispatch().await;
    assert_eq!(
        bot.get_responses().sent_messages.last().unwrap().text(),
        Some("Результат: 9")
    );
}

Несколько важных моментов:

bot.update() заменяет текущее сообщение. Не нужно создавать новый MockBot для каждого шага. Сервер персистентный, состояние сохраняется между dispatch-ами.

dependencies() работает как в продакшене. InMemStorage — это реальный storage из teloxide. В тестах можно использовать его же, или подставить mock базы данных.

Состояние меняется через обработчики. Мы не делаем set_state(State::AwaitingFirstNumber) — состояние меняется естественным образом, как у реального пользователя.

Тестирование ошибок ввода

Хороший бот обрабатывает некорректный ввод. Проверяем:

#[tokio::test]
async fn test_invalid_input_handling() {
    let mut bot = MockBot::new(
        MockMessageText::new().text("/start"),
        handler_tree()
    ).await;
    bot.dependencies(deps![InMemStorage::<State>::new()]);

    // Доходим до ввода числа
    bot.dispatch().await;
    bot.update(MockCallbackQuery::new().data("add"));
    bot.dispatch().await;

    // Пользователь вводит мусор
    bot.update(MockMessageText::new().text("не число"));
    bot.dispatch().await;
    assert_eq!(
        bot.get_responses().sent_messages.last().unwrap().text(),
        Some("Пожалуйста, введите число")
    );

    // Пользователь отправляет фото (зачем?)
    bot.update(MockMessagePhoto::new());
    bot.dispatch().await;
    assert_eq!(
        bot.get_responses().sent_messages.last().unwrap().text(),
        Some("Пожалуйста, отправьте текстовое сообщение")
    );

    // Пользователь одумался
    bot.update(MockMessageText::new().text("5"));
    bot.dispatch().await;
    assert_eq!(
        bot.get_responses().sent_messages.last().unwrap().text(),
        Some("Введите второе число")
    );
}

Три сценария в одном тесте: некорректный текст, неправильный тип сообщения, восстановление. Такой тест гораздо ценнее трёх изолированных unit-тестов.

Продвинутые техники

Инспекция запросов

Иногда важен не только текст ответа, но и как именно бот его отправил. Например, использовал ли он HTML-разметку:

#[tokio::test]
async fn test_message_formatting() {
    let mut bot = MockBot::new(
        MockMessageText::new().text("/formatted"),
        handler_tree()
    ).await;

    bot.dispatch().await;

    // sent_messages_text даёт доступ к исходному запросу
    let response = bot.get_responses().sent_messages_text.last().unwrap();

    // Проверяем текст
    assert_eq!(response.message.text(), Some("<b>Жирный</b> текст"));

    // Проверяем parse_mode в запросе
    assert_eq!(response.bot_request.parse_mode, Some(ParseMode::Html));
}

Для медиа это ещё полезнее:

#[tokio::test]
async fn test_photo_with_spoiler() {
    let mut bot = MockBot::new(
        MockMessageText::new().text("/secret"),
        handler_tree()
    ).await;

    bot.dispatch().await;

    let photo = bot.get_responses().sent_messages_photo.last().unwrap();

    // Проверяем caption
    assert_eq!(photo.message.caption(), Some("Секрет!"));

    // Проверяем флаг спойлера
    assert!(photo.bot_request.has_spoiler.unwrap_or(false));
}

Структура get_responses() содержит типизированные коллекции:

  • sent_messages — все сообщения (только Message)

  • sent_messages_text — текстовые с доступом к bot_request

  • sent_messages_photo — фото с доступом к bot_request

  • sent_messages_video, sent_messages_document, и т.д.

Кастомизация mock-объектов

Билдеры позволяют настроить любые поля:

// Сообщение от конкретного пользователя
let msg = MockMessageText::new()
    .text("Привет от Алексея")
    .from(MockUser::new()
        .id(12345)
        .first_name("Алексей")
        .username("alexey")
        .build());

// Callback с конкретным message_id
let callback = MockCallbackQuery::new()
    .data("confirm")
    .message_id(42);

// Фото с несколькими размерами
let photo = MockMessagePhoto::new()
    .caption("Красивое фото")
    .file_id("AgACAgIAAxk...", "unique_id");

Интеграция с базой данных

teremock не мешает использовать реальные зависимости:

#[tokio::test]
async fn test_save_to_database() {
    // Настраиваем тестовую БД (testcontainers, in-memory SQLite, что угодно)
    let pool = setup_test_database().await;

    let mut bot = MockBot::new(
        MockMessageText::new().text("/save важная информация"),
        handler_tree()
    ).await;

    // Внедряем пул как зависимость
    bot.dependencies(deps![pool.clone()]);

    bot.dispatch().await;

    // Проверяем ответ бота
    assert_eq!(
        bot.get_responses().sent_messages.last().unwrap().text(),
        Some("Сохранено!")
    );

    // Проверяем, что данные реально в БД
    let saved = sqlx::query!("SELECT content FROM notes")
        .fetch_one(&pool)
        .await
        .unwrap();
    assert_eq!(saved.content, "важная информация");
}

Производительность: почему это быстро

Главная оптимизация — персистентный сервер. Сравните:

Подход

50 dispatch-ей

Новый сервер на каждый dispatch

30-60 секунд

teremock (персистентный сервер)

~2 секунды

Ускорение в 15-30 раз.

Откуда берётся разница? Запуск HTTP-сервера — дорогая операция. Нужно забиндить порт, инициализировать обработчики, разогреть кеши. Если делать это 50 раз — складывается во внушительные числа.

teremock запускает сервер один раз. Все последующие dispatch() и update() работают с уже запущенным сервером.

Дополнительные оптимизации:

Stack-safe выполнение. Каждый dispatch() запускается в отдельной tokio-задаче с собственным стеком. Можно делать сотни dispatch-ей в одном тесте без переполнения стека.

Автоматические порты. Каждый MockBot получает свой порт. Можно запускать тесты параллельно без конфликтов.

Минимальные аллокации. Mock-ответы создаются лениво, только когда нужны.

На реальном проекте с 50+ тестами полный прогон занимает 10-15 секунд. Это позволяет запускать тесты после каждого сохранения файла.

Поддерживаемые методы API

teremock из коробки поддерживает:

Отправка сообщений:
sendMessage, sendPhoto, sendVideo, sendAudio, sendVoice, sendVideoNote, sendDocument, sendAnimation, sendSticker, sendLocation, sendVenue, sendContact, sendPoll, sendDice, sendInvoice, sendMediaGroup, sendChatAction

Редактирование:
editMessageText, editMessageCaption, editMessageReplyMarkup

Управление:
deleteMessage, deleteMessages, forwardMessage, copyMessage, pinChatMessage, unpinChatMessage, unpinAllChatMessages

Модераци��:
banChatMember, unbanChatMember, restrictChatMember

Прочее:
answerCallbackQuery, setMessageReaction, setMyCommands, getFile, getMe, getUpdates, getWebhookInfo

Если какой-то метод не реализован — библиотека вернёт понятную ошибку с указанием, какого endpoint не хватает.

Ограничения (да, они есть)

Было бы нечестно не упомянуть ограничения.

Асинхронный конструктор. MockBot::new() — async-функция. Требуется #[tokio::test] или другой async runtime. Это не баг, а следствие того, что нужно запустить HTTP-сервер.

Только black-box. Нет методов get_dialogue_state() или set_dialogue_state(). Это осознанное решение — если вам нужно манипулировать состоянием напрямую, возможно, стоит пересмотреть архитектуру тестов.

Не все методы API. 40+ — это много, но Telegram Bot API содержит больше. Inline mode, payments, некоторые admin-методы могут быть не реализованы.

Только localhost. Mock-сервер работает локально. Distributed testing не поддерживается (но это редкий сценарий для ботов).

Быстрый старт

[dev-dependencies]
teremock = "0.5"
#[cfg(test)]
mod tests {
    use teremock::{MockBot, MockMessageText};
    use super::handler_tree;

    #[tokio::test]
    async fn test_my_bot() {
        let mut bot = MockBot::new(
            MockMessageText::new().text("/start"),
            handler_tree()
        ).await;

        bot.dispatch().await;

        let responses = bot.get_responses();
        assert!(!responses.sent_messages.is_empty());
    }
}
cargo test

Примеры

В репозитории есть директория examples/ с полноценными ботами:

Пример

Что демонстрирует

hello_world_bot

Базовая обработка сообщений

calculator_bot

Диалоги с состоянием, callback-кнопки

deep_linking_bot

Deep linking с параметрами

album_bot

Обработка media groups

file_download_bot

Работа с файлами

phrase_bot

Интеграция с базой данных

Каждый пример содержит и код бота, и тесты. Рекомендую начать с calculator_bot — он покрывает большинство типичных сценариев.

Ссылки


Благодарности

Идея mock-тестирования teloxide-ботов не нова. Проект teloxide_tests от LasterAlex был первопроходцем в этой области и стал важным источником вдохновения для архитектурных решений teremock.

Отдельное спасибо команде teloxide за отличный фреймворк. Без их работы экосистема Telegram-ботов на Rust была бы совсем другой.


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