Skip to content

Commit 3bb7894

Browse files
vzakharovclaude
andcommitted
refactor: replace object.__setattr__ hacks with aiogram's MockedBot pattern
Adapted aiogram's own MockedBot/MockedSession from their test suite. Real Message/CallbackQuery objects get a MockedBot injected via .as_(bot), and outgoing API calls are captured via bot.get_request() for assertion. The "connected" path (asyncio.gather with callback.answer + send_to_user) still needs object.__setattr__ for callback.answer because aiogram's TelegramMethod objects are unhashable, which breaks asyncio.gather's dedup logic. This is isolated to 2 tests; the callback.answer path is verified in the first-accept tests via MockedBot. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6afdafb commit 3bb7894

File tree

5 files changed

+228
-79
lines changed

5 files changed

+228
-79
lines changed

docs/250219_tests.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
- **Coverage target**: ~60-70% initially (critical paths: matcher, profiler, handlers)
66
- **pgvector**: Mock `_find_similar_users()` and `get_embedding()` — no Docker needed
7-
- **Telegram handlers**: Direct handler invocation with `object.__setattr__` to mock methods on frozen aiogram Pydantic models
7+
- **Telegram handlers**: Direct handler invocation with aiogram's own `MockedBot` pattern (adapted from their test suite) — real Message/CallbackQuery objects with bot injected via `.as_(bot)`, outgoing API calls captured via `bot.get_request()`
88
- **Database**: In-memory SQLite with savepoint-based test isolation
99
- **Always**: `uv run pyright` before `uv run pytest`, commit after each bite-sized piece
1010

tests/handlers/helpers.py

Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,110 @@
1-
"""Reusable factories for fake aiogram types used in handler tests."""
1+
"""Reusable factories for fake aiogram types used in handler tests.
2+
3+
Uses aiogram's own MockedBot pattern: real Message/CallbackQuery objects
4+
with a MockedBot injected via .as_(bot). Outgoing API calls are captured
5+
and can be inspected via bot.get_request().
6+
"""
27

38
from collections.abc import AsyncGenerator
49
from contextlib import asynccontextmanager
510
from datetime import datetime
6-
from typing import NamedTuple
7-
from unittest.mock import AsyncMock
11+
from unittest.mock import patch
812

13+
from aiogram.methods import AnswerCallbackQuery, SendMessage
914
from aiogram.types import CallbackQuery, Message
1015
from aiogram.types import Chat as TgChat
1116
from aiogram.types import User as TgUser
1217
from sqlalchemy.ext.asyncio import AsyncSession
1318

19+
from .mocked_bot import MockedBot
20+
21+
# Reusable dummy Message result for staging SendMessage responses.
22+
_DUMMY_MSG = Message(
23+
message_id=999,
24+
date=datetime.now(),
25+
chat=TgChat(id=1, type="private"),
26+
)
27+
28+
29+
def make_bot() -> MockedBot:
30+
return MockedBot()
31+
1432

15-
class FakeMessage(NamedTuple):
16-
message: Message
17-
answer: AsyncMock
33+
def _stage_send_message(bot: MockedBot) -> None:
34+
"""Pre-stage a successful SendMessage response."""
35+
bot.add_result_for(SendMessage, ok=True, result=_DUMMY_MSG)
1836

1937

20-
class FakeCallback(NamedTuple):
21-
callback: CallbackQuery
22-
answer: AsyncMock
23-
msg_edit_text: AsyncMock
24-
msg_answer: AsyncMock
38+
def _stage_callback_answer(bot: MockedBot) -> None:
39+
"""Pre-stage a successful AnswerCallbackQuery response."""
40+
bot.add_result_for(AnswerCallbackQuery, ok=True, result=True)
2541

2642

2743
def make_message(
44+
bot: MockedBot,
2845
text: str = "hello",
2946
user_id: int = 12345,
3047
message_id: int = 1,
31-
) -> FakeMessage:
32-
"""Create a fake Message with mocked answer/reply methods."""
33-
answer_mock = AsyncMock(name="message.answer")
48+
*,
49+
stage_replies: int = 1,
50+
) -> Message:
51+
"""Create a real Message with MockedBot injected.
52+
53+
Args:
54+
stage_replies: how many SendMessage responses to pre-stage
55+
(one per expected message.answer() call).
56+
"""
3457
msg = Message(
3558
message_id=message_id,
3659
date=datetime.now(),
3760
chat=TgChat(id=user_id, type="private"),
3861
from_user=TgUser(id=user_id, is_bot=False, first_name="Test"),
3962
text=text,
4063
)
41-
object.__setattr__(msg, "answer", answer_mock)
42-
return FakeMessage(message=msg, answer=answer_mock)
64+
msg.as_(bot)
65+
for _ in range(stage_replies):
66+
_stage_send_message(bot)
67+
return msg
4368

4469

4570
def make_callback(
71+
bot: MockedBot,
4672
data: str,
4773
user_id: int = 12345,
4874
message_id: int = 1,
49-
) -> FakeCallback:
50-
"""Create a fake CallbackQuery with mocked answer method and attached message."""
51-
edit_text_mock = AsyncMock(name="message.edit_text")
52-
msg_answer_mock = AsyncMock(name="message.answer")
53-
cb_answer_mock = AsyncMock(name="callback.answer")
54-
75+
*,
76+
stage_replies: int = 1,
77+
) -> CallbackQuery:
78+
"""Create a real CallbackQuery with MockedBot injected.
79+
80+
Args:
81+
stage_replies: how many AnswerCallbackQuery responses to pre-stage.
82+
"""
5583
inner_msg = Message(
5684
message_id=message_id,
5785
date=datetime.now(),
5886
chat=TgChat(id=user_id, type="private"),
5987
from_user=TgUser(id=user_id, is_bot=False, first_name="Test"),
6088
text="original",
6189
)
62-
object.__setattr__(inner_msg, "edit_text", edit_text_mock)
63-
object.__setattr__(inner_msg, "answer", msg_answer_mock)
64-
object.__setattr__(
65-
inner_msg,
66-
"edit_reply_markup",
67-
AsyncMock(name="message.edit_reply_markup"),
68-
)
69-
7090
cb = CallbackQuery(
7191
id="test_cb_1",
7292
chat_instance="test",
7393
from_user=TgUser(id=user_id, is_bot=False, first_name="Test"),
7494
message=inner_msg,
7595
data=data,
7696
)
77-
object.__setattr__(cb, "answer", cb_answer_mock)
78-
return FakeCallback(
79-
callback=cb,
80-
answer=cb_answer_mock,
81-
msg_edit_text=edit_text_mock,
82-
msg_answer=msg_answer_mock,
83-
)
97+
cb.as_(bot)
98+
for _ in range(stage_replies):
99+
_stage_callback_answer(bot)
100+
return cb
84101

85102

86103
@asynccontextmanager
87104
async def patch_get_db(
88105
session: AsyncSession,
89106
) -> AsyncGenerator[None, None]:
90107
"""Context manager that patches get_db in both handlers and activation module."""
91-
from unittest.mock import patch
92108

93109
async def fake_get_db() -> AsyncGenerator[AsyncSession, None]:
94110
yield session

tests/handlers/mocked_bot.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""MockedBot for testing — adapted from aiogram's own test suite.
2+
3+
Instead of hitting Telegram's API, MockedSession captures outgoing requests
4+
in a deque and returns pre-staged responses. Usage:
5+
6+
bot = MockedBot()
7+
msg.as_(bot) # inject into TelegramObject
8+
bot.add_result_for(SendMessage, ok=True) # stage a response
9+
await handler(msg) # run the handler
10+
req = bot.get_request() # inspect what was sent
11+
assert isinstance(req, SendMessage)
12+
assert req.text == "expected"
13+
14+
Source: https://github.com/aiogram/aiogram/blob/dev-3.x/tests/mocked_bot.py
15+
"""
16+
17+
from collections import deque
18+
from collections.abc import AsyncGenerator
19+
20+
from aiogram import Bot
21+
from aiogram.client.session.base import BaseSession
22+
from aiogram.methods import TelegramMethod
23+
from aiogram.methods.base import Response, TelegramType
24+
from aiogram.types import ResponseParameters, User
25+
26+
27+
class MockedSession(BaseSession):
28+
def __init__(self) -> None:
29+
super().__init__()
30+
self.responses: deque[Response[object]] = deque()
31+
self.requests: deque[TelegramMethod[object]] = deque()
32+
self.closed = True
33+
34+
def add_result(self, response: Response[object]) -> None:
35+
self.responses.append(response)
36+
37+
def get_request(self) -> TelegramMethod[object]:
38+
return self.requests.pop()
39+
40+
async def close(self) -> None:
41+
self.closed = True
42+
43+
async def make_request(
44+
self,
45+
bot: Bot,
46+
method: TelegramMethod[TelegramType],
47+
timeout: int | None = None,
48+
) -> TelegramType:
49+
self.closed = False
50+
self.requests.append(method) # type: ignore[arg-type]
51+
response = self.responses.pop()
52+
self.check_response(
53+
bot=bot,
54+
method=method,
55+
status_code=response.error_code or 200,
56+
content=response.model_dump_json(),
57+
)
58+
return response.result # type: ignore[return-value]
59+
60+
async def stream_content(
61+
self,
62+
url: str,
63+
headers: dict[str, str] | None = None,
64+
timeout: int = 30,
65+
chunk_size: int = 65536,
66+
raise_for_status: bool = True,
67+
) -> AsyncGenerator[bytes, None]: # pragma: no cover
68+
yield b""
69+
70+
71+
class MockedBot(Bot):
72+
session: MockedSession # type: ignore[assignment]
73+
74+
def __init__(self) -> None:
75+
super().__init__("42:TEST", session=MockedSession())
76+
self._me = User(
77+
id=self.id,
78+
is_bot=True,
79+
first_name="TestBot",
80+
username="test_bot",
81+
)
82+
83+
def add_result_for(
84+
self,
85+
method: type[TelegramMethod[TelegramType]],
86+
ok: bool,
87+
result: TelegramType | None = None,
88+
description: str | None = None,
89+
error_code: int = 200,
90+
) -> None:
91+
response = Response[method.__returning__]( # type: ignore[name-defined]
92+
ok=ok,
93+
result=result,
94+
description=description,
95+
error_code=error_code,
96+
parameters=ResponseParameters(),
97+
)
98+
self.session.add_result(response)
99+
100+
def get_request(self) -> TelegramMethod[object]:
101+
return self.session.get_request()

0 commit comments

Comments
 (0)