Буквально на прошлой неделе я в очередной раз задеплоил бота в прод и тут же получил от пользователя скриншот с ошибкой. Кнопка «Подтвердить заказ» почему-то отправляла сообщение «Добро пожаловать!» вместо подтверждения. Классика.
При этом я «протестировал» бота — открыл 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, разберёмся.
