Skip to content

Commit fc75b66

Browse files
docs(tests): correct the e2e expected-failure helper guidance (#2252)
* docs(tests): correct the e2e expected-failure helper guidance tests/AGENTS.md told tests expecting a tool failure to use safe_call_tool() directly. The harness disagrees: MCPAssertions .call_tool_failure() is used at 61 call sites across 13 e2e files and does strictly more -- it catches the ToolError, asserts the call actually failed rather than silently succeeding, and matches expected_error against the extracted message. safe_call_tool() swallows ToolError and returns a parsed dict, so using it for an expected failure leaves nothing verifying the call failed. Its real roles are finally-block cleanup and availability probing, which the entry now says. CodeRabbit cites the old line as a coding-guidelines violation whenever call_tool_failure appears, most recently on #2246. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015c4gkE9mcKu4RiuQzR5eff * docs(tests): scope the failure-helper claim and fix safe_call_tool's docstring Two corrections from Codex review: call_tool_failure() only proves the call failed when expected_error is given. assert_mcp_failure()'s check is `if data.get("success")`, so a result dict that omits the key entirely is accepted as a failure. The guidance now says to pass expected_error and states the limitation rather than claiming the helper always proves failure. safe_call_tool()'s own docstring still advertised it as the helper for tests that expect a failure, contradicting the new rule in a second place and recreating the ambiguity this branch removes. It now names its real roles -- finally-block cleanup and availability probing -- and points at call_tool_failure() for asserted failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015c4gkE9mcKu4RiuQzR5eff * docs(tests): do not require expected_error the suite does not pass The previous wording made expected_error mandatory. 30 of the 61 call_tool_failure() sites omit it and assert on the returned dict themselves, so that rule would have been broken by half the suite on the day it landed -- the same doc-contradicts-code problem this branch exists to fix. Now stated as a preference, with manual post-assertion named as the equally valid alternative, and the actual failure mode (omitting both) called out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015c4gkE9mcKu4RiuQzR5eff * fix(tests): make assert_mcp_failure reject every success shape assert_mcp_failure guarded on `if data.get("success")`, so a result dict that succeeds WITHOUT that key -- ha_manage_app's pending_restart write, bulk-operation payloads carrying total_operations/results -- was accepted as a failure. An expected-failure test whose tool regressed to one of those shapes passed silently, and 30 of the 61 call_tool_failure sites omit expected_error, so nothing else caught it either. The success-indicator list assert_mcp_success already maintained is now a shared `looks_like_success()` predicate used by both sides, so the two cannot disagree about what success means. call_tool_failure's own ToolError branch uses it too. Documented in tests/AGENTS.md alongside it: the helper now proves failure on its own, and expected_error pins which failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015c4gkE9mcKu4RiuQzR5eff * test(e2e): pin assert_mcp_failure against every success shape The helper fix in fa09814 had no regression test, and this is the class of harness bug that degrades into a green run rather than a red one -- exactly why test_e2e_assertions.py exists. Covers the shapes that used to slip through as failures (pending_restart, bulk payloads keyed on total_operations/results, data-without-error), the genuine failures that must still pass, expected_error matching on a real failure, and pending_restart carrying an explicit error (which the success predicate guards on error is None, so it stays a failure). Four of the five success-shape cases fail against the pre-fix guard: data.get("success") on a dict lacking the key is None, so nothing raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015c4gkE9mcKu4RiuQzR5eff --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 45824cd commit fc75b66

3 files changed

Lines changed: 107 additions & 19 deletions

File tree

tests/AGENTS.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,16 @@ rather than assume the e2e's always-present case.
4949
## Test Patterns
5050

5151
- Tests expecting tool **success**: use `mcp.call_tool_success()` inside `MCPAssertions` context
52-
- Tests expecting tool **failure**: use `safe_call_tool()` directly (catches `ToolError`, returns parsed dict)
53-
- Service availability checks should use `safe_call_tool` to probe, not `call_tool_success`
52+
- Tests expecting tool **failure**: use `mcp.call_tool_failure()` inside `MCPAssertions`
53+
context. It rejects any result `assert_mcp_success()` would accept — including the
54+
tools that succeed with no `success` key (`pending_restart`, bulk-operation payloads)
55+
— so it genuinely proves the call failed. Prefer also passing `expected_error` to pin
56+
*which* failure; about half the current call sites omit it and assert on the returned
57+
dict themselves instead, which is equally fine.
58+
- `safe_call_tool()` is for calls whose outcome the test does **not** assert: `finally`
59+
cleanup (so a cleanup failure cannot mask the real assertion) and service-availability
60+
probes. It swallows `ToolError` and returns a parsed dict, so using it for an expected
61+
failure means nothing verifies the call failed at all.
5462

5563
## E2E Test Patterns
5664

tests/src/e2e/utilities/assertions.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,11 @@ async def safe_call_tool(
113113
) -> dict[str, Any]:
114114
"""Call an MCP tool and return parsed result, handling ToolError exceptions.
115115
116-
This is useful for tests that expect tools to fail and want to inspect
117-
the error response without catching exceptions manually.
116+
For a call whose outcome the test does NOT assert: ``finally``-block cleanup
117+
(so a cleanup failure cannot mask the real assertion) and service-availability
118+
probes. It swallows ``ToolError`` and returns a parsed dict either way, so a
119+
test that asserts a failure should use ``MCPAssertions.call_tool_failure()``
120+
with ``expected_error`` instead -- see tests/AGENTS.md "Test Patterns".
118121
119122
Args:
120123
mcp_client: The MCP client instance
@@ -131,17 +134,16 @@ async def safe_call_tool(
131134
return tool_error_to_result(exc)
132135

133136

134-
def assert_mcp_success(result, operation_name: str = "operation"):
135-
"""
136-
Assert that MCP tool result indicates success.
137+
def looks_like_success(data: dict[str, Any]) -> bool:
138+
"""Whether a parsed tool result is a success response.
137139
138-
Args:
139-
result: FastMCP client result
140-
operation_name: Name of operation for error message
140+
Shared by ``assert_mcp_success`` and ``assert_mcp_failure`` so the two
141+
cannot disagree about what "success" means. Several tools succeed
142+
WITHOUT a ``success`` key (``pending_restart``, bulk-operation
143+
payloads), so a bare ``data.get("success")`` check treats those as
144+
failures -- which is fine for the success assertion (it lists them
145+
explicitly) but silently accepted them as failures on the other side.
141146
"""
142-
data = parse_mcp_result(result)
143-
144-
# Handle different success indicators
145147
success_indicators = [
146148
data.get("success") is True,
147149
# ha_manage_app's options/network write returns
@@ -172,7 +174,20 @@ def assert_mcp_success(result, operation_name: str = "operation"):
172174
),
173175
]
174176

175-
if not any(success_indicators):
177+
return any(success_indicators)
178+
179+
180+
def assert_mcp_success(result, operation_name: str = "operation"):
181+
"""
182+
Assert that MCP tool result indicates success.
183+
184+
Args:
185+
result: FastMCP client result
186+
operation_name: Name of operation for error message
187+
"""
188+
data = parse_mcp_result(result)
189+
190+
if not looks_like_success(data):
176191
error_msg = data.get("error", "Unknown error")
177192
suggestions = data.get("suggestions", [])
178193

@@ -199,8 +214,12 @@ def assert_mcp_failure(
199214
"""
200215
data = parse_mcp_result(result)
201216

202-
# Check that operation actually failed
203-
if data.get("success"):
217+
# Check that operation actually failed. Uses the shared success
218+
# predicate, not a bare data.get("success"): a tool that succeeds
219+
# without a success key (pending_restart, bulk-operation payloads)
220+
# would otherwise be accepted here as a failure, so a regression that
221+
# made an expected-failure call SUCCEED could pass unnoticed.
222+
if looks_like_success(data):
204223
raise AssertionError(f"{operation_name} should have failed but succeeded")
205224

206225
# If expected error specified, check for it
@@ -380,8 +399,9 @@ async def call_tool_failure(
380399
except ToolError as exc:
381400
# Convert ToolError to result dict and validate
382401
data = tool_error_to_result(exc)
383-
# Verify this is actually a failure
384-
if data.get("success"):
402+
# Verify this is actually a failure (shared predicate, see
403+
# assert_mcp_failure)
404+
if looks_like_success(data):
385405
raise AssertionError(
386406
f"{operation_name} should have failed but succeeded"
387407
) from exc

tests/src/unit/test_e2e_assertions.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@
2929
import pytest
3030

3131
from tests.src.e2e.error_handling.test_network_errors import _hard_failures
32-
from tests.src.e2e.utilities.assertions import assert_mcp_success, parse_mcp_result
32+
from tests.src.e2e.utilities.assertions import (
33+
assert_mcp_failure,
34+
assert_mcp_success,
35+
parse_mcp_result,
36+
)
3337

3438

3539
def test_pending_restart_is_a_successful_mcp_result() -> None:
@@ -221,3 +225,59 @@ def test_mixed_batch_returns_only_the_untolerated_failures(self):
221225
healthy_bulk = {"successful_commands": 2, "failed_commands": 0}
222226
results = [exc, tolerated, tool_error, healthy_bulk, {"success": True}]
223227
assert _hard_failures(results) == [exc, tool_error]
228+
229+
230+
class TestFailureAssertionRejectsEverySuccessShape:
231+
"""``assert_mcp_failure`` must reject anything ``assert_mcp_success``
232+
accepts.
233+
234+
It used to guard on a bare ``if data.get("success")``, so the tools that
235+
succeed WITHOUT that key sailed through as failures: ``ha_manage_app``'s
236+
options/network write returns ``{"status": "pending_restart"}``, and
237+
bulk-operation payloads carry ``total_operations``/``results`` with no
238+
``success`` field. An expected-failure test whose tool regressed to one
239+
of those shapes passed silently -- and 30 of the 61 ``call_tool_failure``
240+
call sites omit ``expected_error``, so nothing downstream caught it
241+
either. This is the exact class of harness bug that degrades into a
242+
green run, which is why it is pinned here rather than in the e2e suite.
243+
"""
244+
245+
@pytest.mark.parametrize(
246+
("shape", "why"),
247+
[
248+
({"success": True}, "explicit success"),
249+
({"status": "pending_restart"}, "ha_manage_app write, no success key"),
250+
(
251+
{"total_operations": 2, "successful_commands": 2},
252+
"bulk payload, no success key",
253+
),
254+
({"results": [], "operation_ids": ["a"]}, "bulk payload via results"),
255+
({"data": {"x": 1}}, "data present, no error, no success key"),
256+
],
257+
)
258+
def test_success_shapes_are_not_accepted_as_failures(self, shape, why) -> None:
259+
with pytest.raises(AssertionError, match="should have failed but succeeded"):
260+
assert_mcp_failure(shape, why)
261+
262+
@pytest.mark.parametrize(
263+
"shape",
264+
[
265+
{"success": False, "error": {"code": "VALIDATION_FAILED"}},
266+
{"success": False, "error": "boom"},
267+
{"error": "boom"},
268+
],
269+
)
270+
def test_genuine_failures_still_pass(self, shape) -> None:
271+
assert assert_mcp_failure(shape, "op") == shape
272+
273+
def test_expected_error_still_matched_on_a_genuine_failure(self) -> None:
274+
shape = {"success": False, "error": {"message": "config_hash mismatch"}}
275+
assert_mcp_failure(shape, "op", "config_hash")
276+
with pytest.raises(AssertionError, match="doesn't contain"):
277+
assert_mcp_failure(shape, "op", "not_present")
278+
279+
def test_pending_restart_with_an_explicit_error_is_a_real_failure(self) -> None:
280+
"""The success predicate guards ``pending_restart`` on ``error is
281+
None``, so a write that reports both must stay a failure here."""
282+
shape = {"status": "pending_restart", "error": "supervisor rejected it"}
283+
assert assert_mcp_failure(shape, "op") == shape

0 commit comments

Comments
 (0)