Skip to content

Commit cd1e311

Browse files
CopilotlarohraTaoChenOSU
authored
Python: Achieve 85%+ unit test coverage for azurefunctions package (#3866)
* Initial plan * Initial analysis: azurefunctions package at 80% coverage, need 85% Co-authored-by: larohra <41490930+larohra@users.noreply.github.com> * Add comprehensive unit tests to achieve 86% coverage for azurefunctions package Co-authored-by: larohra <41490930+larohra@users.noreply.github.com> * Add comprehensive coverage report documentation for azurefunctions package Co-authored-by: larohra <41490930+larohra@users.noreply.github.com> * Fix linting errors: combine nested with statements in test_entities.py Co-authored-by: larohra <41490930+larohra@users.noreply.github.com> * Remove COVERAGE_REPORT.md and coverage.json files as requested Co-authored-by: larohra <41490930+larohra@users.noreply.github.com> * Address PR review feedback: fix unused variables, remove line numbers from docstrings, improve test clarity Co-authored-by: larohra <41490930+larohra@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: larohra <41490930+larohra@users.noreply.github.com> Co-authored-by: Laveesh Rohra <larohra@microsoft.com> Co-authored-by: Tao Chen <taochen@microsoft.com>
1 parent e563849 commit cd1e311

4 files changed

Lines changed: 339 additions & 0 deletions

File tree

python/packages/azurefunctions/tests/test_app.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,5 +1164,158 @@ def decorator(func: FuncT) -> FuncT:
11641164
assert body["agents"][0]["mcp_tool_enabled"] is True
11651165

11661166

1167+
class TestAgentFunctionAppErrorPaths:
1168+
"""Test suite for error handling paths."""
1169+
1170+
def test_init_with_invalid_max_poll_retries(self) -> None:
1171+
"""Test initialization handles invalid max_poll_retries by falling back to default."""
1172+
mock_agent = Mock()
1173+
mock_agent.name = "TestAgent"
1174+
1175+
# Test with invalid type
1176+
app = AgentFunctionApp(agents=[mock_agent], max_poll_retries="invalid")
1177+
assert app.max_poll_retries >= 1 # Should use default
1178+
1179+
# Test with None
1180+
app2 = AgentFunctionApp(agents=[mock_agent], max_poll_retries=None)
1181+
assert app2.max_poll_retries >= 1 # Should use default
1182+
1183+
def test_init_with_invalid_poll_interval_seconds(self) -> None:
1184+
"""Test initialization handles invalid poll_interval_seconds by falling back to default."""
1185+
mock_agent = Mock()
1186+
mock_agent.name = "TestAgent"
1187+
1188+
# Test with invalid type
1189+
app = AgentFunctionApp(agents=[mock_agent], poll_interval_seconds="invalid")
1190+
assert app.poll_interval_seconds > 0 # Should use default
1191+
1192+
# Test with None
1193+
app2 = AgentFunctionApp(agents=[mock_agent], poll_interval_seconds=None)
1194+
assert app2.poll_interval_seconds > 0 # Should use default
1195+
1196+
def test_get_agent_raises_for_unregistered_agent(self) -> None:
1197+
"""Test get_agent raises ValueError for unregistered agent."""
1198+
mock_agent = Mock()
1199+
mock_agent.name = "RegisteredAgent"
1200+
1201+
app = AgentFunctionApp(agents=[mock_agent], enable_http_endpoints=False)
1202+
1203+
# Create mock orchestration context
1204+
mock_context = Mock()
1205+
1206+
# Should raise ValueError for unregistered agent
1207+
with pytest.raises(ValueError, match="Agent 'UnknownAgent' is not registered"):
1208+
app.get_agent(mock_context, "UnknownAgent")
1209+
1210+
def test_convert_payload_to_text_with_response_key(self) -> None:
1211+
"""Test _convert_payload_to_text returns response key value."""
1212+
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
1213+
1214+
# Test with response key
1215+
payload = {"response": "Test response"}
1216+
result = app._convert_payload_to_text(payload)
1217+
assert result == "Test response"
1218+
1219+
# Test with error key
1220+
payload = {"error": "Error message"}
1221+
result = app._convert_payload_to_text(payload)
1222+
assert result == "Error message"
1223+
1224+
# Test with message key
1225+
payload = {"message": "Message text"}
1226+
result = app._convert_payload_to_text(payload)
1227+
assert result == "Message text"
1228+
1229+
# Test with no matching keys - should return JSON string
1230+
payload = {"other": "value"}
1231+
result = app._convert_payload_to_text(payload)
1232+
assert "other" in result
1233+
assert "value" in result
1234+
1235+
def test_create_session_id_with_thread_id(self) -> None:
1236+
"""Test _create_session_id with provided thread_id."""
1237+
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
1238+
1239+
# With thread_id provided
1240+
session_id = app._create_session_id("TestAgent", "my-thread-123")
1241+
assert session_id.key == "my-thread-123"
1242+
1243+
# Without thread_id (None) - should generate random
1244+
session_id = app._create_session_id("TestAgent", None)
1245+
assert session_id.key is not None
1246+
assert len(session_id.key) > 0
1247+
1248+
def test_resolve_thread_id_from_body(self) -> None:
1249+
"""Test _resolve_thread_id extracts from body."""
1250+
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
1251+
1252+
mock_req = Mock()
1253+
mock_req.params = {}
1254+
1255+
# Thread ID in body - field name is "thread_id"
1256+
req_body = {"thread_id": "body-thread-123"}
1257+
result = app._resolve_thread_id(mock_req, req_body)
1258+
assert result == "body-thread-123"
1259+
1260+
def test_select_body_parser_json_content_type(self) -> None:
1261+
"""Test _select_body_parser for JSON content type."""
1262+
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
1263+
1264+
# Test with application/json
1265+
parser, format_str = app._select_body_parser("application/json")
1266+
assert parser == app._parse_json_body
1267+
assert format_str == "json"
1268+
1269+
# Test with +json suffix
1270+
parser, format_str = app._select_body_parser("application/vnd.api+json")
1271+
assert parser == app._parse_json_body
1272+
assert format_str == "json"
1273+
1274+
def test_accepts_json_response_with_accept_header(self) -> None:
1275+
"""Test _accepts_json_response checks accept header."""
1276+
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
1277+
1278+
# With application/json in accept header
1279+
headers = {"accept": "application/json"}
1280+
result = app._accepts_json_response(headers)
1281+
assert result is True
1282+
1283+
# Without accept header
1284+
headers = {}
1285+
result = app._accepts_json_response(headers)
1286+
assert result is False
1287+
1288+
def test_parse_json_body_invalid_type(self) -> None:
1289+
"""Test _parse_json_body raises error for invalid JSON."""
1290+
from agent_framework_azurefunctions._errors import IncomingRequestError
1291+
1292+
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
1293+
1294+
# Mock request with non-dict JSON
1295+
mock_req = Mock()
1296+
mock_req.get_json.return_value = ["not", "a", "dict"]
1297+
1298+
with pytest.raises(IncomingRequestError, match="Invalid JSON payload"):
1299+
app._parse_json_body(mock_req)
1300+
1301+
def test_coerce_to_bool_with_none(self) -> None:
1302+
"""Test _coerce_to_bool handles None and various value types."""
1303+
app = AgentFunctionApp(enable_http_endpoints=False, enable_health_check=False)
1304+
1305+
# None returns False
1306+
assert app._coerce_to_bool(None) is False
1307+
1308+
# Integer
1309+
assert app._coerce_to_bool(1) is True
1310+
assert app._coerce_to_bool(0) is False
1311+
1312+
# String
1313+
assert app._coerce_to_bool("true") is True
1314+
assert app._coerce_to_bool("false") is False
1315+
1316+
# Other type returns False
1317+
assert app._coerce_to_bool([]) is False
1318+
1319+
11671320
if __name__ == "__main__":
11681321
pytest.main([__file__, "-v", "--tb=short"])

python/packages/azurefunctions/tests/test_entities.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,114 @@ def test_entity_function_restores_existing_state(self) -> None:
198198
persisted_state = mock_context.set_state.call_args[0][0]
199199
assert persisted_state["data"]["conversationHistory"] == []
200200

201+
def test_entity_function_handles_string_input(self) -> None:
202+
"""Test that the entity function handles non-dict input by converting to string."""
203+
mock_agent = Mock()
204+
mock_agent.run = AsyncMock(return_value=_agent_response("String response"))
205+
206+
entity_function = create_agent_entity(mock_agent)
207+
208+
# Mock context with non-dict input (like a number)
209+
mock_context = Mock()
210+
mock_context.operation_name = "run"
211+
mock_context.entity_key = "conv-456"
212+
# Use a number to test the str() conversion path
213+
mock_context.get_input.return_value = 12345
214+
mock_context.get_state.return_value = None
215+
216+
# Execute - entity will convert non-dict input to string
217+
entity_function(mock_context)
218+
219+
# Verify the result was set
220+
assert mock_context.set_result.called
221+
222+
def test_entity_function_handles_none_input(self) -> None:
223+
"""Test that the entity function handles None input by converting to empty string."""
224+
mock_agent = Mock()
225+
mock_agent.run = AsyncMock(return_value=_agent_response("Empty response"))
226+
227+
entity_function = create_agent_entity(mock_agent)
228+
229+
# Mock context with None input
230+
mock_context = Mock()
231+
mock_context.operation_name = "run"
232+
mock_context.entity_key = "conv-789"
233+
mock_context.get_input.return_value = None
234+
mock_context.get_state.return_value = None
235+
236+
# Execute - should hit error path since entity expects dict or valid JSON string
237+
entity_function(mock_context)
238+
239+
# Verify the result was set (likely error result)
240+
assert mock_context.set_result.called
241+
242+
def test_entity_function_handles_event_loop_runtime_error(self) -> None:
243+
"""Test that the entity function handles RuntimeError from get_event_loop by creating a new loop."""
244+
from unittest.mock import patch
245+
246+
mock_agent = Mock()
247+
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
248+
249+
entity_function = create_agent_entity(mock_agent)
250+
251+
mock_context = Mock()
252+
mock_context.operation_name = "run"
253+
mock_context.entity_key = "conv-loop-test"
254+
mock_context.get_input.return_value = {"message": "Test"}
255+
mock_context.get_state.return_value = None
256+
257+
# Simulate RuntimeError when getting event loop
258+
with (
259+
patch("asyncio.get_event_loop", side_effect=RuntimeError("No event loop")),
260+
patch("asyncio.new_event_loop") as mock_new_loop,
261+
patch("asyncio.set_event_loop") as mock_set_loop,
262+
):
263+
mock_loop = Mock()
264+
mock_loop.is_running.return_value = False
265+
mock_loop.run_until_complete = Mock()
266+
mock_new_loop.return_value = mock_loop
267+
268+
# Execute
269+
entity_function(mock_context)
270+
271+
# Verify new event loop was created
272+
mock_new_loop.assert_called_once()
273+
mock_set_loop.assert_called_once_with(mock_loop)
274+
275+
def test_entity_function_handles_running_event_loop(self) -> None:
276+
"""Test that the entity function handles a running event loop by creating a temporary loop."""
277+
from unittest.mock import patch
278+
279+
mock_agent = Mock()
280+
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
281+
282+
entity_function = create_agent_entity(mock_agent)
283+
284+
mock_context = Mock()
285+
mock_context.operation_name = "run"
286+
mock_context.entity_key = "conv-running-loop"
287+
mock_context.get_input.return_value = {"message": "Test"}
288+
mock_context.get_state.return_value = None
289+
290+
# Simulate a running event loop
291+
mock_existing_loop = Mock()
292+
mock_existing_loop.is_running.return_value = True
293+
294+
mock_temp_loop = Mock()
295+
mock_temp_loop.run_until_complete = Mock()
296+
mock_temp_loop.close = Mock()
297+
298+
with (
299+
patch("asyncio.get_event_loop", return_value=mock_existing_loop),
300+
patch("asyncio.new_event_loop", return_value=mock_temp_loop),
301+
):
302+
# Execute
303+
entity_function(mock_context)
304+
305+
# Verify temporary loop was created and closed
306+
mock_temp_loop.run_until_complete.assert_called_once()
307+
mock_temp_loop.close.assert_called_once()
308+
201309

202310
if __name__ == "__main__":
203311
pytest.main([__file__, "-v", "--tb=short"])
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
"""Unit tests for custom exception types."""
4+
5+
import pytest
6+
7+
from agent_framework_azurefunctions._errors import IncomingRequestError
8+
9+
10+
class TestIncomingRequestError:
11+
"""Test suite for IncomingRequestError exception."""
12+
13+
def test_incoming_request_error_default_status_code(self) -> None:
14+
"""Test that IncomingRequestError has a default status code of 400."""
15+
error = IncomingRequestError("Invalid request")
16+
17+
assert str(error) == "Invalid request"
18+
assert error.status_code == 400
19+
20+
def test_incoming_request_error_custom_status_code(self) -> None:
21+
"""Test that IncomingRequestError can have a custom status code."""
22+
error = IncomingRequestError("Unauthorized", status_code=401)
23+
24+
assert str(error) == "Unauthorized"
25+
assert error.status_code == 401
26+
27+
def test_incoming_request_error_is_value_error(self) -> None:
28+
"""Test that IncomingRequestError inherits from ValueError."""
29+
error = IncomingRequestError("Test error")
30+
31+
assert isinstance(error, ValueError)
32+
33+
def test_incoming_request_error_can_be_raised_and_caught(self) -> None:
34+
"""Test that IncomingRequestError can be raised and caught."""
35+
with pytest.raises(IncomingRequestError) as exc_info:
36+
raise IncomingRequestError("Bad request", status_code=400)
37+
38+
assert exc_info.value.status_code == 400

python/packages/azurefunctions/tests/test_orchestration.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,25 @@ def executor_with_context(mock_context_with_uuid: tuple[Mock, str]) -> tuple[Any
129129
class TestAgentResponseHelpers:
130130
"""Tests for response handling through public AgentTask API."""
131131

132+
def test_try_set_value_exception_handling(self) -> None:
133+
"""Test try_set_value handles exceptions raised when converting a successful task result to AgentResponse."""
134+
entity_task = _create_entity_task()
135+
task = AgentTask(entity_task, None, "correlation-id")
136+
137+
# Simulate successful entity task with invalid result that causes exception
138+
entity_task.state = TaskState.SUCCEEDED
139+
entity_task.result = {"invalid": "format"} # Missing required fields for AgentResponse
140+
141+
# Clear pending_tasks to simulate that parent has processed the child
142+
task.pending_tasks.clear()
143+
144+
# Call try_set_value - should catch exception and set error
145+
task.try_set_value(entity_task)
146+
147+
# Verify task failed due to conversion exception
148+
assert task.state == TaskState.FAILED
149+
assert isinstance(task.result, Exception)
150+
132151
def test_try_set_value_success(self) -> None:
133152
"""Test try_set_value correctly processes successful task completion."""
134153
entity_task = _create_entity_task()
@@ -279,6 +298,27 @@ def test_blocking_mode_still_works(self, executor_with_uuid: tuple[Any, Mock, st
279298
assert isinstance(result, AgentTask)
280299

281300

301+
class TestAzureFunctionsAgentExecutor:
302+
"""Tests for AzureFunctionsAgentExecutor."""
303+
304+
def test_generate_unique_id(self, mock_context_with_uuid: tuple[Mock, str]) -> None:
305+
"""Test generate_unique_id method returns UUID from orchestration context."""
306+
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
307+
308+
context, _ = mock_context_with_uuid
309+
executor = AzureFunctionsAgentExecutor(context)
310+
311+
# Call generate_unique_id
312+
unique_id = executor.generate_unique_id()
313+
314+
# Verify it returns the UUID from context (as string with dashes)
315+
# The UUID is returned in standard format with dashes
316+
context.new_uuid.assert_called_once()
317+
# Just verify it's a string representation of UUID
318+
assert isinstance(unique_id, str)
319+
assert len(unique_id) > 0
320+
321+
282322
class TestOrchestrationIntegration:
283323
"""Integration tests for orchestration scenarios."""
284324

0 commit comments

Comments
 (0)