Metadata-Version: 2.3
Name: aiogram_bot_tester
Version: 0.4.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-Dist: pytest-asyncio>=1.3.0
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
```

> [!IMPORTANT]
> `aiogram_bot_tester` additionaly installs `pytest-asyncio` for async tests.
> The library code itself doesn't depend on that package, it is just for convinience.

## 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:
    tester = BotTester.from_routers(router)

    # For simpler cases, when you have just a ``Bot`` and a ``Dispatcher`:
    # tester = BotTester(bot=bot, dispatcher=dispatcher)

    response = await tester.send_command("start")
    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.contains("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

Each interaction with a `BotTester` returns a `Response` object, which represents answer generated by a Telegram bot.

### contains(*texts)

Returns `True` if this response text contains any of the given `texts`, `False` otherwise.

Example:

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

### matches(*regexes)

Returns `True` if this response text matches any of the given `regexes`, `False` otherwise.
`regexes` may be a list of either `str`ings or compiled patterns.

Example:

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

### 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!")
```

### 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!")
```

### 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"],
])
```

### 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"],
])
```

### in_state(state)

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

Example:

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

### storage_has(**kwargs)

Returns `True` when all of the given `kwargs` are present in `storage`.

Example:

```python
assert response.storage_has(name="Bob")
```

---

## 🚀 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.contains("Echo: Hello")

    response = await tester.click_inline_button("Press me")
    assert response.contains("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.contains("Привет")
```

### 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

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

### contains(*texts)

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

Пример:

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

### matches(*regexes)

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

Пример:

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

### has_inline_button(label)

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

Привет:

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

### has_reply_button(label)

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

Example:

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

### has_inline_keyboard_like(keyboard)

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

Example:

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

### has_reply_keyboard_like(keyboard)

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

Example:

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

### in_state(state)

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

Пример:

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

### storage_has(**kwargs)

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

Пример:

```python
assert response.storage_has(name="Bob")
```


---

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

```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.contains("Echo: Hello")

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