- PVSM.RU - https://www.pvsm.ru -

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

Каждый, кто когда-либо писал telegram-ботов, задавался вопросом: «А как их тестировать?» Сложно найти однозначный ответ. Например, при написании тестов для веб-приложений и API можно воспользоваться тестовым клиентом DRF [1] или FastAPI [2]: просто пишешь запрос и делаешь assert на полученный ответ. Мне захотелось получить подобный функционал и для тестирования telegram-бота.

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

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

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

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

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

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

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

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

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

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

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

PTB Test [12]

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

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

Aiogram Tests [13]

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

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

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

Tg Integration [14]

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

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

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

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

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

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

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

После регистрации мы получаем 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 [17]. (© Telegram Documentation)

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

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

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

https://api.telegram.org/bot<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 [18]

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

📍PTB на данный момент не поддерживает [20] работу бота в тестовом пространстве, но можно передать токен вместе с приставкой /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 в следующем блоке.

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

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

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

Для тестирования приложения мы будем использовать тестовые номера, которые предоставляет [21] 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. Но в примерах здесь я удалил все опциональные настройки, чтобы не отвлекаться от сути. Код вспомогательного класса целиком можно посмотреть тут [22].

  1. Создадим клиента. В отличие от PTB, у Pyrogram есть нативная поддержка [23] тестового пространства 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 [24]. Я использую здесь контекст-менеджер, чтобы явно описать setup и teardown. Позже это будет удобно интегрировать в Pytest в качестве yield фикстуры [25].

@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 [26], что упрощает сборку ответов клиенту от бота. Подробнее можно посмотреть тут [27].

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 [28] и реализовывать подобным методом только базовые и важные функции вашего приложения.

Автор:
beeline_cloud

Источник [29]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/testirovanie/384652

Ссылки в тексте:

[1] DRF: https://www.django-rest-framework.org/api-guide/testing/

[2] FastAPI: https://fastapi.tiangolo.com/tutorial/testing/

[3] Михаил Выборный: https://habr.com/ru/users/vybornyy/

[4] тестовое: https://core.telegram.org/bots/features#testing-your-bot

[5] Python Telegram Bot: https://github.com/python-telegram-bot/python-telegram-bot

[6] Pytest: https://github.com/pytest-dev/pytest

[7] Anyio: https://github.com/agronholm/anyio

[8] Pyrogram: https://github.com/pyrogram/pyrogram

[9] asyncio.Event: https://docs.python.org/3/library/asyncio-sync.html#asyncio.Event

[10] contexlib: https://docs.python.org/3/library/contextlib.html

[11] GitHub: https://github.com/MishaVyb/bart-bot

[12] PTB Test: https://github.com/python-telegram-bot/ptbtest

[13] Aiogram Tests: https://github.com/OCCCAS/aiogram_tests

[14] Tg Integration: https://github.com/JosXa/tgintegration/

[15] зарегистрировать: https://my.telegram.org/apps

[16] Telegram: https://core.telegram.org/api/obtaining_api_id

[17] @BotFather: https://t.me/botfather

[18] Running PTB alongside other asyncio frameworks: https://github.com/python-telegram-bot/python-telegram-bot/wiki/Frequently-requested-design-patterns#running-ptb-alongside-other-asyncio-frameworks

[19] Testing with AnyIO: https://anyio.readthedocs.io/en/stable/testing.html#testing-with-anyio

[20] не поддерживает: https://github.com/python-telegram-bot/python-telegram-bot/issues/3355

[21] предоставляет: https://core.telegram.org/api/auth#test-accounts

[22] тут: https://github.com/MishaVyb/bart-bot/blob/main/tests/tools/integration.py

[23] поддержка: https://docs.pyrogram.org/topics/test-servers

[24] asynccontextmanager: https://docs.python.org/3/library/contextlib.html#contextlib.asynccontextmanager

[25] фикстуры: https://docs.pytest.org/en/6.2.x/fixture.html#yield-fixtures-recommended

[26] conversation: https://docs.telethon.dev/en/stable/modules/custom.html?highlight=conversation#module-telethon.tl.custom.conversation

[27] тут: https://blog.1a23.com/2020/03/06/how-to-write-integration-tests-for-a-telegram-bot/

[28] Testing Pyramid: https://www.headspin.io/blog/the-testing-pyramid-simplified-for-one-and-all

[29] Источник: https://habr.com/ru/companies/beeline_cloud/articles/734164/?utm_source=habrahabr&utm_medium=rss&utm_campaign=734164