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.
@@ -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
+ ![aiogram](https://img.shields.io/badge/aiogram-3.x-blue?logo=telegram)
12
+ ![telegram](https://img.shields.io/badge/telegram-bot-blue?logo=telegram)
13
+ ![status](https://img.shields.io/badge/status-WIP-orange)
14
+ ![python](https://img.shields.io/badge/python-3.10+-blue?logo=python)
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
+ ![aiogram](https://img.shields.io/badge/aiogram-3.x-blue?logo=telegram)
4
+ ![telegram](https://img.shields.io/badge/telegram-bot-blue?logo=telegram)
5
+ ![status](https://img.shields.io/badge/status-WIP-orange)
6
+ ![python](https://img.shields.io/badge/python-3.10+-blue?logo=python)
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,7 @@
1
+ from .bot_tester import (
2
+ BotTester,
3
+ )
4
+
5
+ __all__ = [
6
+ "BotTester",
7
+ ]
@@ -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
+ )