Metadata-Version: 2.3
Name: aiogram_bot_tester
Version: 0.2.0
Summary: Test your Telegram bots easily
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Framework :: Pytest
Classifier: Typing :: Typed
Requires-Dist: aiogram>=3.28.2
Requires-Python: >=3.10
Project-URL: Homepage, https://github.com/samedit66/aiogram-bot-tester
Project-URL: Issues, https://github.com/samedit66/aiogram-bot-tester/issues
Description-Content-Type: text/markdown

# aiogram_bot_tester

![aiogram](https://img.shields.io/badge/aiogram-3.x-blue?logo=telegram)
![telegram](https://img.shields.io/badge/telegram-bot-blue?logo=telegram)
![status](https://img.shields.io/badge/status-WIP-orange)
![python](https://img.shields.io/badge/python-3.10+-blue?logo=python)

**Navigation / Навигация** - [English version](#english-version) -
[Русская версия](#русская-версия)

A lightweight testing utility for **offline testing of aiogram bots without real Telegram API calls**.

Its goal is to make it easy to test bot logic deterministically by simulating Telegram updates, intercepting bot API calls, and exposing a clean assertion-friendly response layer.

Легковесная библиотека для **оффлайн тестирования ботов, написанных на aiogram без использования интернета и Telegram API**.

Цель данного пакета состоит в том, чтобы обеспечить проверку логики работы бота при помощи симуляции реальных Telegram-запросов с использованием чистых и удобных абстракций.

## Installation / Установка

Stable version / Стабильная версия:

```bash
pip install aiogram_bot_tester
```

Latest development version / Последняя разрабатываемая версия

```bash
pip install git+https://github.com/samedit66/aiogram-bot-tester.git
```

## Quick example / Пример

```python
import aiogram
from aiogram import filters, types
from aiogram_bot_tester import BotTester
import pytest

router = aiogram.Router()

@router.message(filters.CommandStart())
async def cmd_start(message: types.Message) -> None:
    await message.answer("Hello! What's your name?")

@pytest.mark.asyncio
async def test_it() -> None:
    # Make a tester
    tester = BotTester.from_routers(router)

    # If you have a `Bot` instance with a `Dispatcher`, use the following:
    # tester = BotTester(bot=bot, dispatcher=dispatcher)

    # Send a command
    response = await tester.send_message("/start")

    # Check that ``"Hello"`` is in response
    assert response.contains("Hello")
```

---

# English Version

## Core API

### BotTester.from_routers(*routers)

Construct a `BotTester` instance from a sequence of `Routers`s.

```python
tester = BotTester.from_routers(router)
```

### send_message(text, **kwargs)

Send a message to the bot. Returns a special `Response` object described below.

```python
response = await tester.send_message("/start")
assert response.text == "Hello"
```

### send_command(command, *command_args, prefix="/")

Helper to make sending commands more easily.

Instead of

```python
await tester.send_message(f"/sum {a} {b}")
```

you can simply write 

```python
await tester.send_command("sum", a, b)
```

### click_reply_button(text, **kwargs)

Simulates clicking a reply button. Actually, it's just another way of calling `send_message`. Exists just to clarify intention.

```python
response = await tester.click_reply_button("Option A")
```

### click_inline_button(label)

Simulates clicking an inline button.

```python
response = await tester.click_inline_button("Press")
```

---

## Response object

Each interaction with a `BotTester` returns a `Response` object.

```python
@dataclass
class Response:
    text: str | None
    state: State | None
    message: object | None
```

### Response.text

Last bot message text.

### Response.state

FSM state.

Example:

```python
class Form(StatesGroup):
    name = State()
    surname = State()
    age = State()

# Inside a test:

response = await tester.send_message("/start")
assert response.state == Form.name # in_state() method does the same
```

### Response.contains(substring)

Returns `True` if this response text contains the given `substring`, `False` otherwise.

Example:

```python
assert response.contains("Hello")
```

### Response.matches(regex)

Returns `True` if this response text matches the given `regex`, `False` otherwise.

Example:

```python
assert response.matches("\d+")
```

### Response.has_inline_button(label)

Returns `True` if this response has an inline button with `label`, `False` otherwise.

Example:

```python
assert response.has_inline_button("Click me!")
```

### Response.has_reply_button(label)

Returns `True` if this response has a button with `label`, `False` otherwise.

Example:

```python
assert response.has_reply_button("Click me!")
```

### Response.has_inline_keyboard_like(keyboard)

Returns `True` if this response has the specified inline `keyboard`, `False` otherwise.

Example:

```python
assert response.has_inline_keyboard_like([
    ["Yes", "No"],
    ["Cancel"],
])
```

### Response.has_reply_keyboard_like(keyboard)

Returns `True` if this response has the specified reply `keyboard`, `False` otherwise.

Example:

```python
assert response.has_reply_keyboard_like([
    ["1", "2", "3"],
    ["4", "5", "6"],
])
```

### Response.in_state(state)

Returns `True` if after this response the state is `state`, `False` otherwise.

Example:

```python
assert response.in_state(Form.name)
```

---

## 🚀 Full example

```python
import pytest
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup

from aiogram_bot_tester import BotTester

router = Router()

@router.message()
async def echo(message: Message):
    await message.answer(
        f"Echo: {message.text}",
        reply_markup=InlineKeyboardMarkup(
            inline_keyboard=[
                [InlineKeyboardButton(text="Press me", callback_data="press")]
            ]
        ),
    )

@router.callback_query(F.data == "press")
async def on_press(callback: CallbackQuery):
    await callback.message.answer("Button clicked!")

@pytest.mark.asyncio
async def test_full_flow():
    tester = BotTester.from_routers(router)

    response = await tester.send_message("Hello")
    assert response.text == "Echo: Hello"

    response = await tester.click_inline_button("Press me")
    assert response.text == "Button clicked!"
```

------------------------------------------------------------------------

# Русская версия

---

## Core API

### BotTester.from_routers(*routers)

Создает объект `BotTester` из набора роутеров.

```python
tester = BotTester.from_routers(router1, router2, router3)
```

### send_message(text, **kwargs)

Симулирует отправку сообщения боту. Возвращает специальный объект `Response`, рассматриваемый далее.

```python
response = await tester.send_message("/start")
assert response.text == "Привет"
```

### send_command(command, *command_args, prefix="/")

Вспомогательная функция для более легкой отправки команд.

Вместо

```python
await tester.send_message(f"/sum {a} {b}")
```

можно просто писать

```python
await tester.send_command("sum", a, b)
```

### click_reply_button(text, **kwargs)

Симулирует нажатие на reply-кнопку. На самом деле, это просто синоним для метода `send_message`, но с названием отражающим суть.

```python
response = await tester.click_reply_button("Вариант А")
```

### click_inline_button(label)

Симулирует нажатие на inline-кнопку.

```python
response = await tester.click_inline_button("Нажми")
```

---

## Response object

В результате любого взаимодействия с `BotTester`, возвращается объект-ответ `Response`.s

```python
@dataclass
class Response:
    text: str | None
    state: State | None
    message: object | None
```

### Response.text

Последнее сообщение бота.

### Response.state

Состояние.

Пример:

```python
class Form(StatesGroup):
    name = State()
    surname = State()
    age = State()

# Внутри теста:

response = await tester.send_message("/start")
assert response.state == Form.name # in_state() делает аналогичное действие
```

### Response.contains(substring)

Возвращает `True`, если текст ответа содержит указанную подстроку, иначе `False`.

Пример:

```python
assert response.contains("Привет")
```

### Response.matches(regex)

Возвращает `True`, если текст ответа соответствует регулярному выражению, иначе `False`.

Пример:

```python
assert response.matches("\d+")
```

### Response.has_inline_button(label)

Возвращает `True`, если в ответе есть inline-кнопка с указанным текстом.

Привет:

```python
assert response.has_inline_button("Нажми меня!")
```

### Response.has_reply_button(label)

Возвращает `True`, если в ответе есть reply-кнопка с указанным текстом.

Example:

```python
assert response.has_reply_button("Нажми меня!")
```

### Response.has_inline_keyboard_like(keyboard)

Возвращает `True`, если inline-клавиатура совпадает с заданной структурой.

Example:

```python
assert response.has_inline_keyboard_like([
    ["Да", "Нет"],
    ["Отмена"],
])
```

### Response.has_reply_keyboard_like(keyboard)

Возвращает `True`, если reply-клавиатура совпадает с заданной структурой.

Example:

```python
assert response.has_reply_keyboard_like([
    ["1", "2", "3"],
    ["4", "5", "6"],
])
```

### Response.in_state(state)

Возвращает `True`, если бот находится в заданном состоянии.

Пример:

```python
assert response.in_state(Form.name)
```

---

## 🚀 Полный пример теста

```python
import pytest
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup

from aiogram_bot_tester import BotTester

router = Router()

@router.message()
async def echo(message: Message):
    await message.answer(
        f"Echo: {message.text}",
        reply_markup=InlineKeyboardMarkup(
            inline_keyboard=[
                [InlineKeyboardButton(text="Нажми меня!", callback_data="press")]
            ]
        ),
    )

@router.callback_query(F.data == "press")
async def on_press(callback: CallbackQuery):
    await callback.message.answer("Кнопка нажата!")

@pytest.mark.asyncio
async def test_full_flow():
    tester = BotTester.from_routers(router)

    response = await tester.send_message("Hello")
    assert response.text == "Echo: Hello"

    response = await tester.click_inline_button("Нажми меня!")
    assert response.text == "Кнопка нажата!"
```
