Skip to content

Commit 4aa3ca2

Browse files
committed
chore: Add unit tests for ToolboxSyncTool
1 parent 83c6763 commit 4aa3ca2

File tree

2 files changed

+249
-0
lines changed

2 files changed

+249
-0
lines changed

packages/toolbox-core/src/toolbox_core/sync_tool.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
1516
import asyncio
1617
from asyncio import AbstractEventLoop
1718
from inspect import Signature
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
import asyncio
17+
from inspect import Parameter, Signature
18+
from threading import Thread
19+
from typing import Any, Callable, Mapping, Union
20+
from unittest.mock import MagicMock, Mock, patch
21+
22+
import pytest
23+
24+
from toolbox_core.sync_tool import ToolboxSyncTool
25+
from toolbox_core.tool import ToolboxTool
26+
27+
28+
@pytest.fixture
29+
def mock_async_tool() -> MagicMock:
30+
"""Fixture for a MagicMock simulating a ToolboxTool instance."""
31+
tool = MagicMock(spec=ToolboxTool)
32+
tool.__name__ = "mock_async_tool_name"
33+
tool.__doc__ = "Mock async tool documentation."
34+
35+
# Create a simple signature for the mock tool
36+
param_a = Parameter("a", Parameter.POSITIONAL_OR_KEYWORD, annotation=str)
37+
param_b = Parameter(
38+
"b", Parameter.POSITIONAL_OR_KEYWORD, annotation=int, default=10
39+
)
40+
tool.__signature__ = Signature(parameters=[param_a, param_b])
41+
42+
tool.__annotations__ = {"a": str, "b": int, "return": str}
43+
44+
# Mock the __call__ method to return a coroutine (MagicMock that can be awaited)
45+
# We'll make the internal call return a simple value for testing the sync wrapper
46+
async def mock_async_call(*args, **kwargs):
47+
return f"async_called_with_{args}_{kwargs}"
48+
49+
tool.__call__ = MagicMock(side_effect=lambda *a, **k: mock_async_call(*a, **k))
50+
51+
# Mock methods that return a new async_tool
52+
tool.add_auth_token_getters = MagicMock(return_value=MagicMock(spec=ToolboxTool))
53+
tool.bind_params = MagicMock(return_value=MagicMock(spec=ToolboxTool))
54+
55+
return tool
56+
57+
58+
@pytest.fixture
59+
def event_loop() -> asyncio.AbstractEventLoop:
60+
"""Fixture for an event loop."""
61+
# Using asyncio.get_event_loop() might be problematic if no loop is set.
62+
# For this test setup, we'll mock `run_coroutine_threadsafe` directly.
63+
return Mock(spec=asyncio.AbstractEventLoop)
64+
65+
66+
@pytest.fixture
67+
def mock_thread() -> MagicMock:
68+
"""Fixture for a mock Thread."""
69+
return MagicMock(spec=Thread)
70+
71+
72+
@pytest.fixture
73+
def toolbox_sync_tool(
74+
mock_async_tool: MagicMock,
75+
event_loop: asyncio.AbstractEventLoop,
76+
mock_thread: MagicMock,
77+
) -> ToolboxSyncTool:
78+
"""Fixture for a ToolboxSyncTool instance."""
79+
return ToolboxSyncTool(mock_async_tool, event_loop, mock_thread)
80+
81+
82+
def test_toolbox_sync_tool_init_success(
83+
mock_async_tool: MagicMock,
84+
event_loop: asyncio.AbstractEventLoop,
85+
mock_thread: MagicMock,
86+
):
87+
"""Tests successful initialization of ToolboxSyncTool."""
88+
tool = ToolboxSyncTool(mock_async_tool, event_loop, mock_thread)
89+
assert tool._ToolboxSyncTool__async_tool is mock_async_tool
90+
assert tool._ToolboxSyncTool__loop is event_loop
91+
assert tool._ToolboxSyncTool__thread is mock_thread
92+
assert tool.__qualname__ == f"ToolboxSyncTool.{mock_async_tool.__name__}"
93+
94+
95+
def test_toolbox_sync_tool_init_type_error():
96+
"""Tests TypeError if async_tool is not a ToolboxTool instance."""
97+
with pytest.raises(
98+
TypeError, match="async_tool must be an instance of ToolboxTool"
99+
):
100+
ToolboxSyncTool("not_a_toolbox_tool", Mock(), Mock())
101+
102+
103+
def test_toolbox_sync_tool_name_property(
104+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
105+
):
106+
"""Tests the __name__ property."""
107+
assert toolbox_sync_tool.__name__ == mock_async_tool.__name__
108+
109+
110+
def test_toolbox_sync_tool_doc_property(
111+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
112+
):
113+
"""Tests the __doc__ property."""
114+
assert toolbox_sync_tool.__doc__ == mock_async_tool.__doc__
115+
116+
# Test with __doc__ = None
117+
mock_async_tool.__doc__ = None
118+
sync_tool_no_doc = ToolboxSyncTool(mock_async_tool, Mock(), Mock())
119+
assert sync_tool_no_doc.__doc__ is None
120+
121+
122+
def test_toolbox_sync_tool_signature_property(
123+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
124+
):
125+
"""Tests the __signature__ property."""
126+
assert toolbox_sync_tool.__signature__ is mock_async_tool.__signature__
127+
128+
129+
def test_toolbox_sync_tool_annotations_property(
130+
toolbox_sync_tool: ToolboxSyncTool, mock_async_tool: MagicMock
131+
):
132+
"""Tests the __annotations__ property."""
133+
assert toolbox_sync_tool.__annotations__ is mock_async_tool.__annotations__
134+
135+
136+
@patch("asyncio.run_coroutine_threadsafe")
137+
def test_toolbox_sync_tool_call(
138+
mock_run_coroutine_threadsafe: MagicMock,
139+
toolbox_sync_tool: ToolboxSyncTool,
140+
mock_async_tool: MagicMock,
141+
event_loop: asyncio.AbstractEventLoop,
142+
):
143+
"""Tests the __call__ method."""
144+
mock_future = MagicMock()
145+
expected_result = "call_result"
146+
mock_future.result.return_value = expected_result
147+
mock_run_coroutine_threadsafe.return_value = mock_future
148+
149+
args_tuple = ("test_arg",)
150+
kwargs_dict = {"kwarg1": "value1"}
151+
152+
# Create a mock coroutine to be returned by async_tool.__call__
153+
mock_coro = MagicMock() # Represents the coroutine object
154+
mock_async_tool.return_value = mock_coro # async_tool() returns mock_coro
155+
156+
result = toolbox_sync_tool(*args_tuple, **kwargs_dict)
157+
158+
mock_async_tool.assert_called_once_with(*args_tuple, **kwargs_dict)
159+
mock_run_coroutine_threadsafe.assert_called_once_with(mock_coro, event_loop)
160+
mock_future.result.assert_called_once_with()
161+
assert result == expected_result
162+
163+
164+
def test_toolbox_sync_tool_add_auth_token_getters(
165+
toolbox_sync_tool: ToolboxSyncTool,
166+
mock_async_tool: MagicMock,
167+
event_loop: asyncio.AbstractEventLoop,
168+
mock_thread: MagicMock,
169+
):
170+
"""Tests the add_auth_token_getters method."""
171+
auth_getters: Mapping[str, Callable[[], str]] = {"service1": lambda: "token1"}
172+
173+
# The mock_async_tool.add_auth_token_getters is already set up to return a new MagicMock
174+
new_mock_async_tool = mock_async_tool.add_auth_token_getters.return_value
175+
new_mock_async_tool.__name__ = "new_async_tool_with_auth" # for __qualname__
176+
177+
new_sync_tool = toolbox_sync_tool.add_auth_token_getters(auth_getters)
178+
179+
mock_async_tool.add_auth_token_getters.assert_called_once_with(auth_getters)
180+
181+
assert isinstance(new_sync_tool, ToolboxSyncTool)
182+
assert new_sync_tool is not toolbox_sync_tool
183+
assert new_sync_tool._ToolboxSyncTool__async_tool is new_mock_async_tool
184+
assert new_sync_tool._ToolboxSyncTool__loop is event_loop # Should be the same loop
185+
assert (
186+
new_sync_tool._ToolboxSyncTool__thread is mock_thread
187+
) # Should be the same thread
188+
assert (
189+
new_sync_tool.__qualname__ == f"ToolboxSyncTool.{new_mock_async_tool.__name__}"
190+
)
191+
192+
193+
def test_toolbox_sync_tool_bind_params(
194+
toolbox_sync_tool: ToolboxSyncTool,
195+
mock_async_tool: MagicMock,
196+
event_loop: asyncio.AbstractEventLoop,
197+
mock_thread: MagicMock,
198+
):
199+
"""Tests the bind_params method."""
200+
bound_params: Mapping[str, Union[Callable[[], Any], Any]] = {
201+
"param1": "value1",
202+
"param2": lambda: "value2",
203+
}
204+
205+
new_mock_async_tool = mock_async_tool.bind_params.return_value
206+
new_mock_async_tool.__name__ = "new_async_tool_with_bound_params"
207+
208+
new_sync_tool = toolbox_sync_tool.bind_params(bound_params)
209+
210+
mock_async_tool.bind_params.assert_called_once_with(bound_params)
211+
212+
assert isinstance(new_sync_tool, ToolboxSyncTool)
213+
assert new_sync_tool is not toolbox_sync_tool
214+
assert new_sync_tool._ToolboxSyncTool__async_tool is new_mock_async_tool
215+
assert new_sync_tool._ToolboxSyncTool__loop is event_loop
216+
assert new_sync_tool._ToolboxSyncTool__thread is mock_thread
217+
assert (
218+
new_sync_tool.__qualname__ == f"ToolboxSyncTool.{new_mock_async_tool.__name__}"
219+
)
220+
221+
222+
def test_toolbox_sync_tool_bind_param(
223+
toolbox_sync_tool: ToolboxSyncTool,
224+
mock_async_tool: MagicMock,
225+
event_loop: asyncio.AbstractEventLoop,
226+
mock_thread: MagicMock,
227+
):
228+
"""Tests the bind_param method."""
229+
param_name = "my_param"
230+
param_value = "my_value"
231+
232+
new_mock_async_tool = mock_async_tool.bind_params.return_value
233+
new_mock_async_tool.__name__ = "new_async_tool_with_single_bound_param"
234+
235+
# Since bind_param calls self.bind_params, which in turn calls async_tool.bind_params,
236+
# we check that async_tool.bind_params is called correctly.
237+
new_sync_tool = toolbox_sync_tool.bind_param(param_name, param_value)
238+
239+
mock_async_tool.bind_params.assert_called_once_with({param_name: param_value})
240+
241+
assert isinstance(new_sync_tool, ToolboxSyncTool)
242+
assert new_sync_tool is not toolbox_sync_tool
243+
assert new_sync_tool._ToolboxSyncTool__async_tool is new_mock_async_tool
244+
assert new_sync_tool._ToolboxSyncTool__loop is event_loop
245+
assert new_sync_tool._ToolboxSyncTool__thread is mock_thread
246+
assert (
247+
new_sync_tool.__qualname__ == f"ToolboxSyncTool.{new_mock_async_tool.__name__}"
248+
)

0 commit comments

Comments
 (0)