Skip to content

Commit a5251b5

Browse files
authored
python(feat): Handle skipped steps in test results helpers. (#394)
1 parent 0de5a88 commit a5251b5

4 files changed

Lines changed: 85 additions & 35 deletions

File tree

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

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919

2020

2121
class TestContextManager:
22+
def test_link_run_to_report(self, report_context, nostromo_run):
23+
report_context.report.update({"run_id": nostromo_run.id_})
24+
assert report_context.report.run_id == nostromo_run.id_
25+
2226
def test_new_step(self, report_context):
2327
initial_end_time = report_context.report.end_time
2428
first_step_path = report_context.get_next_step_path()
@@ -124,41 +128,62 @@ def test_measurement_update(self, report_context):
124128
assert test_step.measurements[2].measurement_type == TestMeasurementType.BOOLEAN
125129

126130
def test_bad_assert(self, report_context, step):
127-
test_step = None
128-
parent_step_path = step.current_step.step_path
129-
initial_open_step_result = report_context.open_step_results.get(parent_step_path, True)
131+
# Capture current state of report context's failures so we can keep things passed at a high level if the test's induced failures happen as expected.
132+
current_step_path = step.current_step.step_path
133+
initial_open_step_result = report_context.open_step_results.get(current_step_path, True)
130134
initial_any_failures = report_context.any_failures
131135

132-
with step.substep("Test Bad Assert", "Test Bad Assert Description") as new_step:
133-
test_step = new_step.current_step
134-
assert False == True
135-
136-
assert test_step.status == TestStatus.ERROR
137-
assert test_step.error_info is not None
138-
assert "AssertionError" in test_step.error_info.error_message
136+
parent_step = None
137+
substep = None
138+
nested_substep = None
139+
sibling_substep = None
140+
with step.substep("Top Level Step", "Should fail") as parent_step_context:
141+
parent_step = parent_step_context.current_step
142+
with parent_step_context.substep("Parent Step", "Should fail") as substep_context:
143+
substep = substep_context.current_step
144+
with substep_context.substep(
145+
"Nested Substep", "Has a bad assert"
146+
) as nested_substep_context:
147+
nested_substep = nested_substep_context.current_step
148+
assert False == True
149+
with substep_context.substep(
150+
"Sibling Substep", "Should pass"
151+
) as sibling_substep_context:
152+
sibling_substep = sibling_substep_context.current_step
153+
154+
assert parent_step.status == TestStatus.FAILED
155+
assert substep.status == TestStatus.FAILED
156+
assert nested_substep.status == TestStatus.ERROR
157+
assert "AssertionError" in nested_substep.error_info.error_message
158+
assert sibling_substep.status == TestStatus.PASSED
159+
160+
# If this test was successful, mark that at a high level.
139161
if initial_open_step_result:
140-
report_context.open_step_results[parent_step_path] = True
162+
report_context.open_step_results[current_step_path] = True
141163
if not initial_any_failures:
142164
report_context.any_failures = False
143165

144-
def test_error_info(self, report_context, step):
166+
def test_manually_skip_step(self, step):
145167
test_step = None
146-
parent_step_path = step.current_step.step_path
147-
initial_open_step_result = report_context.open_step_results.get(parent_step_path, True)
148-
initial_any_failures = report_context.any_failures
168+
substep = None
169+
sibling_substep = None
170+
with step.substep("Parent Step", "Should pass") as parent_step_context:
171+
test_step = parent_step_context.current_step
172+
with parent_step_context.substep("Substep", "Should skip") as substep_context:
173+
substep = substep_context.current_step
174+
substep.update({"status": TestStatus.SKIPPED})
175+
with substep_context.substep(
176+
"Sibling Substep", "Should pass"
177+
) as sibling_substep_context:
178+
sibling_substep = sibling_substep_context.current_step
149179

150-
with report_context.new_step("Test Error", "Test Error Description") as new_step:
151-
test_step = new_step.current_step
152-
raise Exception("Test Error")
153-
assert test_step.error_info is not None
154-
assert test_step.error_info.error_code == 1
155-
assert "Test Error" in test_step.error_info.error_message
156-
assert test_step.status == TestStatus.ERROR
157-
# If the parent step is not marked as failed already, make sure it remains passed at this point.
158-
if initial_open_step_result:
159-
report_context.open_step_results[parent_step_path] = True
160-
if not initial_any_failures:
161-
report_context.any_failures = False
180+
assert test_step.status == TestStatus.PASSED
181+
assert substep.status == TestStatus.SKIPPED
182+
assert sibling_substep.status == TestStatus.PASSED
183+
184+
@pytest.mark.skip(reason="Test Skip Step")
185+
def test_pytest_skip(self):
186+
pass
162187

163188

164189
class TestBounds:

python/lib/sift_client/util/test_results/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@
2525
return result # This is optional for other uses, but the step and its parents will be updated correctly i.e. failed if the measurement fails.
2626
```
2727
28+
#### Manually Updating Underlyling Report
29+
You can also manually update the underlying report or steps by accessing the context manager's attributes.
30+
```python
31+
with ReportContext(client, name="Example Report") as rc:
32+
with rc.new_step(name="Example Step") as step:
33+
if !conditions:
34+
step.update({"status": TestStatus.SKIPPED})
35+
else:
36+
step.measure(name="Example Measurement", value=test_value, bounds={"min": -1, "max": 10})
37+
rc.report.update({"run_id": run_id})
38+
```
39+
2840
For a larger class or script, consider creating the context in a setup method and passing it to the test functions.
2941
```python
3042
def main(self):

python/lib/sift_client/util/test_results/context_manager.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,6 @@ def report_measurement(self, measurement: TestMeasurement, step: TestStep):
148148
def resolve_and_propagate_step_result(
149149
self,
150150
step: TestStep,
151-
parent_step: TestStep | None = None,
152151
error_info: ErrorInfo | None = None,
153152
) -> bool:
154153
"""Resolve the result of a step and propagate the result to the parent step if it failed."""
@@ -157,13 +156,16 @@ def resolve_and_propagate_step_result(
157156
result = False
158157
if step.status != TestStatus.IN_PROGRESS:
159158
# The step was manually completed so use that result.
160-
result = step.status == TestStatus.PASSED
159+
# Skipped steps are considered passed.
160+
result = step.status in (TestStatus.PASSED, TestStatus.SKIPPED)
161161

162162
# Update the parent step results if this step failed (true by default so no need to do anything if we didn't fail).
163163
if not result:
164164
self.any_failures = True
165-
if parent_step:
166-
self.open_step_results[parent_step.step_path] = False
165+
path_parts = step.step_path.split(".")
166+
if len(path_parts) > 1:
167+
parent_step_path = ".".join(path_parts[:-1])
168+
self.open_step_results[parent_step_path] = False
167169

168170
return result
169171

@@ -185,7 +187,6 @@ class NewStep(AbstractContextManager):
185187
report_context: ReportContext
186188
client: SiftClient
187189
current_step: TestStep | None = None
188-
parent_step: TestStep | None = None
189190

190191
def __init__(
191192
self,
@@ -237,12 +238,13 @@ def update_step_from_result(
237238

238239
# Resolve the status of this step (i.e. fail if children failed) and propagate the result to the parent step.
239240
result = self.report_context.resolve_and_propagate_step_result(
240-
self.current_step, self.parent_step, error_info
241+
self.current_step, error_info
241242
)
242243

243244
# Mark the step as completed
244245
status = self.current_step.status
245246
if status == TestStatus.IN_PROGRESS:
247+
# Update the status only if the step was in progress i.e. not updated elsewhere.
246248
status = TestStatus.PASSED if result else TestStatus.FAILED
247249
if error_info:
248250
status = TestStatus.ERROR

python/lib/sift_client/util/test_results/pytest_util.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,27 @@
66

77
import pytest
88

9+
from sift_client.sift_types.test_report import TestStatus
910
from sift_client.util.test_results import ReportContext
1011

1112
if TYPE_CHECKING:
1213
from sift_client.client import SiftClient
1314
from sift_client.util.test_results.context_manager import NewStep
1415

16+
REPORT_CONTEXT: ReportContext | None = None
17+
1518

1619
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
1720
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]):
1821
"""You should import this hook to capture any AssertionErrors that occur during the test. If not included, any assert failures in a test will not automatically fail the step."""
1922
outcome = yield
20-
rep = outcome.get_result()
21-
setattr(item, "rep_" + rep.when, call)
23+
report = outcome.get_result()
24+
if report.outcome == "skipped":
25+
# Skipped steps won't invoke the method/fixtures at all, so we need to manually record a step.
26+
if REPORT_CONTEXT:
27+
with REPORT_CONTEXT.new_step(name=item.name) as new_step:
28+
new_step.current_step.update({"status": TestStatus.SKIPPED})
29+
setattr(item, "rep_" + report.when, call)
2230

2331

2432
def _report_context_impl(
@@ -36,6 +44,9 @@ def _report_context_impl(
3644
name=f"{base_name} {datetime.now(timezone.utc).isoformat()}",
3745
test_case=str(test_case),
3846
) as context:
47+
# Set a global so we can access this in pytest hooks.
48+
global REPORT_CONTEXT
49+
REPORT_CONTEXT = context
3950
yield context
4051

4152

0 commit comments

Comments
 (0)