Тестирование диалогов

Пакет maxo.dialogs.test_tools позволяет тестировать диалоги без реального Max API и без подключения к сети. Тесты выполняются быстро и детерминировано: бот работает в памяти, а отправленные сообщения перехватываются для проверки.

Используйте этот подход на уровне интеграционных тестов: реальная бизнес-логика и опционально реальная БД, но без Telegram/Max.

Компоненты

JsonMemoryStorage

Реализация FSM-хранилища в памяти. Заменяет Redis в тестах - состояния диалогов хранятся в словаре и не требуют внешних зависимостей.

from maxo.dialogs.test_tools.memory_storage import JsonMemoryStorage

storage = JsonMemoryStorage()

BotClient

Симулятор пользователя. Позволяет отправлять текстовые сообщения и нажимать кнопки без реального подключения к API.

from maxo.dialogs.test_tools import BotClient

client = BotClient(dp, user_id=1, chat_id=1)

MockMessageManager

Перехватывает исходящие сообщения бота. Вместо отправки через API сохраняет их во внутренний список - вы можете прочитать их и проверить содержимое.

from maxo.dialogs.test_tools import MockMessageManager

message_manager = MockMessageManager()

Настройка окружения

Соберите Dispatcher с JsonMemoryStorage и передайте MockMessageManager в setup_dialogs(). DefaultKeyBuilder(with_destiny=True) обязателен для корректной работы диалогов.

from maxo import Dispatcher
from maxo.dialogs import setup_dialogs
from maxo.dialogs.test_tools import BotClient, MockMessageManager
from maxo.dialogs.test_tools.memory_storage import JsonMemoryStorage
from maxo.fsm.key_builder import DefaultKeyBuilder
from maxo.fsm.storages.memory import SimpleEventIsolation

storage = JsonMemoryStorage()
message_manager = MockMessageManager()
key_builder = DefaultKeyBuilder(with_destiny=True)
event_isolation = SimpleEventIsolation(key_builder=key_builder)

dp = Dispatcher(
    storage=storage,
    key_builder=key_builder,
    events_isolation=event_isolation,
)
dp.include(my_dialog)
setup_dialogs(dp, message_manager=message_manager, events_isolation=event_isolation)

client = BotClient(dp)

Проверка рендеринга окна

Отправьте команду и прочитайте последнее сообщение из MockMessageManager. Текст доступен через msg.body.text, кнопки - через msg.body.keyboard.buttons.

await client.send("/start")

msg = message_manager.last_message()
assert "Главное меню" in msg.body.text

# Проверка кнопок (фильтруем только CallbackButton)
from maxo.types import CallbackButton
buttons = [btn.text for row in msg.body.keyboard.buttons for btn in row if isinstance(btn, CallbackButton)]
assert "Подробнее" in buttons

Используйте reset_history() чтобы очистить историю между шагами теста - так вы точно читаете сообщение от нужного действия:

await client.send("/start")
message_manager.reset_history()   # очищаем - дальше только новые сообщения

await client.click(msg, locator)
detail_msg = message_manager.last_message()

Локаторы кнопок

BotClient.click() принимает локатор - объект, который находит нужную кнопку в сообщении.

InlineButtonTextLocator

Ищет кнопку по тексту (поддерживает регулярные выражения).

from maxo.dialogs.test_tools.keyboard import InlineButtonTextLocator

# Точное совпадение
locator = InlineButtonTextLocator("Подробнее")

# Regex: любая кнопка с эмодзи-флагом
locator = InlineButtonTextLocator(r"🇷🇺.*")

InlineButtonPositionLocator

Ищет кнопку по позиции в клавиатуре (нумерация с 0).

from maxo.dialogs.test_tools.keyboard import InlineButtonPositionLocator

# Первая кнопка в первом ряду
locator = InlineButtonPositionLocator(row=0, column=0)

InlineButtonDataLocator

Ищет кнопку по значению callback-payload (поддерживает регулярные выражения).

from maxo.dialogs.test_tools.keyboard import InlineButtonDataLocator

locator = InlineButtonDataLocator(r"action:detail:\d+")

Проверка переходов

Нажатие кнопки возвращает callback_id. Передайте его в assert_answered() чтобы убедиться, что бот ответил на callback (без ответа у пользователя останется «часы» на кнопке).

await client.send("/start")
menu_msg = message_manager.last_message()
message_manager.reset_history()

callback_id = await client.click(menu_msg, InlineButtonTextLocator("Подробнее"))
message_manager.assert_answered(callback_id)

detail_msg = message_manager.last_message()
assert "Детальная страница" in detail_msg.body.text

Паттерны pytest

Выносите создание окружения в фикстуры pytest чтобы изолировать каждый тест. Используйте autouse=True чтобы автоматически очищать историю.

import pytest
import pytest_asyncio
from maxo import Dispatcher
from maxo.dialogs import setup_dialogs
from maxo.dialogs.test_tools import BotClient, MockMessageManager
from maxo.dialogs.test_tools.memory_storage import JsonMemoryStorage
from maxo.fsm.key_builder import DefaultKeyBuilder

@pytest_asyncio.fixture(scope="session")
async def dp() -> Dispatcher:
    storage = JsonMemoryStorage()
    _dp = Dispatcher(
        storage=storage,
        key_builder=DefaultKeyBuilder(with_destiny=True),
    )
    _dp.include(my_dialog)
    return _dp

@pytest_asyncio.fixture(scope="session")
async def message_manager(dp: Dispatcher) -> MockMessageManager:
    mm = MockMessageManager()
    setup_dialogs(dp, message_manager=mm)
    return mm

@pytest_asyncio.fixture(scope="session")
async def client(dp: Dispatcher) -> BotClient:
    return BotClient(dp)

@pytest.fixture(autouse=True)
def reset(message_manager: MockMessageManager):
    message_manager.reset_history()
    yield
    message_manager.reset_history()

@pytest.mark.asyncio
async def test_main_menu(client: BotClient, message_manager: MockMessageManager):
    await client.send("/start")
    msg = message_manager.last_message()
    assert "Главное меню" in msg.body.text

Полный пример

Рабочий пример с обоими сценариями (рендеринг окна и переход по кнопке): examples/dialogs_testing.py