Ques/Help/Req Как реализовать end-to-end-тестирование telegram-бота

XakeR

Member
Регистрация
13.05.2006
Сообщения
1 912
Реакции
0
Баллы
16
Местоположение
Ukraine
Каждый, кто когда-либо писал telegram-ботов, задавался вопросом: «А как их тестировать?» Сложно найти однозначный ответ. Например, при написании тестов для веб-приложений и API можно воспользоваться тестовым клиентом DRF или FastAPI: просто пишешь запрос и делаешь assert на полученный ответ. Мне захотелось получить подобный функционал и для тестирования telegram-бота.

Привет, Хабр. Я Михаил Выборный, python-разработчик, backend-developer в облачном провайдере beeline cloud. В этой статье я хочу поделиться опытом написания автоматизированных end-to-end-тестов без эмуляции Telegram Bot API, но с использованием тестовых аккаунтов. Мы зайдем в изолированное тестовое пространство Telegram, создадим тестового бота, подготовим фикстуру для запуска нашего приложения и напишем авторизацию для тестовых клиентов.

По ходу статьи я буду использовать инструменты следующих библиотек:


  • Python Telegram Bot — для написания бота (сокращенно PTB);


  • Pytest — для организации тестов;


  • Anyio — для асинхронных тестов и фикстур;


  • Pyrogram — для отправки тестовых сообщений;


  • asyncio.Event — для оповещения о получении ожидаемых сообщений и предотвращения излишних ожиданий;


  • contexlib — для удобного синтаксиса написания контекстных менеджеров.

Статья написана на примере реализации несложного бота для хранения фотографий и отправки их в ответ при запросе. Вы можете посмотреть исходный код приложения и тестов на GitHub. Ниже я использую короткие выдержки из официальной документации Telegram на английском – к ним даю пояснения на русском своими словами.

Альтернативы​


Перед тем как что-то писать самому, я пытался найти готовое решение и был уверен, что Google мне поможет. Но оказалось, что готового оптимального решения просто не существует. Здесь я кратко перечислю то, что мне удалось найти и почему это не подошло.

PTB Test


  • больше не поддерживается


  • интеграция с unittest (не pytest)

Aiogram Tests


  • подходит для тестирования ботов, написанных только на aiogram


  • реализует unit-тестирование через Mock-объекты (без интеграции Telegram Bot API)

Все тест-кейсы описываются как вызов конкретных handler-ов, из-за чего нет возможности протестировать их поведения целиком — от отправки пользователем сообщения до получения ответа. К примеру, непонятно, как тестировать фильтрацию входящих сообщений и их распределение по различным handler-ам и т. п.

Tg Integration


  • также не поддерживается


  • тянет устаревшие зависимости (pyrogram < 2.0.0 typing-extensions < 4.0.0)


  • практически отсутствует документация

Перед тем как начать. Регистрация APP​


Предположим, наш бот уже готов и мы переходим к тестированию. Для этого мы подготовим необходимые инструменты, чтобы написание самих тестов было кратким и понятным. Отправить сообщение, получить ответ, проверить его. Да, придется потратить время, чтобы реализовать вспомогательные фикстуры по регистрации клиентов и обработки входящих сообщений. Но, заморочившись однажды, мы сможем писать сами тесты всего в несколько строк. Их будет легко обновлять и поддерживать.

Отправлять тестовые сообщения мы будем с помощью Pyrogram. Для этого нужно зарегистрировать будущее клиентское приложение.

📍
Тут важно понимать, что для взаимодействия с Telegram API необходимо зарегистрировать свое приложение в системе Telegram — для этого потребуется действующий номер телефона. Но для запуска самих тестов будут использоваться тестовые телефонные номера. Поэтому переживать за сохранность личных данных или получение флуд-бана не стоит.

После регистрации мы получаем api_key и api_id, которые можем использовать для создания множества клиентских приложений.

The API key defines a token for a Telegram application you are going to build. This means that you are able to authorize multiple users or bots with a single API key. (© Telegram Documentation)

Тестовый бот​


Регистрация бота

Для тестирования пользовательских приложений Telegram дает доступ к Dedicated test environment (выделенная тестовая среда). Это изолированная песочница, где можно регистрировать пользователей и создавать ботов. Если аккаунты мы будем создавать динамически, используя тестовые номера, то зарегистрировать тестового бота у @BotFather проще вручную, используя свой настоящий номер.

The test environment is completely separate from the main environment, so you will need to create a new user account and a new bot with @BotFather. (© Telegram Documentation)

Как это сделать, можно прочитать тут. Я использовал приложение Telegram для iOS, кликнув 10 раз на иконку Settings (Настройки).

Как реализовать end-to-end-тестирование telegram-бота0


После этого в приложении становится доступен второй аккаунт. Это очень удобное свойство Telegram, о котором знают немногие (лично я раньше не знал). Можно использовать для различного тестирования или даже завести секретный чат с другим человеком (который тоже зарегистрировался в тестовом пространстве). Далее, как обычно, регистрируем бота у @BotFather и получаем токен. Для обращения к любому из методов нужно добавить /test/ в путь запроса.

<token>/test/METHOD_NAMEЗапуск бота во время тестов

Теперь при запуске тестов мы можем запустить наше приложение в тестовом пространстве. Для этого подготовим фикстуру.

@pytest.fixture(scope=’session’) # fixture runs Bot app only once for entire tess session async def application(): app = Application.builder().token(‘<token>’ + ‘/test’).build() await app.initialize() await app.post_init(app) # initialize does *not* call `post_init` — that is only done by run_polling/webhook await app.start() await app.updater.start_polling() yield app await app.updater.stop() await app.stop() await app.shutdown()

Обычно для запуска приложения мы используем app.run_polling(), но это заблокирует дальнейшее исполнение программы. Мы же будем запускать тесты в параллель с тем, как работает наш бот. Подробнее о том, как запускать PTB с другим асинхронным кодом, — Running PTB alongside other asyncio frameworks

📍
pytest сам по себе не асинхронный, но может работать с асинхронными тестами и фикстурами, если добавить pytestmark = pytest.mark.anyio. Подробнее: Testing with AnyIO

📍
PTB на данный момент не поддерживает работу бота в тестовом пространстве, но можно передать токен вместе с приставкой /test. Класс telegram.Bot формирует базовый путь сложением base_url и token.

# content of /telegram/_bot.py self._base_url: str = base_url + self._token self._base_file_url: str = base_file_url + self._tokenПерехватывание исключений

По умолчанию все необработанные исключение в PTB пишутся в лог. Но при запуске тестов мы хотим убедиться, что в приложении не возникало ошибок. Для этого добавим обработчик ошибок, где будем сохранять возникшие исключения в отдельное место (в моем случае это переменная класса). Позже мы сможем проверить на наличие непредвиденных исключений.

async def collect_app_exceptions_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE): self.collected_exception = context.error self.collection_event.set() # special asyncio event we are wating for while collecting replyes raise ApplicationHandlerStop # prevent any other error handlers app.add_error_handler(self.collect_app_exceptions_callback)

📍
collection_event объект класса asyncio.Event — событие, исполнение которого мы ожидаем, пока ждем ответа от бота. В случае перехватывания исключений ждать ответа дальше смысла нет, так как произошла ошибка. Но pytest об этой ошибке ничего не знает, поэтому сохраним ее здесь и сделаем re-rise позже. Вы увидите инициализацию и полное применение collection_event в следующем блоке.

Отлично, наш бот работает. Теперь можем отправлять ему тестовые сообщения. Для этого понадобится аккаунт.

Тестовый клиент-аккаунт​


Тестовые номера

Для тестирования приложения мы будем использовать тестовые номера, которые предоставляет Telegram. Мы можем сформировать тестовые номера заранее и хранить их, к примеру, в .env файле или генерировать на лету для каждой тест-сессии отдельно. Тестовый номер — это:

99966XYYYY X — номер номер DC от 1 до 3 Y — любая цифра от 0 до 9

Тестовый номер позволяет в автоматическом режиме проходить авторизацию, и мы можем создать сколь угодно много аккаунтов. К примеру, мой бот позволяет давать другим людям доступ к общей базе фотографий. Так я смогу протестировать, что один клиент загрузил фотографию, другой получил к ней доступ, а третий нет.

📍
Telegram периодически сбрасывает тестовые номера, поэтому все дальнейшие действия по авторизации и настройке telegram-клиента лучше производить непосредственно перед каждым запуском тестов.

Do not store any important or private information in the messages of such test accounts; anyone can make use of the simplified authorization mechanism – and we periodically wipe all information stored there. (© Telegram Documentation)

Создание клиента

Для упрощенной регистрации клиентов и перехватывания всех сообщений от бота отдельно для каждого клиента я инкапсулировал всю логику в отдельный класс ClientIntegration. Но в примерах здесь я удалил все опциональные настройки, чтобы не отвлекаться от сути. Код вспомогательного класса целиком можно посмотреть тут.


  1. Создадим клиента. В отличие от PTB, у Pyrogram есть нативная поддержка тестового пространства Telegram.

self.client = Client( ‘test-client’, api_id='<api_id>’, api_hash='<api_hash>’, test_mode=True, in_memory=True, phone_number=’99966′ + ‘1’ + ‘2023’, phone_code=’1′ * 5, )


  • in_memory — после авторизации сессия не будет записываться в файл test-client.session, а останется в памяти. Это позволит изолировать тесты друг от друга и не подчищать *.session файлы вручную после тестов.


  • phone_code — код подтверждения авторизации. Для тестовых номеров всегда равен номеру DC х 5 раз.

  1. Мы уже указали телефонный номер и код подтверждения. Но может быть такое, что тестовый номер не зарегистрирован. В этом случае Pyrogram попросит ввести имя и фамилию. Чтобы пройти процесс регистрации автоматически, заменим stdout и stdin:

def mock_input_callback(prompt: str = »): if ‘Enter first name: ‘ == prompt: return ‘<first name>’ if ‘Enter last name (empty to skip): ‘ == prompt: return ‘<last name>’ raise ValueError(prompt) def mock_print_callback(self, *args, **kwargs): … with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setattr(builtins, ‘input’, mock_input_callback) monkeypatch.setattr(builtins, ‘print’, mock_print_callback) …


  1. Теперь мы можем запустить приложение клиента Pyrogram. Для удобства я описал все действия в отдельном методе ClientIntegration и обернул его в asynccontextmanager. Я использую здесь контекст-менеджер, чтобы явно описать setup и teardown. Позже это будет удобно интегрировать в Pytest в качестве yield фикстуры.

@asynccontextmanager async def session_context(self) … try: # Some phonenumbers are registered already, some other not. # To be sure, handle sign up action by patching stdin/stdout. with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setattr(builtins, ‘input’, mock_input_callback) await self.client.start() # Update Telegram User properties, if they are needed for tests cases. await self.client.update_profile(‘<first name>’, ‘<last name>’, ‘<bio>’) if self.client.me.username != self.credits.username: await self.client.set_username(‘<username>’) # Clear messages to make each test isolated from others. await client.invoke( DeleteHistory(peer=await client.resolve_peer(‘<bot name>’), max_id=0, just_clear=False) ) yield self finally: await self.client.set_username(None) # cleaning up await self.client.stop()

📍
Если для авторизации клиента использовать случайно сгенерированный тестовый номер, то стоит учесть, что другой разработчик мог установить двухфакторную авторизацию для этого номера, защитив его паролем. В таком случае остается только попробовать любой другой номер.

4. В итоге можем использовать ClientIntegration.session_context для описания фикстур.

@pytest.fixture(scope=’session’) async def integration_1(): async with ClientIntegration().session_context() as integration_1: yield integration_1 @pytest.fixture(scope=’session’) async def integration_2(): async with ClientIntegration().session_context() as integration_2: yield integration_2 …

📍
Я использую параметр scope=’session’, чтобы инициализировать клиента один раз для всей сессии. Это сократит время на подготовку фикстур, а поскольку сам клиент не хранит данные, это не нарушит изолированности тестов.

Тестирование​


Сборщик ответов

Для тестов необходимо отправлять сообщения боту и получать от него ответы. Если с первым все понятно, то для второго нужно повесить обработчик сообщений, где мы будем складывать все полученные сообщения в список, чтобы потом сделать assert на содержание полученных сообщений.

Но как долго нам ждать ответа от бота? Можно задать максимальное значение timeout, а можно указать конкретное количество. К примеру, сейчас ожидаем получить только одно сообщение, и как только оно будет получено, можно двигаться дальше.

# messages collector: async def collect_replyes_callback(self, client: Client, message: Message) self.collected_replyes.append(message) # If True, set event flag to True. No more waiting for messages: if len(self.collected_replyes) == self.collection_required_amount: self.collection_event.set() # timeout checker: async def collection_max_timeout_waiting(self, timeout: float): await sleep(timeout) self.collection_event.set() # apply handler and create Event: self.client.on_message(filters.chat(‘<bot name>’))(self.collect_replyes_callback) self.collection_event = asyncio.Event() # Wait until all messages are received or reaching timeout. timout_task = asyncio.create_task(self.collection_max_timeout_waiting(timeout)) await self.collection_event.wait() timout_task.cansel()

Теперь мы можем проверить полученные сообщения и то, что в приложении не было ошибок (мы использовали error_handler при инициализации приложения PTB в предыдущем блоке).

if self.collected_exceptions: raise self.collected_exception assert len(self.collected_replyes) == amount, ‘Received unexpected messages amount. ‘

Для удобства я описал все эти действия в отдельном методе ClientIntegration и также обернул его в @asynccontextmanager. При выходе из контекстного менеджера collect сработает блок finally, где мы будем ожидать ответы от бота.

@asynccontextmanager async def collect(self, *, amount: int, timeout: float = 2.0): self.collection_amount = amount self.collected_replyes.clear() self.collection_event = asyncio.Event() try: yield self.collected_replyes finally: timout_event = asyncio.create_task(self.collection_max_timeout_waiting(timeout)) await self.collection_event.wait() timout_event.cancel() if self.collected_exceptions: raise self.collected_exception assert len(self.collected_replyes) == amount, ‘Received unexpected messages amount. ‘

Описание тестов​


К примеру, мы хотим протестировать отправку сообщения /start и получение ответа Hello, {username}!. Благодаря фикстурам, описанным выше, этот тест-кейс может быть описан буквально тремя строчками.

pytestmark = [ pytest.mark.anyio, pytest.mark.usefixtures(‘application’) # PTB Application ] async def test_start_handler(integration: ClientIntegration): # test action: async with integration.collect(amount=1) as replyes: await integration.client.send_message(‘<bot name>’, ‘/start’) # at __aexit__ wait until all messages are received # test assertion: assert replyes[0].text == f’Hellow, {username}!’

📍
В качестве альтернативы Pyrogram может быть Telethon. Это схожая библиотека для работы с клиентом Telegram. Из плюсов — Telethon реализует метод conversation, что упрощает сборку ответов клиенту от бота. Подробнее можно посмотреть тут.

with client.conversation(‘<bot name>’) as conv: await conv.send_message(«/start») reply: Message = await conv.get_response() assert reply.raw_text == f’Hellow, {username}!’

Заключение​


Потратив дополнительное время на подготовку фикстур, мы получаем все необходимые инструменты для написания тестов от начала и до конца. Мы можем создать несколько пользователей, отправлять им разные тестовые сообщения и проверять ответы. Это удобно не только для написания тестов в целом, но и во время разработки — как точка входа для запуска и отладки. Вместо того чтобы каждый раз доставать телефон и вручную отправлять сообщения боту, можно описать простой тест на отправку сообщения клиентом.

Но стоит понимать, что такой способ тестирования не самый дешевый с точки зрения реализации, поддержки и времени исполнения самих тестов. Каждый запрос на регистрацию клиента, отправку сообщения клиентом, на отправку ответа ботом происходит посредством реальных запрос к серверу Telegram. А это занимает время. Когда подобных тестов станет много, это может стать проблемой. Вот почему для написания тестов стоит придерживаться парадигмы Testing Pyramid и реализовывать подобным методом только базовые и важные функции вашего приложения.

beeline cloud — secure cloud provider.
Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы
 
198 238Темы
635 210Сообщения
3 618 427Пользователи
anton1346Новый пользователь
Верх