Буквально на прошлой неделе я в очередной раз задеплоил бота в прод и тут же получил от пользователя скриншот с ошибкой. Кнопка «Подтвердить заказ» почему-то отправляла сообщение «Добро пожаловать!» вместо подтверждения. Классика.
При этом я «протестировал» бота — открыл Telegram, потыкал основные сценарии, убедился что /start работает. Но именно тот callback, который сломался, я проверять поленился. Знакомо?
После этого случая я решил разобраться, как нормально тестировать teloxide-ботов. Спойлер: оказалось, что готовых решений практически нет, а те что есть — либо медленные, либо требуют танцев с бубном. В итоге я написал свою библиотеку. Но обо всём по порядку.
Содержание
Три способа тестировать ботов (и почему все они плохие)
Давайте честно посмотрим на то, как обычно тестируют 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!")); }
Что происходит под капотом:
MockBot::new()запускает actix-web сервер на случайном свободном портуСоздаётся
Botиз teloxide, настроенный на этот локальный серверСоздаётся
Dispatcherс вашим handler treedispatch()отправляет mock-сообщение в dispatcherБот вызывает
send_message(), запрос уходит на mock-серверMock-сервер записывает запрос и возвращает валидный ответ
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>>;
Пользовательский сценарий:
Отправляет
/startВидит кнопки «Сложить» и «Вычесть»
Нажимает «Сложить»
Получает запрос первого числа
Вводит «5»
Получает запрос второго числа
Вводит «4»
Получает «Результат: 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_requestsent_messages_photo— фото с доступом кbot_requestsent_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/ с полноценными ботами:
Пример | Что демонстрирует |
|---|---|
| Базовая обработка сообщений |
| Диалоги с состоянием, callback-кнопки |
| Deep linking с параметрами |
| Обработка media groups |
| Работа с файлами |
| Интеграция с базой данных |
Каждый пример содержит и код бота, и тесты. Рекомендую начать с calculator_bot — он покрывает большинство типичных сценариев.
Ссылки
Crates.io: https://crates.io/crates/teremock
Документация: https://docs.rs/teremock
Благодарности
Идея mock-тестирования teloxide-ботов не нова. Проект teloxide_tests от LasterAlex был первопроходцем в этой области и стал важным источником вдохновения для архитектурных решений teremock.
Отдельное спасибо команде teloxide за отличный фреймворк. Без их работы экосистема Telegram-ботов на Rust была бы совсем другой.
Если статья была полезна — ставьте звёздочку на GitHub. Если нашли баг или не хватает какого-то метода API — открывайте issue, разберёмся.
