Перейти к содержанию
Innovialabs

Как писать автотесты для Telegram-бота

Продвинутое4 мин

Коротко

Тестируйте хендлеры и шаги 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-тестах.

Частые вопросы

Да, но немного. Моки не поймают ошибки разметки, невалидные клавиатуры или проблемы с webhook, о которых сообщает только Telegram.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.