Skip to content

Commit 357c274

Browse files
committed
initial commit
1 parent 848d828 commit 357c274

4 files changed

Lines changed: 501 additions & 12 deletions

File tree

python/docs/examples/pytest_plugin.md

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -342,8 +342,11 @@ TestReport
342342

343343
### Parametrized tests
344344

345-
Each parametrize case is a distinct pytest node, so each gets its own step.
346-
The step name includes the parameter id pytest generates.
345+
Parametrize axes become nested step layers. The plugin reads each item's
346+
`callspec.params` at collection time and opens one shared parent step per axis,
347+
so siblings that share an outer axis value live under the same parent. Leaf
348+
step names are `axis=value` (with `repr()`, so strings stay quoted), not
349+
pytest's bracket-mangled ID.
347350

348351
```python
349352
@pytest.mark.parametrize("voltage", [3.3, 5.0, 12.0])
@@ -353,9 +356,49 @@ def test_rail(step, voltage):
353356

354357
```text title="Sift report"
355358
TestReport
356-
├── test_rail[3.3]
357-
├── test_rail[5.0]
358-
└── test_rail[12.0]
359+
└── test_rail
360+
├── voltage=3.3
361+
├── voltage=5.0
362+
└── voltage=12.0
363+
```
364+
365+
Stacked `parametrize` decorators nest in the order they appear in source — the
366+
top decorator is the outermost step:
367+
368+
```python
369+
@pytest.mark.parametrize("voltage", ["high", "low"])
370+
@pytest.mark.parametrize("component", ["motor", "ducer", "valve"])
371+
def test_iso(step, voltage, component): ...
372+
```
373+
374+
```text title="Sift report"
375+
TestReport
376+
└── test_iso
377+
├── voltage='high'
378+
│ ├── component='motor'
379+
│ ├── component='ducer'
380+
│ └── component='valve'
381+
└── voltage='low'
382+
├── component='motor'
383+
├── component='ducer'
384+
└── component='valve'
385+
```
386+
387+
Fixture-level parametrization participates too:
388+
389+
```python
390+
@pytest.fixture(params=["a", "b"])
391+
def widget(request):
392+
return request.param
393+
394+
def test_widget(step, widget): ...
395+
```
396+
397+
```text title="Sift report"
398+
TestReport
399+
└── test_widget
400+
├── widget='a'
401+
└── widget='b'
359402
```
360403

361404
### Helper functions
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Test doubles for the pytester-driven pytest-plugin tests.
2+
3+
The fake ``ReportContext`` is a drop-in for the real one that records every
4+
step creation to a JSON file at session exit. Used by ``test_pytest_plugin_parametrize.py``
5+
to assert the step tree produced by an inner pytester pytest run.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
from typing import TYPE_CHECKING, Any
12+
from unittest.mock import MagicMock
13+
14+
if TYPE_CHECKING:
15+
from pathlib import Path
16+
17+
18+
class FakeStep:
19+
def __init__(self, id_: str, name: str, parent_step_id: str | None, step_path: str) -> None:
20+
self.id_ = id_
21+
self.name = name
22+
self.parent_step_id = parent_step_id
23+
self.step_path = step_path
24+
self.status: Any = None
25+
self.description: Any = None
26+
self.error_info: Any = None
27+
28+
def update(self, fields: dict[str, Any]) -> None:
29+
for k, v in fields.items():
30+
setattr(self, k, v)
31+
32+
33+
class FakeReport:
34+
def __init__(self) -> None:
35+
self.id_ = "report-id"
36+
37+
def update(self, fields: dict[str, Any]) -> None:
38+
pass
39+
40+
41+
class FakeReportContext:
42+
_counter = 0
43+
44+
def __init__(self, steps_file: Path) -> None:
45+
self.steps_file = steps_file
46+
self.report = FakeReport()
47+
self.client = MagicMock()
48+
self.step_stack: list[FakeStep] = []
49+
self.step_number_at_depth: dict[int, int] = {}
50+
self.open_step_results: dict[str, bool] = {}
51+
self.any_failures = False
52+
self.log_file: Path | None = None
53+
self.steps: list[dict[str, Any]] = []
54+
55+
def __enter__(self) -> FakeReportContext:
56+
return self
57+
58+
def __exit__(self, *_: Any) -> None:
59+
self.steps_file.write_text(json.dumps(self.steps))
60+
61+
def new_step(
62+
self,
63+
name: str,
64+
description: str | None = None,
65+
assertion_as_fail_not_error: bool = True,
66+
metadata: dict[str, Any] | None = None,
67+
) -> Any:
68+
# Reuse the real NewStep machinery — it talks to this fake via the
69+
# methods below.
70+
from sift_client.util.test_results.context_manager import NewStep
71+
72+
return NewStep(
73+
self, # type: ignore[arg-type]
74+
name=name,
75+
description=description,
76+
assertion_as_fail_not_error=assertion_as_fail_not_error,
77+
metadata=metadata,
78+
)
79+
80+
def get_next_step_path(self) -> str:
81+
top = self.step_stack[-1] if self.step_stack else None
82+
path = top.step_path if top else ""
83+
next_n = self.step_number_at_depth.get(len(self.step_stack), 0) + 1
84+
prefix = f"{path}." if path else ""
85+
return f"{prefix}{next_n}"
86+
87+
def create_step(
88+
self,
89+
name: str,
90+
description: str | None = None,
91+
metadata: dict[str, Any] | None = None,
92+
) -> FakeStep:
93+
type(self)._counter += 1
94+
step_path = self.get_next_step_path()
95+
parent = self.step_stack[-1] if self.step_stack else None
96+
step = FakeStep(
97+
id_=f"step-{type(self)._counter}",
98+
name=name,
99+
parent_step_id=parent.id_ if parent else None,
100+
step_path=step_path,
101+
)
102+
self.step_number_at_depth[len(self.step_stack)] = (
103+
self.step_number_at_depth.get(len(self.step_stack), 0) + 1
104+
)
105+
self.step_stack.append(step)
106+
self.open_step_results[step.step_path] = True
107+
self.steps.append(
108+
{
109+
"id": step.id_,
110+
"name": name,
111+
"parent_step_id": step.parent_step_id,
112+
"step_path": step_path,
113+
}
114+
)
115+
return step
116+
117+
def record_step_outcome(self, outcome: bool, step: FakeStep) -> None:
118+
if not outcome:
119+
self.open_step_results[step.step_path] = False
120+
self.any_failures = True
121+
122+
def resolve_and_propagate_step_result(self, step: FakeStep, error_info: Any = None) -> bool:
123+
result = self.open_step_results.get(step.step_path, True)
124+
if error_info:
125+
result = False
126+
return result
127+
128+
def exit_step(self, step: FakeStep) -> None:
129+
self.step_number_at_depth[len(self.step_stack)] = 0
130+
stack_top = self.step_stack.pop()
131+
self.open_step_results.pop(step.step_path)
132+
if stack_top.id_ != step.id_:
133+
raise ValueError("popped step was not the top of the stack")

0 commit comments

Comments
 (0)