Как писать автотесты для Telegram-бота
Продвинутое4 минРедакция Innovialabs
Коротко
Тестируйте хендлеры и шаги FSM изолированно — с замоканными вызовами Bot API и фейковыми апдейтами, добавьте несколько smoke-тестов в тестовой среде Telegram и запускайте всё в CI, чтобы упавший тест блокировал деплой.
Что тестировать и на каком уровне
Бот — это в основном набор хендлеров: они получают апдейт и вызывают методы Bot API. Поэтому его легко тестировать, если разделить проверки по уровням:
- Unit-тесты хендлеров — вызываете функцию хендлера напрямую с моком сообщения и проверяете, что она отправила и как изменила состояние. Быстро, без сети.
- Тесты сценариев — прогоняете фейковые апдейты через диспетчер и перехватываете исходящие вызовы Bot API. Проверяют маршрутизацию, фильтры, middleware и переходы FSM вместе.
- Интеграционные тесты — несколько сценариев в настоящей тестовой среде Telegram.
- CI — всё запускается автоматически перед каждым деплоем.
Большинство тестов — на первых двух уровнях. Они ловят основную часть регрессий за секунды.
Хендлеры и FSM в aiogram
Типичная форма из двух шагов:
from aiogram import Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import Message
router = Router()
class OrderForm(StatesGroup):
name = State()
phone = State()
@router.message(Command("order"))
async def start_order(message: Message, state: FSMContext):
await state.set_state(OrderForm.name)
await message.answer("Как вас зовут?")
@router.message(OrderForm.name)
async def process_name(message: Message, state: FSMContext):
await state.update_data(name=message.text)
await state.set_state(OrderForm.phone)
await message.answer("Ваш номер телефона?")
Хендлерам нужно лишь сообщение с методом answer и FSMContext. Достаточно мока и хранилища в памяти (с pytest-asyncio):
import pytest
from unittest.mock import AsyncMock
from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.base import StorageKey
from aiogram.fsm.storage.memory import MemoryStorage
from bot.handlers import OrderForm, process_name
@pytest.fixture
def state():
key = StorageKey(bot_id=1, chat_id=42, user_id=42)
return FSMContext(storage=MemoryStorage(), key=key)
@pytest.mark.asyncio
async def test_name_step_moves_to_phone(state):
await state.set_state(OrderForm.name)
message = AsyncMock(text="Анна")
await process_name(message, state)
assert await state.get_state() == OrderForm.phone.state
assert (await state.get_data())["name"] == "Анна"
message.answer.assert_awaited_once()
Каждый тест получает новое хранилище, поэтому состояния не протекают между тестами. Для тестов сценариев передавайте настоящие объекты Update в dispatcher.feed_update(bot, update), а сессию бота замените фейковой, которая записывает запросы вместо отправки.
Перехват вызовов Bot API в grammY
В grammY есть API transformers — функции-обёртки над каждым исходящим вызовом. В тестах трансформер записывает вызов и возвращает фейковый ответ, и до Telegram ничего не доходит:
import { Bot } from "grammy";
import { expect, test } from "vitest";
import { registerHandlers } from "../src/bot";
import { textUpdate } from "./fixtures";
test("/start sends a greeting", async () => {
const bot = new Bot("test-token", {
botInfo: { id: 1, is_bot: true, first_name: "Test", username: "test_bot" } as any,
});
const calls: { method: string; payload: any }[] = [];
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload });
return { ok: true, result: true } as any;
});
registerHandlers(bot);
await bot.handleUpdate(textUpdate("/start"));
expect(calls[0].method).toBe("sendMessage");
expect(calls[0].payload.text).toContain("Привет");
});
Переданный botInfo избавляет от вызова getMe. Для многошаговых сценариев (сессии или плагин conversations) держите один экземпляр бота на тест и отправляйте несколько апдейтов подряд, проверяя calls после каждого.
Фикстуры фейковых апдейтов
JSON, написанный вручную в каждом тесте, быстро превращается в шум. Используйте небольшую фабрику:
import type { Update } from "grammy/types";
let seq = 1;
export function textUpdate(text: string, userId = 42): Update {
const command = text.startsWith("/")
? { entities: [{ type: "bot_command" as const, offset: 0, length: text.split(" ")[0].length }] }
: {};
return {
update_id: seq++,
message: {
message_id: seq++,
date: Math.floor(Date.now() / 1000),
chat: { id: userId, type: "private", first_name: "Test" },
from: { id: userId, is_bot: false, first_name: "Test" },
text,
...command,
},
};
}
Правила, которые экономят время:
- Добавляйте entities для команд — фильтры команд могут на них опираться.
- Делайте фабрики для каждого типа апдейтов, который обрабатываете: callback query, контакты, фото, сообщения в группах.
- Берите за основу реальные апдейты из логов, удалив персональные данные.
Интеграционные тесты на тестовом сервере
У Telegram есть отдельная тестовая среда со своими аккаунтами и своим BotFather. Бот, созданный там, работает только в этой среде. Запросы Bot API идут на https://api.telegram.org/bot<token>/test/METHOD_NAME; в grammY это опция client: { environment: "test" }, другие фреймворки позволяют сменить базовый URL.
Используйте её для нескольких smoke-сценариев: бот запускается, webhook принимается, сообщения с вашей реальной разметкой и клавиатурами проходят валидацию. Чтобы действовать от лица пользователя, MTProto-клиент, авторизованный в тестовом аккаунте, может отправлять сообщения и ждать ответов. Тестовая среда не обещает хранить данные, поэтому тесты должны сами создавать всё, что им нужно.
Запуск в CI
name: bot
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt -r requirements-dev.txt
- run: pytest -q
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
needs: test означает, что красный тест останавливает деплой. Интеграционные тесты держите в отдельном job с токеном тестового бота в секретах и никогда не используйте продакшен-токен в unit-тестах.