pytest-xspec 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_xspec/__init__.py +0 -0
- pytest_xspec/collector.py +23 -0
- pytest_xspec/config.py +60 -0
- pytest_xspec/outcomes.py +38 -0
- pytest_xspec/plugin.py +94 -0
- pytest_xspec/reporting.py +103 -0
- pytest_xspec-0.5.0.dist-info/METADATA +205 -0
- pytest_xspec-0.5.0.dist-info/RECORD +11 -0
- pytest_xspec-0.5.0.dist-info/WHEEL +4 -0
- pytest_xspec-0.5.0.dist-info/entry_points.txt +2 -0
- pytest_xspec-0.5.0.dist-info/licenses/LICENSE +21 -0
pytest_xspec/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Validate requirement associations before pytest filters collected tests."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Collection
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def requirement_ids(item: pytest.Item, inventory: Collection[str]) -> tuple[str, ...]:
|
|
9
|
+
identifiers: dict[str, None] = {}
|
|
10
|
+
|
|
11
|
+
for marker in item.iter_markers("xspec"):
|
|
12
|
+
if len(marker.args) != 1 or marker.kwargs or not isinstance(marker.args[0], str):
|
|
13
|
+
raise pytest.UsageError(
|
|
14
|
+
f"{item.nodeid}: xspec requires exactly one positional string ID and no keywords"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
identifier = marker.args[0]
|
|
18
|
+
if identifier not in inventory:
|
|
19
|
+
raise pytest.UsageError(f"{item.nodeid}: unknown xspec requirement ID {identifier!r}")
|
|
20
|
+
|
|
21
|
+
identifiers[identifier] = None
|
|
22
|
+
|
|
23
|
+
return tuple(identifiers)
|
pytest_xspec/config.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Read requirement inventories from the two supported project files."""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import sys
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_requirements(config: pytest.Config) -> list[str]:
|
|
11
|
+
inventories: dict[str, object] = {}
|
|
12
|
+
pyproject = config.rootpath / "pyproject.toml"
|
|
13
|
+
ini = config.rootpath / "pytest.ini"
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
if pyproject.is_file():
|
|
17
|
+
# pytest supplies tomli on Python 3.10; newer Python supplies tomllib.
|
|
18
|
+
toml = import_module("tomllib" if sys.version_info >= (3, 11) else "tomli")
|
|
19
|
+
with pyproject.open("rb") as stream:
|
|
20
|
+
section = toml.load(stream).get("tool", {}).get("xspec", {})
|
|
21
|
+
|
|
22
|
+
if not isinstance(section, dict):
|
|
23
|
+
raise pytest.UsageError("pyproject.toml: xspec must be a table")
|
|
24
|
+
|
|
25
|
+
if "requirements" in section:
|
|
26
|
+
inventories[pyproject.name] = section["requirements"]
|
|
27
|
+
|
|
28
|
+
if ini.is_file():
|
|
29
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
30
|
+
parser.read(ini, encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
if parser.has_option("pytest", "xspec"):
|
|
33
|
+
inventories[ini.name] = [
|
|
34
|
+
line.strip()
|
|
35
|
+
for line in parser.get("pytest", "xspec").splitlines()
|
|
36
|
+
if line.strip()
|
|
37
|
+
]
|
|
38
|
+
except (OSError, ValueError, configparser.Error) as error:
|
|
39
|
+
raise pytest.UsageError(f"Cannot read xspec requirements: {error}") from error
|
|
40
|
+
|
|
41
|
+
if len(inventories) > 1:
|
|
42
|
+
raise pytest.UsageError("Declare requirements in only one of pyproject.toml and pytest.ini")
|
|
43
|
+
|
|
44
|
+
if not inventories:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
filename, requirements = next(iter(inventories.items()))
|
|
48
|
+
if not isinstance(requirements, list) or any(
|
|
49
|
+
not isinstance(requirement, str) for requirement in requirements
|
|
50
|
+
):
|
|
51
|
+
raise pytest.UsageError(f"{filename}: requirements must be a list of string IDs")
|
|
52
|
+
|
|
53
|
+
seen: set[str] = set()
|
|
54
|
+
for requirement in requirements:
|
|
55
|
+
if requirement in seen:
|
|
56
|
+
raise pytest.UsageError(f"{filename}: duplicate requirements ID {requirement!r}")
|
|
57
|
+
|
|
58
|
+
seen.add(requirement)
|
|
59
|
+
|
|
60
|
+
return requirements
|
pytest_xspec/outcomes.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Translate execution phases into requirement verification outcomes."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
PRECEDENCE = {
|
|
6
|
+
status: rank
|
|
7
|
+
for rank, status in enumerate(
|
|
8
|
+
("PASSED", "DESELECTED", "SKIPPED", "XFAILED", "XPASSED", "FAILED", "ERROR")
|
|
9
|
+
)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def phase_outcome(
|
|
14
|
+
when: str | None, outcome: str, *, wasxfail: bool = False, longrepr: object = None
|
|
15
|
+
) -> str | None:
|
|
16
|
+
if wasxfail and outcome == "skipped":
|
|
17
|
+
return "XFAILED"
|
|
18
|
+
|
|
19
|
+
if outcome == "failed":
|
|
20
|
+
if when != "call":
|
|
21
|
+
return "ERROR"
|
|
22
|
+
|
|
23
|
+
if isinstance(longrepr, str) and longrepr.startswith("[XPASS(strict)] "):
|
|
24
|
+
return "XPASSED"
|
|
25
|
+
|
|
26
|
+
return "FAILED"
|
|
27
|
+
|
|
28
|
+
if outcome == "skipped":
|
|
29
|
+
return "SKIPPED"
|
|
30
|
+
|
|
31
|
+
if when == "call":
|
|
32
|
+
return "XPASSED" if wasxfail else "PASSED"
|
|
33
|
+
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def aggregate(outcomes: Iterable[str]) -> str:
|
|
38
|
+
return max(outcomes, key=PRECEDENCE.__getitem__, default="MISSING")
|
pytest_xspec/plugin.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Pytest entry point for executable requirement verification."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator, Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from _pytest.terminal import TerminalReporter
|
|
8
|
+
|
|
9
|
+
from pytest_xspec.collector import requirement_ids
|
|
10
|
+
from pytest_xspec.config import load_requirements
|
|
11
|
+
from pytest_xspec.outcomes import aggregate, phase_outcome
|
|
12
|
+
from pytest_xspec.reporting import build_report, write_json, write_terminal
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
16
|
+
group = parser.getgroup("xspec")
|
|
17
|
+
group.addoption("--xspec-report", action="store_true", help="Print requirement verification")
|
|
18
|
+
group.addoption("--xspec-strict", action="store_true", help="Fail on unverified requirements")
|
|
19
|
+
group.addoption("--xspec-json", metavar="PATH", help="Write requirement verification as JSON")
|
|
20
|
+
|
|
21
|
+
parser.addini("xspec", "Requirement IDs, one per line (pytest.ini only)", "linelist")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
25
|
+
config.addinivalue_line("markers", "xspec(id): associate a test with a declared requirement")
|
|
26
|
+
config.pluginmanager.register(RequirementPlugin(config), "xspec-results")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RequirementPlugin:
|
|
30
|
+
def __init__(self, config: pytest.Config) -> None:
|
|
31
|
+
self.config = config
|
|
32
|
+
self.requirements: dict[str, list[str]] = {
|
|
33
|
+
identifier: [] for identifier in load_requirements(config)
|
|
34
|
+
}
|
|
35
|
+
self.outcomes: dict[str, str | None] = {}
|
|
36
|
+
self.collection_errors = 0
|
|
37
|
+
|
|
38
|
+
@pytest.hookimpl(wrapper=True, tryfirst=True)
|
|
39
|
+
def pytest_collection_modifyitems(self, items: list[pytest.Item]) -> Generator[None]:
|
|
40
|
+
for item in items:
|
|
41
|
+
identifiers = requirement_ids(item, self.requirements)
|
|
42
|
+
|
|
43
|
+
if identifiers and item.nodeid not in self.outcomes:
|
|
44
|
+
self.outcomes[item.nodeid] = None
|
|
45
|
+
|
|
46
|
+
for identifier in identifiers:
|
|
47
|
+
self.requirements[identifier].append(item.nodeid)
|
|
48
|
+
|
|
49
|
+
yield
|
|
50
|
+
|
|
51
|
+
def pytest_deselected(self, items: Sequence[pytest.Item]) -> None:
|
|
52
|
+
for item in items:
|
|
53
|
+
if item.nodeid in self.outcomes:
|
|
54
|
+
self.outcomes[item.nodeid] = "DESELECTED"
|
|
55
|
+
|
|
56
|
+
def pytest_collectreport(self, report: pytest.CollectReport) -> None:
|
|
57
|
+
if report.failed:
|
|
58
|
+
self.collection_errors += 1
|
|
59
|
+
|
|
60
|
+
def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
|
|
61
|
+
if report.nodeid in self.outcomes:
|
|
62
|
+
outcome = phase_outcome(
|
|
63
|
+
report.when,
|
|
64
|
+
report.outcome,
|
|
65
|
+
wasxfail=hasattr(report, "wasxfail"),
|
|
66
|
+
longrepr=report.longrepr,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if outcome is not None:
|
|
70
|
+
previous = self.outcomes[report.nodeid]
|
|
71
|
+
self.outcomes[report.nodeid] = aggregate(
|
|
72
|
+
[previous, outcome] if previous is not None else [outcome]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def pytest_sessionfinish(self, session: pytest.Session) -> None:
|
|
76
|
+
report = build_report(self.requirements, self.outcomes, self.collection_errors)
|
|
77
|
+
|
|
78
|
+
path = self.config.getoption("xspec_json")
|
|
79
|
+
if path:
|
|
80
|
+
write_json(report, Path(path))
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
self.config.getoption("xspec_strict")
|
|
84
|
+
and not report["summary"]["verified"]
|
|
85
|
+
and session.exitstatus in (pytest.ExitCode.OK, pytest.ExitCode.NO_TESTS_COLLECTED)
|
|
86
|
+
):
|
|
87
|
+
session.exitstatus = pytest.ExitCode.TESTS_FAILED
|
|
88
|
+
|
|
89
|
+
def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None:
|
|
90
|
+
if self.config.getoption("xspec_report") or self.config.getoption("xspec_strict"):
|
|
91
|
+
write_terminal(
|
|
92
|
+
build_report(self.requirements, self.outcomes, self.collection_errors),
|
|
93
|
+
terminalreporter,
|
|
94
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Build and render the public requirement report."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TypedDict
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from _pytest.terminal import TerminalReporter
|
|
10
|
+
|
|
11
|
+
from pytest_xspec.outcomes import aggregate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestResult(TypedDict):
|
|
15
|
+
nodeid: str
|
|
16
|
+
outcome: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RequirementResult(TypedDict):
|
|
20
|
+
status: str
|
|
21
|
+
tests: list[TestResult]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Summary(TypedDict):
|
|
25
|
+
requirements: int
|
|
26
|
+
passed: int
|
|
27
|
+
unverified: int
|
|
28
|
+
coverage: float
|
|
29
|
+
verified: bool
|
|
30
|
+
collection_errors: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Report(TypedDict):
|
|
34
|
+
requirements: dict[str, RequirementResult]
|
|
35
|
+
summary: Summary
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_report(
|
|
39
|
+
requirements: Mapping[str, Sequence[str]],
|
|
40
|
+
outcomes: Mapping[str, str | None],
|
|
41
|
+
collection_errors: int,
|
|
42
|
+
) -> Report:
|
|
43
|
+
results: dict[str, RequirementResult] = {}
|
|
44
|
+
|
|
45
|
+
for identifier, nodeids in requirements.items():
|
|
46
|
+
tests: list[TestResult] = [
|
|
47
|
+
{"nodeid": nodeid, "outcome": outcomes[nodeid] or "SKIPPED"} for nodeid in nodeids
|
|
48
|
+
]
|
|
49
|
+
results[identifier] = {
|
|
50
|
+
"status": aggregate(test["outcome"] for test in tests),
|
|
51
|
+
"tests": tests,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
count = len(results)
|
|
55
|
+
passed = sum(result["status"] == "PASSED" for result in results.values())
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"requirements": results,
|
|
59
|
+
"summary": {
|
|
60
|
+
"requirements": count,
|
|
61
|
+
"passed": passed,
|
|
62
|
+
"unverified": count - passed,
|
|
63
|
+
"coverage": passed / count * 100 if count else 0.0,
|
|
64
|
+
"verified": passed == count and collection_errors == 0,
|
|
65
|
+
"collection_errors": collection_errors,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_json(report: Report, path: Path) -> None:
|
|
71
|
+
try:
|
|
72
|
+
path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
73
|
+
except OSError as error:
|
|
74
|
+
raise pytest.UsageError(f"Cannot write xspec JSON report {path}: {error}") from error
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def write_terminal(report: Report, terminal: TerminalReporter) -> None:
|
|
78
|
+
red = not report["summary"]["verified"]
|
|
79
|
+
terminal.write_sep("=", "xspec", red=red, green=not red)
|
|
80
|
+
|
|
81
|
+
width = max([len("Requirement"), *(len(identifier) for identifier in report["requirements"])])
|
|
82
|
+
terminal.write_line(f"{'Requirement':<{width}} {'Status':<10} Tests", red=red, green=not red)
|
|
83
|
+
|
|
84
|
+
for identifier, result in report["requirements"].items():
|
|
85
|
+
terminal.write_line(
|
|
86
|
+
f"{identifier:<{width}} {result['status']:<10} {len(result['tests'])}",
|
|
87
|
+
red=red,
|
|
88
|
+
green=not red,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
for label, field in (
|
|
92
|
+
("Requirements", "requirements"),
|
|
93
|
+
("Passed", "passed"),
|
|
94
|
+
("Unverified", "unverified"),
|
|
95
|
+
):
|
|
96
|
+
terminal.write_line(f"{label}: {report['summary'][field]}", red=red, green=not red)
|
|
97
|
+
|
|
98
|
+
terminal.write_line(f"Coverage: {report['summary']['coverage']:.1f}%", red=red, green=not red)
|
|
99
|
+
|
|
100
|
+
if report["summary"]["collection_errors"]:
|
|
101
|
+
terminal.write_line(
|
|
102
|
+
f"Collection errors: {report['summary']['collection_errors']}", red=red, green=not red
|
|
103
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-xspec
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Executable acceptance criteria for pytest
|
|
5
|
+
Project-URL: Repository, https://github.com/Thyholt/pytest-xspec
|
|
6
|
+
Project-URL: Issues, https://github.com/Thyholt/pytest-xspec/issues
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: pytest>=8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# pytest-xspec
|
|
14
|
+
|
|
15
|
+
[![PyPI version][pypi-version-badge]][pypi]
|
|
16
|
+
[![Python versions][python-versions-badge]][pypi]
|
|
17
|
+
[![Tests][tests-badge]][tests]
|
|
18
|
+
[![Code coverage][codecov-badge]][codecov]
|
|
19
|
+
|
|
20
|
+
**Executable specifications with pytest.**
|
|
21
|
+
|
|
22
|
+
Link specifications to your existing pytest tests, organize your test suite your way, and see
|
|
23
|
+
which specifications your test suite verifies. Detect missing tests and enforce verification in CI.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
Install `pytest-xspec` into your pytest environment. The plugin is discovered
|
|
28
|
+
automatically. Python 3.10+ and pytest 8+ are supported.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install pytest-xspec
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Declare specifications
|
|
35
|
+
|
|
36
|
+
Declare specification IDs under `requirements` in `pyproject.toml`.
|
|
37
|
+
|
|
38
|
+
```toml
|
|
39
|
+
[tool.xspec]
|
|
40
|
+
requirements = ["REQ-001", "REQ-002"]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Alternatively, use one ID per line in `pytest.ini`.
|
|
44
|
+
|
|
45
|
+
```ini
|
|
46
|
+
[pytest]
|
|
47
|
+
xspec =
|
|
48
|
+
REQ-001
|
|
49
|
+
REQ-002
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Declare specifications in one of these files. Using both is an error, and other
|
|
53
|
+
files do not supply specifications. IDs are opaque strings. Duplicates and marked
|
|
54
|
+
IDs that have not been declared are rejected. No inventory, or an empty inventory,
|
|
55
|
+
means zero specifications.
|
|
56
|
+
|
|
57
|
+
### Mark tests
|
|
58
|
+
|
|
59
|
+
Associate tests with specifications, using one marker per specification.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import pytest
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.xspec("REQ-001")
|
|
66
|
+
@pytest.mark.xspec("REQ-002")
|
|
67
|
+
def test_shared_behavior():
|
|
68
|
+
assert 1 + 1 == 2
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Run pytest
|
|
72
|
+
|
|
73
|
+
Plain `pytest` checks specification configuration and markers without printing
|
|
74
|
+
a specification report. Add `--xspec-report` to see the report, or `--xspec-strict`
|
|
75
|
+
to also fail the run when a specification is unverified.
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
pytest --xspec-report # Print the verification report
|
|
79
|
+
pytest --xspec-json=report.json # Export statuses and individual test outcomes
|
|
80
|
+
pytest --xspec-strict # Report and fail on unverified specifications
|
|
81
|
+
pytest -m xspec --xspec-strict # Run only marked specification tests
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The report is green when all specifications are verified and red when verification fails.
|
|
85
|
+
The first report shows the example above; the second shows a specification without tests.
|
|
86
|
+
|
|
87
|
+

|
|
88
|
+
|
|
89
|
+
### Read specification statuses
|
|
90
|
+
|
|
91
|
+
Reports label specifications as requirements.
|
|
92
|
+
|
|
93
|
+
A specification is verified when every test marked with its ID passes. One passing
|
|
94
|
+
test is not enough if another test for the same specification fails or skips.
|
|
95
|
+
|
|
96
|
+
When those tests have different results, the report uses the first matching
|
|
97
|
+
status in this table, reading from top to bottom.
|
|
98
|
+
|
|
99
|
+
| Status | Meaning |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| `ERROR` | A test's setup or cleanup failed. |
|
|
102
|
+
| `FAILED` | A test failed. |
|
|
103
|
+
| `XPASSED` | A test marked as an expected failure passed instead. |
|
|
104
|
+
| `XFAILED` | A test failed as expected. |
|
|
105
|
+
| `SKIPPED` | A test was skipped or never ran. |
|
|
106
|
+
| `DESELECTED` | A test was excluded by a filter such as `-k` or `-m`. |
|
|
107
|
+
| `PASSED` | Every test for the specification passed. |
|
|
108
|
+
|
|
109
|
+
For example, if one test passes and another skips, the specification is `SKIPPED`
|
|
110
|
+
and remains unverified. If one test fails and another skips, it is `FAILED`.
|
|
111
|
+
|
|
112
|
+
### Missing and unfinished tests
|
|
113
|
+
|
|
114
|
+
Pytest first finds tests, then runs them. If it finds no marked tests for a
|
|
115
|
+
declared specification, that specification is `MISSING`. Tests that were found but
|
|
116
|
+
never ran are `SKIPPED`, including runs that only find tests or stop early.
|
|
117
|
+
If pytest encounters an error while finding tests, such as a broken import,
|
|
118
|
+
the verification check cannot succeed.
|
|
119
|
+
|
|
120
|
+
### Tests without markers
|
|
121
|
+
|
|
122
|
+
Tests without a specification marker do not change specification statuses. They
|
|
123
|
+
can still make pytest fail. In [the duck example][acceptance-only],
|
|
124
|
+
the specification passes, but the unmarked cheese test fails the pytest run.
|
|
125
|
+
|
|
126
|
+
### Check coverage
|
|
127
|
+
|
|
128
|
+
Coverage is the percentage of declared specifications that are verified. With
|
|
129
|
+
no declared specifications and no errors while finding tests, the verification
|
|
130
|
+
check succeeds and coverage is 0.0%.
|
|
131
|
+
|
|
132
|
+
Errors while finding tests make verification fail, even at 100% coverage. The report follows
|
|
133
|
+
pytest's color settings, so `--color=no` disables color.
|
|
134
|
+
|
|
135
|
+
For a complete report, let pytest find the whole test suite. With `-k` or `-m`,
|
|
136
|
+
the plugin still knows about the tests excluded by the filter and reports marked
|
|
137
|
+
ones as `DESELECTED`. If you run only one folder, it cannot see tests in other
|
|
138
|
+
folders.
|
|
139
|
+
|
|
140
|
+
## Runnable examples
|
|
141
|
+
|
|
142
|
+
Start with [the pizza][pizza], then try the other
|
|
143
|
+
examples to see what makes a specification pass or remain unverified.
|
|
144
|
+
|
|
145
|
+
Run the commands below from the repository root. Dependencies are installed automatically.
|
|
146
|
+
|
|
147
|
+
| Example | With `--xspec-strict` | Exit code |
|
|
148
|
+
| --- | --- | --- |
|
|
149
|
+
| [Quickstart][quickstart] - Two slices of pizza | `PASSED`, 100% coverage | 0 |
|
|
150
|
+
| [Missing specification][missing-requirement] - A lost sock | `MISSING`, 50% coverage | 1 |
|
|
151
|
+
| [Partial verification][partial-verification] - Cat skips a meeting | `SKIPPED`, 0% coverage | 1 |
|
|
152
|
+
| [Acceptance only][acceptance-only] - Duck versus cheese | `PASSED` with `-m xspec` | 0 |
|
|
153
|
+
|
|
154
|
+
### Run an example
|
|
155
|
+
|
|
156
|
+
Run the `Quickstart` example, then replace `quickstart` with another example folder.
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
uv run --locked pytest examples/quickstart --xspec-strict
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
For `acceptance_only`, add `-m xspec` to run only the marked test.
|
|
163
|
+
Without that filter, the unmarked test makes pytest fail.
|
|
164
|
+
|
|
165
|
+
### Inspect the JSON report
|
|
166
|
+
|
|
167
|
+
Export the example results to see the specification status and individual test outcomes.
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
uv run --locked pytest examples/quickstart --xspec-json=examples/quickstart/report.json
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Open [examples/quickstart/report.json][report] after running the command.
|
|
174
|
+
|
|
175
|
+
### Verify all examples
|
|
176
|
+
|
|
177
|
+
Check that every example behaves as described.
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
uv run --locked pytest examples/test_examples.py -n 0
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
All five checks should pass, including checks for the examples that deliberately
|
|
184
|
+
fail. The examples are also verified in CI.
|
|
185
|
+
|
|
186
|
+
## Contributing
|
|
187
|
+
|
|
188
|
+
See [CONTRIBUTING.md][contributing] for setup, tests and pull requests.
|
|
189
|
+
|
|
190
|
+
[acceptance-only]: https://github.com/Thyholt/pytest-xspec/tree/master/examples/acceptance_only
|
|
191
|
+
[pizza]: https://github.com/Thyholt/pytest-xspec/blob/master/examples/quickstart/test_behavior.py
|
|
192
|
+
[quickstart]: https://github.com/Thyholt/pytest-xspec/tree/master/examples/quickstart
|
|
193
|
+
[missing-requirement]: https://github.com/Thyholt/pytest-xspec/tree/master/examples/missing_requirement
|
|
194
|
+
[partial-verification]: https://github.com/Thyholt/pytest-xspec/tree/master/examples/partial_verification
|
|
195
|
+
[report]: https://github.com/Thyholt/pytest-xspec/blob/master/examples/quickstart/report.json
|
|
196
|
+
[contributing]: https://github.com/Thyholt/pytest-xspec/blob/master/CONTRIBUTING.md
|
|
197
|
+
|
|
198
|
+
[pypi]: https://pypi.org/project/pytest-xspec/
|
|
199
|
+
[pypi-version-badge]: https://img.shields.io/pypi/v/pytest-xspec
|
|
200
|
+
[python-versions-badge]: https://img.shields.io/badge/python-3.10%2B-blue
|
|
201
|
+
[tests]: https://github.com/Thyholt/pytest-xspec/actions/workflows/check.yml
|
|
202
|
+
[tests-badge]: https://img.shields.io/github/actions/workflow/status/Thyholt/pytest-xspec/check.yml?branch=master&label=tests
|
|
203
|
+
|
|
204
|
+
[codecov]: https://app.codecov.io/gh/Thyholt/pytest-xspec
|
|
205
|
+
[codecov-badge]: https://codecov.io/gh/Thyholt/pytest-xspec/branch/master/graph/badge.svg
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pytest_xspec/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pytest_xspec/collector.py,sha256=4qSrsbvJ78bqggYYG4Sbq7bXp2wc0DjRs_50W2KE76c,794
|
|
3
|
+
pytest_xspec/config.py,sha256=KFRbd4GRj20SjDv9d5lGtq0fsLUdiON44OEyVpxdhfc,2181
|
|
4
|
+
pytest_xspec/outcomes.py,sha256=K4EpjJMq16J9ffDSUKceNf1U5I1Bx2e8Ngi3iL_R1GQ,952
|
|
5
|
+
pytest_xspec/plugin.py,sha256=9UYo-qN-VS5vtTIbeND9bBCZ3JyCnoJwGU7GAIc7V4Y,3770
|
|
6
|
+
pytest_xspec/reporting.py,sha256=T4v4i8bCxzG6srsXCW3sv1jvARdvpn1C7dZHXGm8C3k,3096
|
|
7
|
+
pytest_xspec-0.5.0.dist-info/METADATA,sha256=8l014jjgVJ2-XByMd0J7vtAJO2jfSkWeU3aJsMGcgSg,7687
|
|
8
|
+
pytest_xspec-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
pytest_xspec-0.5.0.dist-info/entry_points.txt,sha256=TCQsg6OwG8dKvjg3eeazOa9fY5GgKp1ONpBbMtoCZiI,39
|
|
10
|
+
pytest_xspec-0.5.0.dist-info/licenses/LICENSE,sha256=UKFrzi3aP48KpaWL0SxbfrYo85PrVDvEqw3oShDk6xg,1082
|
|
11
|
+
pytest_xspec-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andreas Thyholt Henriksen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|