Skip to content

Commit 2e4c9cf

Browse files
Python(feat): report assertion message in report as error info for pytest plugin (#587)
1 parent c18aecf commit 2e4c9cf

10 files changed

Lines changed: 150 additions & 37 deletions

File tree

python/docs/examples/pytest_plugin.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def sift_client() -> SiftClient:
8686
| Name | Kind | Scope | Purpose |
8787
|---|---|---|---|
8888
| `report_context` | fixture (autouse) | session | The `ReportContext` backing the run's `TestReport`. Use it to attach metadata or open ad-hoc steps. |
89-
| `step` | fixture (autouse) | function | A `NewStep` created for the current test function. Exposes `measure*`, `substep`, `report_outcome`, and `current_step`. |
89+
| `step` | fixture (autouse) | function | A `NewStep` created for the current test function. Exposes `measure*`, `substep`, `report_outcome`, `fail_if_measurements_failed`, and `current_step`. |
9090
| `_hierarchy_parents` | internal fixture (autouse) | function | Opens a parent step for each `pytest.Package`, `pytest.Module`, and `pytest.Class` ancestor of the current test. Each layer is gated independently — see [ini options](#ini-options). |
9191
| `_parametrize_parents` | internal fixture (autouse) | function | Opens a parent step for each `@pytest.mark.parametrize` axis (and fixture parametrization), nested inside the hierarchy parents. |
9292
| `client_has_connection` | fixture | session | Calls `sift_client.ping.ping()`; consulted by `report_context` at session start in online mode (the default). Override to skip the ping or use a different reachability signal. |
@@ -263,13 +263,15 @@ def test_no_fixtures_still_creates_a_step():
263263
def test_measure_a_single_value(step):
264264
"""Take `step` explicitly when you want to record a measurement."""
265265
voltage = 4.97
266-
passed = step.measure(
266+
step.measure(
267267
name="battery_voltage",
268268
value=voltage,
269269
bounds={"min": 4.8, "max": 5.2},
270270
unit="V",
271271
)
272-
assert passed, f"voltage {voltage}V out of bounds"
272+
# An out-of-bounds measurement already marks the step FAILED. Call this at
273+
# the end to also fail pytest, without an assertion message in error_info.
274+
step.fail_if_measurements_failed()
273275
274276
275277
def test_measure_strings_and_booleans(step):
@@ -612,8 +614,8 @@ def test_only_outliers_recorded(step):
612614
unit="psi",
613615
)
614616
# Returns False because 99.9 is out of bounds. The step is already
615-
# marked failed; raise here only if you also want pytest to fail.
616-
assert all_in_bounds
617+
# marked failed; call this only if you also want pytest to fail.
618+
step.fail_if_measurements_failed()
617619
```
618620

619621
!!! note "`measure_all` requires at least one bound"

python/docs/examples/pytest_plugin_quickstart.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ TestReport (FAILED, since failures propagate up from leaves)
136136
│ (test_excluded: @sift_exclude, runs in pytest, NOT in tree)
137137
├── test_measure_series PASSED
138138
├── test_failed_measurement_marks_sift_step_failed FAILED (pytest PASSED)
139-
├── test_assert_measurements_passed_at_end FAILED (pytest FAILED)
139+
├── test_fail_if_measurements_failed_at_end FAILED (pytest FAILED)
140140
├── test_report_level_metadata PASSED
141141
└── TestClassStep
142142
├── test_parametrize
@@ -158,12 +158,13 @@ The `with_sift` module shows two patterns for handling measurement results:
158158
`test_failed_measurement_marks_sift_step_failed` lets the test keep passing
159159
in pytest while the Sift step is `FAILED` (useful when measurements are
160160
diagnostic data you want to collect regardless of outcome); and
161-
`test_assert_measurements_passed_at_end` takes every measurement first and
162-
then asserts `step.measurements_passed` once at the end, so every
161+
`test_fail_if_measurements_failed_at_end` takes every measurement first and
162+
then calls `step.fail_if_measurements_failed()` once at the end, so every
163163
measurement still lands in the report even when one fails. The end-of-test
164-
assertion is the recommended pattern: asserting on an individual
165-
`step.measure(...)` call short-circuits on the first failure and skips
166-
every measurement that follows. Expected
164+
call is the recommended pattern: it fails via `pytest.fail` (no assertion
165+
noise in `error_info`), and unlike asserting on an individual
166+
`step.measure(...)` call it does not short-circuit on the first failure and
167+
skip every measurement that follows. Expected
167168
pytest output is `16 passed, 3 failed, 1 skipped`.
168169

169170
Flip any of the `sift_*_step` / `sift_parametrize_nesting` flags in

python/docs/guides/pytest_plugin/pass_fail_behavior.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ The statuses below come from `sift_client.sift_types.test_report.TestStatus`.
2626
| `pytest.fail("...")` from the body | `pytest.fail("intentional failure")` | `FAILED` |
2727
| Uncaught non-assertion exception | `raise ValueError("boom")` | `ERROR` |
2828

29-
A non-assertion exception gets its formatted traceback recorded on
30-
`step.error_info.error_message`.
29+
An assertion failure records the concise assertion message (the exception
30+
line(s), no traceback frames) on `step.error_info.error_message` while still
31+
mapping to `FAILED`. A non-assertion exception gets its formatted traceback
32+
recorded on `step.error_info.error_message`.
3133

3234
## Hard exits
3335

python/examples/pytest_plugin/README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ TestReport (FAILED, since failures propagate up from leaves)
7575
│ (test_excluded: @sift_exclude, runs in pytest, NOT in tree)
7676
├── test_measure_series PASSED
7777
├── test_failed_measurement_marks_sift_step_failed FAILED (pytest PASSED)
78-
├── test_assert_measurements_passed_at_end FAILED (pytest FAILED)
78+
├── test_fail_if_measurements_failed_at_end FAILED (pytest FAILED)
7979
├── test_report_level_metadata PASSED
8080
└── TestClassStep
8181
├── test_parametrize
@@ -97,12 +97,13 @@ The `with_sift` module shows two patterns for handling measurement results:
9797
`test_failed_measurement_marks_sift_step_failed` lets the test keep passing
9898
in pytest while the Sift step is `FAILED` (useful when measurements are
9999
diagnostic data you want to collect regardless of outcome); and
100-
`test_assert_measurements_passed_at_end` takes every measurement first and
101-
then asserts `step.measurements_passed` once at the end, so every
100+
`test_fail_if_measurements_failed_at_end` takes every measurement first and
101+
then calls `step.fail_if_measurements_failed()` once at the end, so every
102102
measurement still lands in the report even when one fails. The end-of-test
103-
assertion is the recommended pattern: asserting on an individual
104-
`step.measure(...)` call short-circuits on the first failure and skips
105-
every measurement that follows. Expected
103+
call is the recommended pattern: it fails via `pytest.fail` (no assertion
104+
noise in `error_info`), and unlike asserting on an individual
105+
`step.measure(...)` call it does not short-circuit on the first failure and
106+
skip every measurement that follows. Expected
106107
pytest output is `16 passed, 3 failed, 1 skipped`.
107108

108109
Toggle any of the `sift_*_step` / `sift_parametrize_nesting` flags in
@@ -115,5 +116,5 @@ Toggle any of the `sift_*_step` / `sift_parametrize_nesting` flags in
115116
| `conftest.py` | Plugin registration via `pytest_plugins`; optional `load_dotenv()` |
116117
| `pytest.ini` | The four nesting flags + git metadata flag at their defaults |
117118
| `tests/pytest_only/test_pytest_only_demo.py` | Plain pytest tests with no Sift APIs. The plugin captures pass/fail automatically; covers functions, fixtures, parametrize, classes, plus one each of `AssertionError` (FAILED), `pytest.skip` (SKIPPED), and a raised `ValueError` (ERROR) |
118-
| `tests/with_sift/test_with_sift_demo.py` | `step.measure` (numeric/string/bool bounds, units, description, metadata, `channel_names`), `step.measure_avg` and `step.measure_all` for series, an out-of-bounds measurement (pytest PASSED, Sift step FAILED), the recommended `assert step.measurements_passed` end-of-test pattern that fails pytest while still recording every measurement, nested `step.substep` (with step-level `metadata=...`), `@pytest.mark.sift_exclude`, class step + class docstring → description, nested classes, stacked `@pytest.mark.parametrize`, `step.report_outcome`, and session-level metadata via `report_context.report.update({...})` |
119+
| `tests/with_sift/test_with_sift_demo.py` | `step.measure` (numeric/string/bool bounds, units, description, metadata, `channel_names`), `step.measure_avg` and `step.measure_all` for series, an out-of-bounds measurement (pytest PASSED, Sift step FAILED), the recommended `step.fail_if_measurements_failed()` end-of-test call that fails pytest while still recording every measurement, nested `step.substep` (with step-level `metadata=...`), `@pytest.mark.sift_exclude`, class step + class docstring → description, nested classes, stacked `@pytest.mark.parametrize`, `step.report_outcome`, and session-level metadata via `report_context.report.update({...})` |
119120
| `tests/{pytest_only,with_sift}/__init__.py` | Each Python package (directory with `__init__.py`) becomes a parent step in the report tree |

python/examples/pytest_plugin/tests/with_sift/test_with_sift_demo.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,24 +94,25 @@ def test_failed_measurement_marks_sift_step_failed(step) -> None:
9494
)
9595

9696

97-
def test_assert_measurements_passed_at_end(step) -> None:
98-
"""Recommended pattern: take every measurement first, then assert
99-
``step.measurements_passed`` once at the end.
97+
def test_fail_if_measurements_failed_at_end(step) -> None:
98+
"""Recommended pattern: take every measurement first, then call
99+
``step.fail_if_measurements_failed()`` once at the end.
100100
101101
Asserting on individual ``step.measure(...)`` calls raises
102102
``AssertionError`` on the first failure, so any measurements after the
103103
failing one never run and never land in the Sift report. The end-of-test
104-
assertion is strictly better for diagnostic completeness: every
105-
measurement is recorded, including the failures, and the aggregate
106-
result is then folded into the pytest outcome.
104+
call is strictly better for diagnostic completeness: every measurement is
105+
recorded, including the failures, and the aggregate result is then folded
106+
into the pytest outcome. It fails via ``pytest.fail`` rather than an
107+
assertion, so the failed step carries no assertion noise in ``error_info``.
107108
108109
The ``b`` measurement below is deliberately out of bounds. ``c`` still
109-
runs and is recorded; only the final ``assert`` fires.
110+
runs and is recorded; only the final call fails the test.
110111
"""
111112
step.measure(name="a", value=1.0, bounds={"min": 0.0, "max": 2.0})
112113
step.measure(name="b", value=99.0, bounds={"min": 0.0, "max": 2.0}) # out of bounds
113114
step.measure(name="c", value=1.5, bounds={"min": 0.0, "max": 2.0}) # still recorded
114-
assert step.measurements_passed, "one or more measurements out of bounds"
115+
step.fail_if_measurements_failed()
115116

116117

117118
def test_report_level_metadata(step, report_context) -> None:

python/lib/sift_client/_tests/pytest_plugin/_step_status_capture.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class CapturedStep:
2727
step_path: str
2828
parent_step_id: str | None
2929
statuses: list[TestStatus] = field(default_factory=list)
30+
error_messages: list[str] = field(default_factory=list)
3031

3132

3233
_PROTO_STATUS_NAMES = {
@@ -58,19 +59,23 @@ def parse_log(log_path: Path) -> dict[str, CapturedStep]:
5859
for request_type, response_id, json_str in iter_log_data_lines(log_path):
5960
payload = json.loads(json_str)
6061
test_step = payload.get("testStep", {})
62+
error_message = test_step.get("errorInfo", {}).get("errorMessage")
6163
if request_type == "CreateTestStep" and response_id:
6264
steps[response_id] = CapturedStep(
6365
step_id=response_id,
6466
name=test_step.get("name", ""),
6567
step_path=test_step.get("stepPath", ""),
6668
parent_step_id=test_step.get("parentStepId") or None,
6769
statuses=[_status(test_step.get("status"))],
70+
error_messages=[error_message] if error_message else [],
6871
)
6972
elif request_type == "UpdateTestStep":
7073
step_id = test_step.get("testStepId")
7174
new_status = test_step.get("status")
7275
if step_id and step_id in steps and new_status is not None:
7376
steps[step_id].statuses.append(_status(new_status))
77+
if error_message:
78+
steps[step_id].error_messages.append(error_message)
7479
return steps
7580

7681

@@ -117,6 +122,11 @@ def final_status(name: str) -> TestStatus | None:
117122
return step.statuses[-1] if step and step.statuses else None
118123

119124

125+
def final_error_message(name: str) -> str | None:
126+
step = test_step(name)
127+
return step.error_messages[-1] if step and step.error_messages else None
128+
129+
120130
def load_steps(log_path: Path) -> list[dict]:
121131
"""Load the offline log as a list of step records keyed by hierarchy fields.
122132

python/lib/sift_client/_tests/pytest_plugin/test_pass_fail.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ def test_x():
9191
""",
9292
)
9393
assert capture.final_status("test_x") == TestStatus.FAILED
94+
# The concise assertion message is recorded on error_info for the UI, but
95+
# without the full traceback frames.
96+
message = capture.final_error_message("test_x")
97+
assert message is not None
98+
assert "assert 1 == 2" in message
99+
assert "Traceback (most recent call last)" not in message
94100

95101

96102
def test_generic_exception_maps_to_error(inner):
@@ -131,6 +137,34 @@ def test_x():
131137
assert capture.final_status("test_x") == TestStatus.FAILED
132138

133139

140+
def test_fail_if_measurements_failed_fails_without_error_info(inner):
141+
# An out-of-bounds measurement plus step.fail_if_measurements_failed()
142+
# fails the test via pytest.fail, so the step is FAILED with no assertion
143+
# message in error_info (the reason this helper exists over `assert`).
144+
_run(
145+
inner,
146+
"""
147+
def test_x(step):
148+
step.measure(name="b", value=99.0, bounds={"min": 0.0, "max": 2.0})
149+
step.fail_if_measurements_failed()
150+
""",
151+
)
152+
assert capture.final_status("test_x") == TestStatus.FAILED
153+
assert capture.final_error_message("test_x") is None
154+
155+
156+
def test_fail_if_measurements_failed_passes_when_in_bounds(inner):
157+
_run(
158+
inner,
159+
"""
160+
def test_x(step):
161+
step.measure(name="a", value=1.0, bounds={"min": 0.0, "max": 2.0})
162+
step.fail_if_measurements_failed()
163+
""",
164+
)
165+
assert capture.final_status("test_x") == TestStatus.PASSED
166+
167+
134168
def test_keyboard_interrupt_leaves_step_in_progress(inner):
135169
# Case: CALL-06
136170
# KeyboardInterrupt aborts the session before the call-phase makereport
@@ -174,6 +208,27 @@ def test_x(step):
174208
assert test_x.statuses[-1] == TestStatus.FAILED
175209

176210

211+
def test_substep_assert_failure_records_message_with_failed(inner):
212+
# Case: CALL-02 (substep). A substep inherits assertion_as_fail_not_error
213+
# from the autouse step (False under pytest), so a failed assertion in a
214+
# substep resolves to FAILED and records the concise assertion message.
215+
_run(
216+
inner,
217+
"""
218+
def test_x(step):
219+
with step.substep(name="inner"):
220+
assert 1 == 2
221+
""",
222+
)
223+
inner_sub = next(iter(capture.steps_by_name("inner")), None)
224+
assert inner_sub is not None
225+
assert inner_sub.statuses[-1] == TestStatus.FAILED
226+
assert inner_sub.error_messages
227+
message = inner_sub.error_messages[-1]
228+
assert "assert 1 == 2" in message
229+
assert "Traceback (most recent call last)" not in message
230+
231+
177232
# ---------------------------------------------------------------------------
178233
# Skip paths
179234
# ---------------------------------------------------------------------------

python/lib/sift_client/_tests/util/test_test_results_utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,7 +463,11 @@ def test_bad_assert(self, report_context, step):
463463
assert parent_step.status == TestStatus.FAILED
464464
assert substep.status == TestStatus.FAILED
465465
assert nested_substep.status == TestStatus.FAILED
466-
assert nested_substep.error_info is None
466+
# The assertion-as-fail path records the concise assertion message (no
467+
# traceback frames) on error_info while keeping the FAILED status.
468+
assert nested_substep.error_info is not None
469+
assert "AssertionError" in nested_substep.error_info.error_message
470+
assert "Traceback (most recent call last)" not in nested_substep.error_info.error_message
467471
assert nested_substep_2.status == TestStatus.ERROR
468472
assert "AssertionError" in nested_substep_2.error_info.error_message
469473
assert sibling_substep.status == TestStatus.PASSED

python/lib/sift_client/pytest_plugin.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
from sift_client.errors import SiftWarning
1515
from sift_client.sift_types.test_report import ErrorInfo, TestStatus
1616
from sift_client.util.test_results import ReportContext
17-
from sift_client.util.test_results.context_manager import format_truncated_traceback
17+
from sift_client.util.test_results.context_manager import (
18+
format_assertion_message,
19+
format_truncated_traceback,
20+
)
1821

1922

2023
class SiftPytestPluginWarning(SiftWarning):
@@ -588,6 +591,7 @@ def _resolve_initial_status(new_step: NewStep, item: pytest.Item) -> None:
588591
status = TestStatus.FAILED
589592
elif isinstance(excinfo.value, AssertionError):
590593
status = TestStatus.FAILED
594+
error_info = format_assertion_message(excinfo.type, excinfo.value)
591595
elif isinstance(excinfo.value, pytest.fail.Exception):
592596
status = TestStatus.FAILED
593597
elif isinstance(excinfo.value, (KeyboardInterrupt, SystemExit)):

0 commit comments

Comments
 (0)