aiogram_bot_tester 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aiogram_bot_tester-0.1.0/PKG-INFO +511 -0
- aiogram_bot_tester-0.1.0/README.md +503 -0
- aiogram_bot_tester-0.1.0/pyproject.toml +20 -0
- aiogram_bot_tester-0.1.0/src/aiogram_bot_tester/__init__.py +7 -0
- aiogram_bot_tester-0.1.0/src/aiogram_bot_tester/bot_tester.py +329 -0
- aiogram_bot_tester-0.1.0/src/aiogram_bot_tester/py.typed +0 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: aiogram_bot_tester
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Test your Telegram bots easily
|
|
5
|
+
Requires-Dist: aiogram>=3.28.2
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# aiogram_bot_tester
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
**Navigation / Навигация** - [English version](#english-version) -
|
|
17
|
+
[Русская версия](#русская-версия)
|
|
18
|
+
|
|
19
|
+
A lightweight testing utility for **offline testing of aiogram bots without real Telegram API calls**.
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# English Version
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
With pip:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
With poetry:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
poetry add git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
With uv:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv add git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## What is this?
|
|
50
|
+
|
|
51
|
+
`aiogram_bot_tester` is a testing framework for bots built with `aiogram`.
|
|
52
|
+
|
|
53
|
+
Instead of running a real Telegram bot, it:
|
|
54
|
+
|
|
55
|
+
- simulates incoming Telegram updates (`Message`, `CallbackQuery`)
|
|
56
|
+
- intercepts outgoing bot API calls (`send_message`, etc.)
|
|
57
|
+
- captures state changes (`FSM`)
|
|
58
|
+
- provides a clean assertion API
|
|
59
|
+
|
|
60
|
+
#### Goal
|
|
61
|
+
|
|
62
|
+
Enable fast, deterministic, offline testing of Telegram bots without network, tokens, or Telegram API dependency.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Quick example
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from aiogram_bot_tester import BotTester
|
|
70
|
+
|
|
71
|
+
tester = BotTester.from_routers(router)
|
|
72
|
+
|
|
73
|
+
response = await tester.send_message("/start")
|
|
74
|
+
|
|
75
|
+
assert response.text == "Hello"
|
|
76
|
+
assert response.has_inline_button("Press")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Core API
|
|
82
|
+
|
|
83
|
+
### BotTester.from_routers(*routers)
|
|
84
|
+
|
|
85
|
+
Construct a `BotTester` instance from a sequence of `Routers`s.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
tester = BotTester.from_routers(router)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### send_message(text, **kwargs)
|
|
92
|
+
|
|
93
|
+
Send a message to the bot. Returns a special `Response` object described below.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
response = await tester.send_message("/start")
|
|
97
|
+
assert response.text == "Hello"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### click_reply_button(text, **kwargs)
|
|
101
|
+
|
|
102
|
+
Simulates clicking a reply button. Actually, it's just another way of calling `send_message`. Exists just to clarify intention.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
response = await tester.click_reply_button("Option A")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### click_inline_button(label)
|
|
109
|
+
|
|
110
|
+
Simulates clicking an inline button.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
response = await tester.click_inline_button("Press")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Response object
|
|
119
|
+
|
|
120
|
+
Each interaction with a `BotTester` returns a `Response` object.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
@dataclass
|
|
124
|
+
class Response:
|
|
125
|
+
text: str | None
|
|
126
|
+
state: State | None
|
|
127
|
+
message: object | None
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Response.text
|
|
131
|
+
|
|
132
|
+
Last bot message text.
|
|
133
|
+
|
|
134
|
+
### Response.state
|
|
135
|
+
|
|
136
|
+
FSM state.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
class Form(StatesGroup):
|
|
142
|
+
name = State()
|
|
143
|
+
surname = State()
|
|
144
|
+
age = State()
|
|
145
|
+
|
|
146
|
+
# Inside a test:
|
|
147
|
+
|
|
148
|
+
response = await tester.send_message("/start")
|
|
149
|
+
assert response.state == Form.name # in_state() method does the same
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Response.contains(substring)
|
|
153
|
+
|
|
154
|
+
Returns `True` if this response text contains the given `substring`, `False` otherwise.
|
|
155
|
+
|
|
156
|
+
Example:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
assert response.contains("Hello")
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Response.matches(regex)
|
|
163
|
+
|
|
164
|
+
Returns `True` if this response text matches the given `regex`, `False` otherwise.
|
|
165
|
+
|
|
166
|
+
Example:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
assert response.matches("\d+")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Response.has_inline_button(label)
|
|
173
|
+
|
|
174
|
+
Returns `True` if this response has an inline button with `label`, `False` otherwise.
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
assert response.has_inline_button("Click me!")
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Response.has_reply_button(label)
|
|
183
|
+
|
|
184
|
+
Returns `True` if this response has a button with `label`, `False` otherwise.
|
|
185
|
+
|
|
186
|
+
Example:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
assert response.has_reply_button("Click me!")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Response.has_inline_keyboard_like(keyboard)
|
|
193
|
+
|
|
194
|
+
Returns `True` if this response has the specified inline `keyboard`, `False` otherwise.
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
assert response.has_inline_keyboard_like([
|
|
200
|
+
["Yes", "No"],
|
|
201
|
+
["Cancel"],
|
|
202
|
+
])
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Response.has_reply_keyboard_like(keyboard)
|
|
206
|
+
|
|
207
|
+
Returns `True` if this response has the specified reply `keyboard`, `False` otherwise.
|
|
208
|
+
|
|
209
|
+
Example:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
assert response.has_reply_keyboard_like([
|
|
213
|
+
["1", "2", "3"],
|
|
214
|
+
["4", "5", "6"],
|
|
215
|
+
])
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Response.in_state(state)
|
|
219
|
+
|
|
220
|
+
Returns `True` if after this response the state is `state`, `False` otherwise.
|
|
221
|
+
|
|
222
|
+
Example:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
assert response.in_state(Form.name)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## 🚀 Full example
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
import pytest
|
|
234
|
+
from aiogram import Router, F
|
|
235
|
+
from aiogram.types import Message, CallbackQuery
|
|
236
|
+
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
237
|
+
|
|
238
|
+
from aiogram_bot_tester import BotTester
|
|
239
|
+
|
|
240
|
+
router = Router()
|
|
241
|
+
|
|
242
|
+
@router.message()
|
|
243
|
+
async def echo(message: Message):
|
|
244
|
+
await message.answer(
|
|
245
|
+
f"Echo: {message.text}",
|
|
246
|
+
reply_markup=InlineKeyboardMarkup(
|
|
247
|
+
inline_keyboard=[
|
|
248
|
+
[InlineKeyboardButton(text="Press me", callback_data="press")]
|
|
249
|
+
]
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
@router.callback_query(F.data == "press")
|
|
254
|
+
async def on_press(callback: CallbackQuery):
|
|
255
|
+
await callback.message.answer("Button clicked!")
|
|
256
|
+
|
|
257
|
+
@pytest.mark.asyncio
|
|
258
|
+
async def test_full_flow():
|
|
259
|
+
tester = BotTester.from_routers(router)
|
|
260
|
+
|
|
261
|
+
response = await tester.send_message("Hello")
|
|
262
|
+
assert response.text == "Echo: Hello"
|
|
263
|
+
|
|
264
|
+
response = await tester.click_inline_button("Press me")
|
|
265
|
+
assert response.text == "Button clicked!"
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
# Русская версия
|
|
271
|
+
|
|
272
|
+
## Установка
|
|
273
|
+
|
|
274
|
+
Через pip:
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
pip install git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Через poetry:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
poetry add git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Через uv:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
uv add git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Что это?
|
|
295
|
+
|
|
296
|
+
`aiogram_bot_tester` это тестировочный фреймворк для Telegram-ботов, написанных на `aiogram`.
|
|
297
|
+
|
|
298
|
+
Он:
|
|
299
|
+
|
|
300
|
+
- симулирует отправку различных Telegram-апдейтов (`Message`, `CallbackQuery`)
|
|
301
|
+
- предоставляет знакомый интерфейс для работы с ботом (`send_message` и т.д.)
|
|
302
|
+
- хранит информацию о состоянии (`FSM`)
|
|
303
|
+
- предоставляет чистый и красивый API
|
|
304
|
+
|
|
305
|
+
#### Цель
|
|
306
|
+
|
|
307
|
+
Предоставить быстрое, детерменированное и оффлайн тестирование функционала Telegram-бота.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Пример
|
|
312
|
+
|
|
313
|
+
```python
|
|
314
|
+
from aiogram_bot_tester import BotTester
|
|
315
|
+
|
|
316
|
+
tester = BotTester.from_routers(router)
|
|
317
|
+
|
|
318
|
+
response = await tester.send_message("/start")
|
|
319
|
+
|
|
320
|
+
assert response.text == "Привет"
|
|
321
|
+
assert response.has_inline_button("Нажми меня!")
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Core API
|
|
327
|
+
|
|
328
|
+
### BotTester.from_routers(*routers)
|
|
329
|
+
|
|
330
|
+
Создает объект `BotTester` из набора роутеров.
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
tester = BotTester.from_routers(router1, router2, router3)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### send_message(text, **kwargs)
|
|
337
|
+
|
|
338
|
+
Симулирует отправку сообщения боту. Возвращает специальный объект `Response`, рассматриваемый далее.
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
response = await tester.send_message("/start")
|
|
342
|
+
assert response.text == "Привет"
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### click_reply_button(text, **kwargs)
|
|
346
|
+
|
|
347
|
+
Симулирует нажатие на reply-кнопку. На самом деле, это просто синоним для метода `send_message`, но с названием отражающим суть.
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
response = await tester.click_reply_button("Вариант А")
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### click_inline_button(label)
|
|
354
|
+
|
|
355
|
+
Симулирует нажатие на inline-кнопку.
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
response = await tester.click_inline_button("Нажми")
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## Response object
|
|
364
|
+
|
|
365
|
+
В результате любого взаимодействия с `BotTester`, возвращается объект-ответ `Response`.s
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
@dataclass
|
|
369
|
+
class Response:
|
|
370
|
+
text: str | None
|
|
371
|
+
state: State | None
|
|
372
|
+
message: object | None
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Response.text
|
|
376
|
+
|
|
377
|
+
Последнее сообщение бота.
|
|
378
|
+
|
|
379
|
+
### Response.state
|
|
380
|
+
|
|
381
|
+
Состояние.
|
|
382
|
+
|
|
383
|
+
Пример:
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
class Form(StatesGroup):
|
|
387
|
+
name = State()
|
|
388
|
+
surname = State()
|
|
389
|
+
age = State()
|
|
390
|
+
|
|
391
|
+
# Внутри теста:
|
|
392
|
+
|
|
393
|
+
response = await tester.send_message("/start")
|
|
394
|
+
assert response.state == Form.name # in_state() делает аналогичное действие
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### Response.contains(substring)
|
|
398
|
+
|
|
399
|
+
Возвращает `True`, если текст ответа содержит указанную подстроку, иначе `False`.
|
|
400
|
+
|
|
401
|
+
Пример:
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
assert response.contains("Привет")
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
### Response.matches(regex)
|
|
408
|
+
|
|
409
|
+
Возвращает `True`, если текст ответа соответствует регулярному выражению, иначе `False`.
|
|
410
|
+
|
|
411
|
+
Пример:
|
|
412
|
+
|
|
413
|
+
```python
|
|
414
|
+
assert response.matches("\d+")
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Response.has_inline_button(label)
|
|
418
|
+
|
|
419
|
+
Возвращает `True`, если в ответе есть inline-кнопка с указанным текстом.
|
|
420
|
+
|
|
421
|
+
Привет:
|
|
422
|
+
|
|
423
|
+
```python
|
|
424
|
+
assert response.has_inline_button("Нажми меня!")
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Response.has_reply_button(label)
|
|
428
|
+
|
|
429
|
+
Возвращает `True`, если в ответе есть reply-кнопка с указанным текстом.
|
|
430
|
+
|
|
431
|
+
Example:
|
|
432
|
+
|
|
433
|
+
```python
|
|
434
|
+
assert response.has_reply_button("Нажми меня!")
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Response.has_inline_keyboard_like(keyboard)
|
|
438
|
+
|
|
439
|
+
Возвращает `True`, если inline-клавиатура совпадает с заданной структурой.
|
|
440
|
+
|
|
441
|
+
Example:
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
assert response.has_inline_keyboard_like([
|
|
445
|
+
["Да", "Нет"],
|
|
446
|
+
["Отмена"],
|
|
447
|
+
])
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Response.has_reply_keyboard_like(keyboard)
|
|
451
|
+
|
|
452
|
+
Возвращает `True`, если reply-клавиатура совпадает с заданной структурой.
|
|
453
|
+
|
|
454
|
+
Example:
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
assert response.has_reply_keyboard_like([
|
|
458
|
+
["1", "2", "3"],
|
|
459
|
+
["4", "5", "6"],
|
|
460
|
+
])
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Response.in_state(state)
|
|
464
|
+
|
|
465
|
+
Возвращает `True`, если бот находится в заданном состоянии.
|
|
466
|
+
|
|
467
|
+
Пример:
|
|
468
|
+
|
|
469
|
+
```python
|
|
470
|
+
assert response.in_state(Form.name)
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
## 🚀 Полный пример теста
|
|
476
|
+
|
|
477
|
+
```python
|
|
478
|
+
import pytest
|
|
479
|
+
from aiogram import Router, F
|
|
480
|
+
from aiogram.types import Message, CallbackQuery
|
|
481
|
+
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
482
|
+
|
|
483
|
+
from aiogram_bot_tester import BotTester
|
|
484
|
+
|
|
485
|
+
router = Router()
|
|
486
|
+
|
|
487
|
+
@router.message()
|
|
488
|
+
async def echo(message: Message):
|
|
489
|
+
await message.answer(
|
|
490
|
+
f"Echo: {message.text}",
|
|
491
|
+
reply_markup=InlineKeyboardMarkup(
|
|
492
|
+
inline_keyboard=[
|
|
493
|
+
[InlineKeyboardButton(text="Нажми меня!", callback_data="press")]
|
|
494
|
+
]
|
|
495
|
+
),
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
@router.callback_query(F.data == "press")
|
|
499
|
+
async def on_press(callback: CallbackQuery):
|
|
500
|
+
await callback.message.answer("Кнопка нажата!")
|
|
501
|
+
|
|
502
|
+
@pytest.mark.asyncio
|
|
503
|
+
async def test_full_flow():
|
|
504
|
+
tester = BotTester.from_routers(router)
|
|
505
|
+
|
|
506
|
+
response = await tester.send_message("Hello")
|
|
507
|
+
assert response.text == "Echo: Hello"
|
|
508
|
+
|
|
509
|
+
response = await tester.click_inline_button("Нажми меня!")
|
|
510
|
+
assert response.text == "Кнопка нажата!"
|
|
511
|
+
```
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
# aiogram_bot_tester
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
**Navigation / Навигация** - [English version](#english-version) -
|
|
9
|
+
[Русская версия](#русская-версия)
|
|
10
|
+
|
|
11
|
+
A lightweight testing utility for **offline testing of aiogram bots without real Telegram API calls**.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# English Version
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
With pip:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
With poetry:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
poetry add git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
With uv:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv add git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## What is this?
|
|
42
|
+
|
|
43
|
+
`aiogram_bot_tester` is a testing framework for bots built with `aiogram`.
|
|
44
|
+
|
|
45
|
+
Instead of running a real Telegram bot, it:
|
|
46
|
+
|
|
47
|
+
- simulates incoming Telegram updates (`Message`, `CallbackQuery`)
|
|
48
|
+
- intercepts outgoing bot API calls (`send_message`, etc.)
|
|
49
|
+
- captures state changes (`FSM`)
|
|
50
|
+
- provides a clean assertion API
|
|
51
|
+
|
|
52
|
+
#### Goal
|
|
53
|
+
|
|
54
|
+
Enable fast, deterministic, offline testing of Telegram bots without network, tokens, or Telegram API dependency.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Quick example
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from aiogram_bot_tester import BotTester
|
|
62
|
+
|
|
63
|
+
tester = BotTester.from_routers(router)
|
|
64
|
+
|
|
65
|
+
response = await tester.send_message("/start")
|
|
66
|
+
|
|
67
|
+
assert response.text == "Hello"
|
|
68
|
+
assert response.has_inline_button("Press")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Core API
|
|
74
|
+
|
|
75
|
+
### BotTester.from_routers(*routers)
|
|
76
|
+
|
|
77
|
+
Construct a `BotTester` instance from a sequence of `Routers`s.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
tester = BotTester.from_routers(router)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### send_message(text, **kwargs)
|
|
84
|
+
|
|
85
|
+
Send a message to the bot. Returns a special `Response` object described below.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
response = await tester.send_message("/start")
|
|
89
|
+
assert response.text == "Hello"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### click_reply_button(text, **kwargs)
|
|
93
|
+
|
|
94
|
+
Simulates clicking a reply button. Actually, it's just another way of calling `send_message`. Exists just to clarify intention.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
response = await tester.click_reply_button("Option A")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### click_inline_button(label)
|
|
101
|
+
|
|
102
|
+
Simulates clicking an inline button.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
response = await tester.click_inline_button("Press")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Response object
|
|
111
|
+
|
|
112
|
+
Each interaction with a `BotTester` returns a `Response` object.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
@dataclass
|
|
116
|
+
class Response:
|
|
117
|
+
text: str | None
|
|
118
|
+
state: State | None
|
|
119
|
+
message: object | None
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Response.text
|
|
123
|
+
|
|
124
|
+
Last bot message text.
|
|
125
|
+
|
|
126
|
+
### Response.state
|
|
127
|
+
|
|
128
|
+
FSM state.
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
class Form(StatesGroup):
|
|
134
|
+
name = State()
|
|
135
|
+
surname = State()
|
|
136
|
+
age = State()
|
|
137
|
+
|
|
138
|
+
# Inside a test:
|
|
139
|
+
|
|
140
|
+
response = await tester.send_message("/start")
|
|
141
|
+
assert response.state == Form.name # in_state() method does the same
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Response.contains(substring)
|
|
145
|
+
|
|
146
|
+
Returns `True` if this response text contains the given `substring`, `False` otherwise.
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
assert response.contains("Hello")
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Response.matches(regex)
|
|
155
|
+
|
|
156
|
+
Returns `True` if this response text matches the given `regex`, `False` otherwise.
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
assert response.matches("\d+")
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Response.has_inline_button(label)
|
|
165
|
+
|
|
166
|
+
Returns `True` if this response has an inline button with `label`, `False` otherwise.
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
assert response.has_inline_button("Click me!")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Response.has_reply_button(label)
|
|
175
|
+
|
|
176
|
+
Returns `True` if this response has a button with `label`, `False` otherwise.
|
|
177
|
+
|
|
178
|
+
Example:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
assert response.has_reply_button("Click me!")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Response.has_inline_keyboard_like(keyboard)
|
|
185
|
+
|
|
186
|
+
Returns `True` if this response has the specified inline `keyboard`, `False` otherwise.
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
assert response.has_inline_keyboard_like([
|
|
192
|
+
["Yes", "No"],
|
|
193
|
+
["Cancel"],
|
|
194
|
+
])
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Response.has_reply_keyboard_like(keyboard)
|
|
198
|
+
|
|
199
|
+
Returns `True` if this response has the specified reply `keyboard`, `False` otherwise.
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
assert response.has_reply_keyboard_like([
|
|
205
|
+
["1", "2", "3"],
|
|
206
|
+
["4", "5", "6"],
|
|
207
|
+
])
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Response.in_state(state)
|
|
211
|
+
|
|
212
|
+
Returns `True` if after this response the state is `state`, `False` otherwise.
|
|
213
|
+
|
|
214
|
+
Example:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
assert response.in_state(Form.name)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## 🚀 Full example
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
import pytest
|
|
226
|
+
from aiogram import Router, F
|
|
227
|
+
from aiogram.types import Message, CallbackQuery
|
|
228
|
+
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
229
|
+
|
|
230
|
+
from aiogram_bot_tester import BotTester
|
|
231
|
+
|
|
232
|
+
router = Router()
|
|
233
|
+
|
|
234
|
+
@router.message()
|
|
235
|
+
async def echo(message: Message):
|
|
236
|
+
await message.answer(
|
|
237
|
+
f"Echo: {message.text}",
|
|
238
|
+
reply_markup=InlineKeyboardMarkup(
|
|
239
|
+
inline_keyboard=[
|
|
240
|
+
[InlineKeyboardButton(text="Press me", callback_data="press")]
|
|
241
|
+
]
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@router.callback_query(F.data == "press")
|
|
246
|
+
async def on_press(callback: CallbackQuery):
|
|
247
|
+
await callback.message.answer("Button clicked!")
|
|
248
|
+
|
|
249
|
+
@pytest.mark.asyncio
|
|
250
|
+
async def test_full_flow():
|
|
251
|
+
tester = BotTester.from_routers(router)
|
|
252
|
+
|
|
253
|
+
response = await tester.send_message("Hello")
|
|
254
|
+
assert response.text == "Echo: Hello"
|
|
255
|
+
|
|
256
|
+
response = await tester.click_inline_button("Press me")
|
|
257
|
+
assert response.text == "Button clicked!"
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
# Русская версия
|
|
263
|
+
|
|
264
|
+
## Установка
|
|
265
|
+
|
|
266
|
+
Через pip:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
pip install git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Через poetry:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
poetry add git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Через uv:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
uv add git+https://github.com/samedit66/aiogram-bot-tester.git
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Что это?
|
|
287
|
+
|
|
288
|
+
`aiogram_bot_tester` это тестировочный фреймворк для Telegram-ботов, написанных на `aiogram`.
|
|
289
|
+
|
|
290
|
+
Он:
|
|
291
|
+
|
|
292
|
+
- симулирует отправку различных Telegram-апдейтов (`Message`, `CallbackQuery`)
|
|
293
|
+
- предоставляет знакомый интерфейс для работы с ботом (`send_message` и т.д.)
|
|
294
|
+
- хранит информацию о состоянии (`FSM`)
|
|
295
|
+
- предоставляет чистый и красивый API
|
|
296
|
+
|
|
297
|
+
#### Цель
|
|
298
|
+
|
|
299
|
+
Предоставить быстрое, детерменированное и оффлайн тестирование функционала Telegram-бота.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Пример
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
from aiogram_bot_tester import BotTester
|
|
307
|
+
|
|
308
|
+
tester = BotTester.from_routers(router)
|
|
309
|
+
|
|
310
|
+
response = await tester.send_message("/start")
|
|
311
|
+
|
|
312
|
+
assert response.text == "Привет"
|
|
313
|
+
assert response.has_inline_button("Нажми меня!")
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Core API
|
|
319
|
+
|
|
320
|
+
### BotTester.from_routers(*routers)
|
|
321
|
+
|
|
322
|
+
Создает объект `BotTester` из набора роутеров.
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
tester = BotTester.from_routers(router1, router2, router3)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### send_message(text, **kwargs)
|
|
329
|
+
|
|
330
|
+
Симулирует отправку сообщения боту. Возвращает специальный объект `Response`, рассматриваемый далее.
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
response = await tester.send_message("/start")
|
|
334
|
+
assert response.text == "Привет"
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### click_reply_button(text, **kwargs)
|
|
338
|
+
|
|
339
|
+
Симулирует нажатие на reply-кнопку. На самом деле, это просто синоним для метода `send_message`, но с названием отражающим суть.
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
response = await tester.click_reply_button("Вариант А")
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### click_inline_button(label)
|
|
346
|
+
|
|
347
|
+
Симулирует нажатие на inline-кнопку.
|
|
348
|
+
|
|
349
|
+
```python
|
|
350
|
+
response = await tester.click_inline_button("Нажми")
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## Response object
|
|
356
|
+
|
|
357
|
+
В результате любого взаимодействия с `BotTester`, возвращается объект-ответ `Response`.s
|
|
358
|
+
|
|
359
|
+
```python
|
|
360
|
+
@dataclass
|
|
361
|
+
class Response:
|
|
362
|
+
text: str | None
|
|
363
|
+
state: State | None
|
|
364
|
+
message: object | None
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Response.text
|
|
368
|
+
|
|
369
|
+
Последнее сообщение бота.
|
|
370
|
+
|
|
371
|
+
### Response.state
|
|
372
|
+
|
|
373
|
+
Состояние.
|
|
374
|
+
|
|
375
|
+
Пример:
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
class Form(StatesGroup):
|
|
379
|
+
name = State()
|
|
380
|
+
surname = State()
|
|
381
|
+
age = State()
|
|
382
|
+
|
|
383
|
+
# Внутри теста:
|
|
384
|
+
|
|
385
|
+
response = await tester.send_message("/start")
|
|
386
|
+
assert response.state == Form.name # in_state() делает аналогичное действие
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Response.contains(substring)
|
|
390
|
+
|
|
391
|
+
Возвращает `True`, если текст ответа содержит указанную подстроку, иначе `False`.
|
|
392
|
+
|
|
393
|
+
Пример:
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
assert response.contains("Привет")
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Response.matches(regex)
|
|
400
|
+
|
|
401
|
+
Возвращает `True`, если текст ответа соответствует регулярному выражению, иначе `False`.
|
|
402
|
+
|
|
403
|
+
Пример:
|
|
404
|
+
|
|
405
|
+
```python
|
|
406
|
+
assert response.matches("\d+")
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
### Response.has_inline_button(label)
|
|
410
|
+
|
|
411
|
+
Возвращает `True`, если в ответе есть inline-кнопка с указанным текстом.
|
|
412
|
+
|
|
413
|
+
Привет:
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
assert response.has_inline_button("Нажми меня!")
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Response.has_reply_button(label)
|
|
420
|
+
|
|
421
|
+
Возвращает `True`, если в ответе есть reply-кнопка с указанным текстом.
|
|
422
|
+
|
|
423
|
+
Example:
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
assert response.has_reply_button("Нажми меня!")
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Response.has_inline_keyboard_like(keyboard)
|
|
430
|
+
|
|
431
|
+
Возвращает `True`, если inline-клавиатура совпадает с заданной структурой.
|
|
432
|
+
|
|
433
|
+
Example:
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
assert response.has_inline_keyboard_like([
|
|
437
|
+
["Да", "Нет"],
|
|
438
|
+
["Отмена"],
|
|
439
|
+
])
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### Response.has_reply_keyboard_like(keyboard)
|
|
443
|
+
|
|
444
|
+
Возвращает `True`, если reply-клавиатура совпадает с заданной структурой.
|
|
445
|
+
|
|
446
|
+
Example:
|
|
447
|
+
|
|
448
|
+
```python
|
|
449
|
+
assert response.has_reply_keyboard_like([
|
|
450
|
+
["1", "2", "3"],
|
|
451
|
+
["4", "5", "6"],
|
|
452
|
+
])
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Response.in_state(state)
|
|
456
|
+
|
|
457
|
+
Возвращает `True`, если бот находится в заданном состоянии.
|
|
458
|
+
|
|
459
|
+
Пример:
|
|
460
|
+
|
|
461
|
+
```python
|
|
462
|
+
assert response.in_state(Form.name)
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## 🚀 Полный пример теста
|
|
468
|
+
|
|
469
|
+
```python
|
|
470
|
+
import pytest
|
|
471
|
+
from aiogram import Router, F
|
|
472
|
+
from aiogram.types import Message, CallbackQuery
|
|
473
|
+
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
474
|
+
|
|
475
|
+
from aiogram_bot_tester import BotTester
|
|
476
|
+
|
|
477
|
+
router = Router()
|
|
478
|
+
|
|
479
|
+
@router.message()
|
|
480
|
+
async def echo(message: Message):
|
|
481
|
+
await message.answer(
|
|
482
|
+
f"Echo: {message.text}",
|
|
483
|
+
reply_markup=InlineKeyboardMarkup(
|
|
484
|
+
inline_keyboard=[
|
|
485
|
+
[InlineKeyboardButton(text="Нажми меня!", callback_data="press")]
|
|
486
|
+
]
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
@router.callback_query(F.data == "press")
|
|
491
|
+
async def on_press(callback: CallbackQuery):
|
|
492
|
+
await callback.message.answer("Кнопка нажата!")
|
|
493
|
+
|
|
494
|
+
@pytest.mark.asyncio
|
|
495
|
+
async def test_full_flow():
|
|
496
|
+
tester = BotTester.from_routers(router)
|
|
497
|
+
|
|
498
|
+
response = await tester.send_message("Hello")
|
|
499
|
+
assert response.text == "Echo: Hello"
|
|
500
|
+
|
|
501
|
+
response = await tester.click_inline_button("Нажми меня!")
|
|
502
|
+
assert response.text == "Кнопка нажата!"
|
|
503
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aiogram_bot_tester"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Test your Telegram bots easily"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"aiogram>=3.28.2",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[dependency-groups]
|
|
12
|
+
dev = [
|
|
13
|
+
"pytest>=9.0.3",
|
|
14
|
+
"pytest-asyncio>=1.3.0",
|
|
15
|
+
"ruff>=0.15.13",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["uv_build>=0.11.14,<0.12"]
|
|
20
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import dataclasses as dc
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import re
|
|
7
|
+
import typing as t
|
|
8
|
+
import unittest.mock as mock
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
import aiogram as aio
|
|
12
|
+
import aiogram.fsm.state as fsm_state
|
|
13
|
+
import aiogram.fsm.storage.memory as fsm_memory
|
|
14
|
+
import aiogram.methods as methods
|
|
15
|
+
import aiogram.types as types
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dc.dataclass(frozen=True, slots=True)
|
|
19
|
+
class InlineButton:
|
|
20
|
+
"""Snapshot of an inline keyboard button."""
|
|
21
|
+
|
|
22
|
+
text: str
|
|
23
|
+
"""Visible label on the button."""
|
|
24
|
+
|
|
25
|
+
callback_data: str | None
|
|
26
|
+
"""Callback payload attached to the button, or ``None`` if absent."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dc.dataclass(frozen=True, slots=True)
|
|
30
|
+
class CapturedMessage:
|
|
31
|
+
"""Snapshot of a message sent by the bot."""
|
|
32
|
+
|
|
33
|
+
text: str | None
|
|
34
|
+
"""Message text, or ``None`` when the bot sent no text."""
|
|
35
|
+
|
|
36
|
+
inline_keyboard: list[list[InlineButton]]
|
|
37
|
+
"""Inline keyboard layout captured from the message."""
|
|
38
|
+
|
|
39
|
+
reply_keyboard: list[list[str]]
|
|
40
|
+
"""Reply keyboard layout captured from the message."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dc.dataclass(frozen=True, slots=True)
|
|
44
|
+
class Response:
|
|
45
|
+
"""Result of a synthetic interaction with the bot."""
|
|
46
|
+
|
|
47
|
+
text: str | None
|
|
48
|
+
"""Bot text response, or ``None`` when the bot sent no text."""
|
|
49
|
+
|
|
50
|
+
state: fsm_state.State | None
|
|
51
|
+
"""FSM state after handling the update, or ``None`` when inactive."""
|
|
52
|
+
|
|
53
|
+
message: CapturedMessage | None
|
|
54
|
+
"""
|
|
55
|
+
Captured outgoing message, if any.
|
|
56
|
+
|
|
57
|
+
This includes the message text and any inline or reply keyboards.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def contains(self, text: str) -> bool:
|
|
61
|
+
"""Return ``True`` when the response text contains ``text``."""
|
|
62
|
+
return self.text is not None and text in self.text
|
|
63
|
+
|
|
64
|
+
def matches(self, regex: str | re.Pattern[str]) -> bool:
|
|
65
|
+
"""Return ``True`` when the response text matches ``regex``."""
|
|
66
|
+
pattern = regex if isinstance(regex, re.Pattern) else re.compile(regex)
|
|
67
|
+
return bool(pattern.match(self.text or ""))
|
|
68
|
+
|
|
69
|
+
def has_inline_button(self, label: str) -> bool:
|
|
70
|
+
"""Return ``True`` when an inline button with ``label`` exists."""
|
|
71
|
+
if self.message is None:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
return any(
|
|
75
|
+
button.text == label
|
|
76
|
+
for row in self.message.inline_keyboard
|
|
77
|
+
for button in row
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def has_reply_button(self, label: str) -> bool:
|
|
81
|
+
"""Return ``True`` when a reply button with ``label`` exists."""
|
|
82
|
+
if self.message is None:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
return any(label in row for row in self.message.reply_keyboard)
|
|
86
|
+
|
|
87
|
+
def has_inline_keyboard_like(
|
|
88
|
+
self,
|
|
89
|
+
keyboard: list[list[str]],
|
|
90
|
+
) -> bool:
|
|
91
|
+
"""Return ``True`` when the inline keyboard matches ``keyboard``."""
|
|
92
|
+
if self.message is None:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
[button.text for button in row] for row in self.message.inline_keyboard
|
|
97
|
+
] == keyboard
|
|
98
|
+
|
|
99
|
+
def has_reply_keyboard_like(
|
|
100
|
+
self,
|
|
101
|
+
keyboard: list[list[str]],
|
|
102
|
+
) -> bool:
|
|
103
|
+
"""Return ``True`` when the reply keyboard matches ``keyboard``."""
|
|
104
|
+
if self.message is None:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
return self.message.reply_keyboard == keyboard
|
|
108
|
+
|
|
109
|
+
def in_state(self, state: fsm_state.State | None) -> bool:
|
|
110
|
+
"""Return ``True`` when the response FSM state equals ``state``."""
|
|
111
|
+
return self.state == state
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dc.dataclass(slots=True)
|
|
115
|
+
class BotTester:
|
|
116
|
+
"""Helper for driving a bot through synthetic updates in tests."""
|
|
117
|
+
|
|
118
|
+
bot: aio.Bot
|
|
119
|
+
"""Attached bot instance."""
|
|
120
|
+
|
|
121
|
+
dispatcher: aio.Dispatcher
|
|
122
|
+
"""Attached dispatcher instance."""
|
|
123
|
+
|
|
124
|
+
messages: list[CapturedMessage] = dc.field(default_factory=list)
|
|
125
|
+
"""Captured outgoing messages in send order."""
|
|
126
|
+
|
|
127
|
+
CHAT_ID: ClassVar[int] = 1
|
|
128
|
+
"""Synthetic chat identifier used for generated updates."""
|
|
129
|
+
|
|
130
|
+
USER_ID: ClassVar[int] = 1
|
|
131
|
+
"""Synthetic user identifier used for generated updates."""
|
|
132
|
+
|
|
133
|
+
def __post_init__(self) -> None:
|
|
134
|
+
"""Attach a mocked transport layer used for capturing bot responses."""
|
|
135
|
+
self.bot.session.make_request = mock.AsyncMock(
|
|
136
|
+
side_effect=self._capture_request,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_routers(
|
|
141
|
+
cls,
|
|
142
|
+
*routers: aio.Router,
|
|
143
|
+
token: str = "42:TEST",
|
|
144
|
+
) -> BotTester:
|
|
145
|
+
"""Create a tester instance with the provided routers registered."""
|
|
146
|
+
bot = aio.Bot(token=token)
|
|
147
|
+
|
|
148
|
+
dispatcher = aio.Dispatcher(storage=fsm_memory.MemoryStorage())
|
|
149
|
+
# We perform deepcopy because of the fact, that a single router cannot
|
|
150
|
+
# be attached to multiple dispatchers
|
|
151
|
+
dispatcher.include_routers(*[copy.deepcopy(r) for r in routers])
|
|
152
|
+
|
|
153
|
+
return cls(bot, dispatcher)
|
|
154
|
+
|
|
155
|
+
async def send_message(
|
|
156
|
+
self,
|
|
157
|
+
text: str,
|
|
158
|
+
**message_kwargs: t.Any,
|
|
159
|
+
) -> Response:
|
|
160
|
+
"""Send a synthetic message and return the resulting response snapshot."""
|
|
161
|
+
message = self._create_message(
|
|
162
|
+
text=text,
|
|
163
|
+
**message_kwargs,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
update = types.Update(
|
|
167
|
+
update_id=1,
|
|
168
|
+
message=message,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
await self.dispatcher.feed_update(
|
|
172
|
+
self.bot,
|
|
173
|
+
update,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return await self._build_response()
|
|
177
|
+
|
|
178
|
+
async def click_reply_button(
|
|
179
|
+
self,
|
|
180
|
+
text: str,
|
|
181
|
+
**message_kwargs: t.Any,
|
|
182
|
+
) -> Response:
|
|
183
|
+
"""Simulate a reply-keyboard click by sending the button text."""
|
|
184
|
+
return await self.send_message(
|
|
185
|
+
text=text,
|
|
186
|
+
**message_kwargs,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def click_inline_button(
|
|
190
|
+
self,
|
|
191
|
+
label: str,
|
|
192
|
+
) -> Response:
|
|
193
|
+
"""Simulate clicking an inline button with the given label."""
|
|
194
|
+
callback_data = self._find_callback_data(label)
|
|
195
|
+
|
|
196
|
+
telegram_message = self._create_message(
|
|
197
|
+
text=self.messages[-1].text or "",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
callback_query = types.CallbackQuery(
|
|
201
|
+
id="test-callback-query",
|
|
202
|
+
from_user=telegram_message.from_user,
|
|
203
|
+
chat_instance="test-chat-instance",
|
|
204
|
+
data=callback_data,
|
|
205
|
+
message=telegram_message,
|
|
206
|
+
bot=self.bot,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
update = types.Update(
|
|
210
|
+
update_id=1,
|
|
211
|
+
callback_query=callback_query,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
await self.dispatcher.feed_update(
|
|
215
|
+
self.bot,
|
|
216
|
+
update,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return await self._build_response()
|
|
220
|
+
|
|
221
|
+
def _find_callback_data(
|
|
222
|
+
self,
|
|
223
|
+
label: str,
|
|
224
|
+
) -> str:
|
|
225
|
+
"""Return callback data associated with the inline button label."""
|
|
226
|
+
if not self.messages:
|
|
227
|
+
raise ValueError("No messages available")
|
|
228
|
+
|
|
229
|
+
for row in self.messages[-1].inline_keyboard:
|
|
230
|
+
for button in row:
|
|
231
|
+
if button.text == label:
|
|
232
|
+
if button.callback_data is None:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
f"Inline button '{label}' has no callback_data"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return button.callback_data
|
|
238
|
+
|
|
239
|
+
raise ValueError(f"Inline button '{label}' not found")
|
|
240
|
+
|
|
241
|
+
def _create_message(
|
|
242
|
+
self,
|
|
243
|
+
text: str,
|
|
244
|
+
**message_kwargs: t.Any,
|
|
245
|
+
) -> types.Message:
|
|
246
|
+
"""Create a synthetic Telegram message object."""
|
|
247
|
+
return types.Message(
|
|
248
|
+
message_id=1,
|
|
249
|
+
date=dt.datetime.now(),
|
|
250
|
+
chat=types.Chat(
|
|
251
|
+
id=self.CHAT_ID,
|
|
252
|
+
type="private",
|
|
253
|
+
),
|
|
254
|
+
from_user=types.User(
|
|
255
|
+
id=self.USER_ID,
|
|
256
|
+
is_bot=False,
|
|
257
|
+
first_name="Test",
|
|
258
|
+
),
|
|
259
|
+
text=text,
|
|
260
|
+
bot=self.bot,
|
|
261
|
+
**message_kwargs,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
async def _capture_request(
|
|
265
|
+
self,
|
|
266
|
+
bot: aio.Bot,
|
|
267
|
+
method: t.Any,
|
|
268
|
+
timeout: int | None = None,
|
|
269
|
+
) -> mock.MagicMock:
|
|
270
|
+
"""Capture outgoing bot requests for later inspection."""
|
|
271
|
+
if isinstance(method, methods.SendMessage):
|
|
272
|
+
self.messages.append(
|
|
273
|
+
CapturedMessage(
|
|
274
|
+
text=method.text,
|
|
275
|
+
inline_keyboard=self._extract_inline_keyboard(
|
|
276
|
+
method.reply_markup,
|
|
277
|
+
),
|
|
278
|
+
reply_keyboard=self._extract_reply_keyboard(
|
|
279
|
+
method.reply_markup,
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return mock.MagicMock()
|
|
285
|
+
|
|
286
|
+
def _extract_inline_keyboard(
|
|
287
|
+
self,
|
|
288
|
+
markup: t.Any,
|
|
289
|
+
) -> list[list[InlineButton]]:
|
|
290
|
+
"""Extract inline keyboard data from Telegram markup."""
|
|
291
|
+
if not isinstance(markup, types.InlineKeyboardMarkup):
|
|
292
|
+
return []
|
|
293
|
+
|
|
294
|
+
return [
|
|
295
|
+
[
|
|
296
|
+
InlineButton(
|
|
297
|
+
text=button.text,
|
|
298
|
+
callback_data=button.callback_data,
|
|
299
|
+
)
|
|
300
|
+
for button in row
|
|
301
|
+
]
|
|
302
|
+
for row in markup.inline_keyboard
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
def _extract_reply_keyboard(
|
|
306
|
+
self,
|
|
307
|
+
markup: t.Any,
|
|
308
|
+
) -> list[list[str]]:
|
|
309
|
+
"""Extract reply keyboard data from Telegram markup."""
|
|
310
|
+
if not isinstance(markup, types.ReplyKeyboardMarkup):
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
return [[button.text for button in row] for row in markup.keyboard]
|
|
314
|
+
|
|
315
|
+
async def _build_response(self) -> Response:
|
|
316
|
+
"""Build a response snapshot from captured bot state."""
|
|
317
|
+
state = await self.dispatcher.fsm.get_context(
|
|
318
|
+
bot=self.bot,
|
|
319
|
+
chat_id=self.CHAT_ID,
|
|
320
|
+
user_id=self.USER_ID,
|
|
321
|
+
).get_state()
|
|
322
|
+
|
|
323
|
+
message = self.messages[-1] if self.messages else None
|
|
324
|
+
|
|
325
|
+
return Response(
|
|
326
|
+
text=message.text if message else None,
|
|
327
|
+
state=state,
|
|
328
|
+
message=message,
|
|
329
|
+
)
|
|
File without changes
|