Skip to content
Innovialabs

How to Write Automated Tests for a Telegram Bot

Advanced5 min

In short

Test handlers and FSM steps in isolation with mocked Bot API calls and fake updates, add a few smoke tests against the Telegram test environment, and run the whole suite in CI so a failing test blocks the deploy.

What to test and at which level

A bot is mostly a set of handlers that take an update and call Bot API methods. That makes it easy to test if you split the work into levels:

  • Unit tests of handlers — call the handler function directly with a mocked message and check what it sent and how it changed state. Fast, no network.
  • Flow tests — feed fake updates through the dispatcher and intercept outgoing Bot API calls. They check routing, filters, middlewares and FSM transitions together.
  • Integration tests — a few scenarios against the real Telegram test environment.
  • CI — everything runs automatically before each deploy.

Most tests belong to the first two levels. They catch the majority of regressions in seconds.

Handlers and FSM in aiogram

A typical two-step form:

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("What is your name?")

@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("Your phone number?")

The handlers only need a message with an answer method and an FSMContext. A mock and in-memory storage are enough (with 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="Anna")

    await process_name(message, state)

    assert await state.get_state() == OrderForm.phone.state
    assert (await state.get_data())["name"] == "Anna"
    message.answer.assert_awaited_once()

Each test gets a fresh storage, so states never leak between tests. For flow tests, pass real Update objects to dispatcher.feed_update(bot, update) and replace the bot's session with a fake one that records requests instead of sending them.

Intercepting Bot API calls in grammY

grammY has API transformers — functions that wrap every outgoing call. In tests, a transformer can record the call and return a fake response, so nothing reaches 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("Hello");
});

Passing botInfo skips the getMe call. For multi-step flows (sessions or the conversations plugin), keep one bot instance per test and send several updates in a row, checking calls after each one.

Fake update fixtures

Hand-written JSON in every test quickly becomes noise. Use a small factory:

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,
    },
  };
}

Rules that save time:

  • Add command entities — command filters may rely on them.
  • Build factories for each update type you handle: callback queries, contacts, photos, group messages.
  • Base fixtures on real updates captured from logs, with personal data removed.

Integration tests against the test server

Telegram runs a separate test environment with its own accounts and its own BotFather. A bot created there works only in that environment. Bot API requests go to https://api.telegram.org/bot<token>/test/METHOD_NAME; in grammY it is the client: { environment: "test" } option, other frameworks let you change the base URL.

Use it for a handful of smoke scenarios: the bot starts, the webhook is accepted, messages with your real formatting and keyboards pass validation. To act as a user, an MTProto client library logged in to a test account can send messages and wait for replies. The test environment does not promise to keep data, so tests should create everything they need themselves.

Running the suite in 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 means a red test stops the deploy. Keep integration tests in a separate job with a test-bot token in secrets, and never put the production token into unit tests.

FAQ

Yes, but only a few. Mocks cannot catch formatting errors, invalid keyboards or webhook problems that only Telegram reports.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.