tesserakit-tests 0.4.0__tar.gz
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.
- tesserakit_tests-0.4.0/.gitignore +8 -0
- tesserakit_tests-0.4.0/PKG-INFO +56 -0
- tesserakit_tests-0.4.0/README.md +35 -0
- tesserakit_tests-0.4.0/pyproject.toml +40 -0
- tesserakit_tests-0.4.0/src/tessera_tests/__init__.py +3 -0
- tesserakit_tests-0.4.0/src/tessera_tests/cli.py +45 -0
- tesserakit_tests-0.4.0/src/tessera_tests/compiler.py +121 -0
- tesserakit_tests-0.4.0/src/tessera_tests/loader.py +23 -0
- tesserakit_tests-0.4.0/src/tessera_tests/pack.py +36 -0
- tesserakit_tests-0.4.0/src/tessera_tests/scan.py +97 -0
- tesserakit_tests-0.4.0/src/tessera_tests/schema.py +25 -0
- tesserakit_tests-0.4.0/src/tessera_tests/validator.py +38 -0
- tesserakit_tests-0.4.0/tests/test_tests_pack.py +83 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tesserakit-tests
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Tests job pack for Tessera: audit a Python test suite for hygiene (no-assert, skipped, xfail).
|
|
5
|
+
Project-URL: Homepage, https://github.com/ShaileshRawat1403/tessera
|
|
6
|
+
Project-URL: Repository, https://github.com/ShaileshRawat1403/tessera
|
|
7
|
+
Project-URL: Issues, https://github.com/ShaileshRawat1403/tessera/issues
|
|
8
|
+
Author: Shailesh Rawat
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: pydantic>=2.7
|
|
15
|
+
Requires-Dist: rich>=13.7
|
|
16
|
+
Requires-Dist: tesserakit-core>=0.1.0
|
|
17
|
+
Requires-Dist: typer>=0.12
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# tesserakit-tests
|
|
23
|
+
|
|
24
|
+
Audit a Python test suite for hygiene problems.
|
|
25
|
+
|
|
26
|
+
`tessera-tests` parses test files with `ast` (never imports or runs them), inventories the test functions and methods, and surfaces the tests that aren't really protecting anything: tests with no assertions, and tests that are skipped or expected to fail.
|
|
27
|
+
|
|
28
|
+
## Audit
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
tessera tests audit --input . --output ./out/tests_pack
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Test discovery follows pytest/unittest conventions: files named `test_*.py` / `*_test.py` or under a `tests/` directory; functions named `test*`; methods named `test*` inside `Test*` classes.
|
|
35
|
+
|
|
36
|
+
Artifacts written:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
tests.jsonl one TestCase per test (asserts, skip/xfail/param flags)
|
|
40
|
+
index.md the test inventory
|
|
41
|
+
validation_report.md hygiene findings
|
|
42
|
+
coverage_report.md counts (skipped/xfail/parametrized/no-assert) + per-file
|
|
43
|
+
not_running.md skipped + xfail tests (present but not protecting anything)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## What it detects
|
|
47
|
+
|
|
48
|
+
- **Assertions**: `assert` statements, `self.assert*` calls, and `pytest.raises`/`warns` blocks.
|
|
49
|
+
- **Markers**: `@pytest.mark.skip` / `skipif`, `xfail`, `parametrize` (matched on the decorator name).
|
|
50
|
+
|
|
51
|
+
## Findings
|
|
52
|
+
|
|
53
|
+
- `no_assertion_test` (warning) — a test with zero assertions that isn't skipped/xfail
|
|
54
|
+
- `skipped_test` (info) — a skipped test
|
|
55
|
+
- `xfail_test` (info) — an expected-failure test
|
|
56
|
+
- `parse_error`, `no_tests_found`
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# tesserakit-tests
|
|
2
|
+
|
|
3
|
+
Audit a Python test suite for hygiene problems.
|
|
4
|
+
|
|
5
|
+
`tessera-tests` parses test files with `ast` (never imports or runs them), inventories the test functions and methods, and surfaces the tests that aren't really protecting anything: tests with no assertions, and tests that are skipped or expected to fail.
|
|
6
|
+
|
|
7
|
+
## Audit
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
tessera tests audit --input . --output ./out/tests_pack
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Test discovery follows pytest/unittest conventions: files named `test_*.py` / `*_test.py` or under a `tests/` directory; functions named `test*`; methods named `test*` inside `Test*` classes.
|
|
14
|
+
|
|
15
|
+
Artifacts written:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
tests.jsonl one TestCase per test (asserts, skip/xfail/param flags)
|
|
19
|
+
index.md the test inventory
|
|
20
|
+
validation_report.md hygiene findings
|
|
21
|
+
coverage_report.md counts (skipped/xfail/parametrized/no-assert) + per-file
|
|
22
|
+
not_running.md skipped + xfail tests (present but not protecting anything)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What it detects
|
|
26
|
+
|
|
27
|
+
- **Assertions**: `assert` statements, `self.assert*` calls, and `pytest.raises`/`warns` blocks.
|
|
28
|
+
- **Markers**: `@pytest.mark.skip` / `skipif`, `xfail`, `parametrize` (matched on the decorator name).
|
|
29
|
+
|
|
30
|
+
## Findings
|
|
31
|
+
|
|
32
|
+
- `no_assertion_test` (warning) — a test with zero assertions that isn't skipped/xfail
|
|
33
|
+
- `skipped_test` (info) — a skipped test
|
|
34
|
+
- `xfail_test` (info) — an expected-failure test
|
|
35
|
+
- `parse_error`, `no_tests_found`
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tesserakit-tests"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Tests job pack for Tessera: audit a Python test suite for hygiene (no-assert, skipped, xfail)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "Shailesh Rawat" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"tesserakit-core>=0.1.0",
|
|
14
|
+
"typer>=0.12",
|
|
15
|
+
"rich>=13.7",
|
|
16
|
+
"pydantic>=2.7",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/ShaileshRawat1403/tessera"
|
|
27
|
+
Repository = "https://github.com/ShaileshRawat1403/tessera"
|
|
28
|
+
Issues = "https://github.com/ShaileshRawat1403/tessera/issues"
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=8.0"]
|
|
32
|
+
|
|
33
|
+
[project.entry-points."tessera.commands"]
|
|
34
|
+
tests = "tessera_tests.cli:register"
|
|
35
|
+
|
|
36
|
+
[project.entry-points."tessera.jobpacks"]
|
|
37
|
+
tests = "tessera_tests.pack:create_pack"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/tessera_tests"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from tessera_core.models import RunContext
|
|
10
|
+
|
|
11
|
+
from tessera_tests.pack import TestsPack
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
tests_app = typer.Typer(help="Audit a Python test suite for hygiene (no-assert, skipped, xfail).")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@tests_app.command("audit")
|
|
18
|
+
def audit_cmd(
|
|
19
|
+
input: Path = typer.Option(Path("."), "--input", "-i", exists=True, readable=True, help="Project directory."),
|
|
20
|
+
output: Path = typer.Option(Path("tests_pack"), "--output", "-o", help="Output directory."),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Discover tests and report hygiene issues (no assertions, skipped, xfail)."""
|
|
23
|
+
ctx = RunContext(job_name="tests", output_dir=output)
|
|
24
|
+
pack = TestsPack()
|
|
25
|
+
artifacts = pack.run(input_path=input, ctx=ctx, options={})
|
|
26
|
+
|
|
27
|
+
table = Table(title="Tests Pack Created")
|
|
28
|
+
table.add_column("Artifact")
|
|
29
|
+
table.add_column("Path")
|
|
30
|
+
table.add_column("Kind")
|
|
31
|
+
for art in artifacts:
|
|
32
|
+
table.add_row(art.name, str(art.path), art.kind)
|
|
33
|
+
console.print(table)
|
|
34
|
+
|
|
35
|
+
summary = Table(title="Run Summary")
|
|
36
|
+
summary.add_column("Metric")
|
|
37
|
+
summary.add_column("Value")
|
|
38
|
+
summary.add_row("run_id", ctx.run_id)
|
|
39
|
+
summary.add_row("tests", str(ctx.metadata.get("record_count", 0)))
|
|
40
|
+
summary.add_row("findings", str(ctx.metadata.get("finding_count", 0)))
|
|
41
|
+
console.print(summary)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def register(root_app: typer.Typer) -> None:
|
|
45
|
+
root_app.add_typer(tests_app, name="tests")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from tessera_core.artifacts import write_jsonl, write_markdown
|
|
8
|
+
from tessera_core.models import Artifact, RunContext, ValidationFinding
|
|
9
|
+
|
|
10
|
+
from tessera_tests.loader import load_test_records
|
|
11
|
+
from tessera_tests.schema import TestCase
|
|
12
|
+
from tessera_tests.validator import validate_test_records
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_records(input_path: Path, options: dict[str, Any]) -> list[TestCase]:
|
|
16
|
+
return load_test_records(input_path, options)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_records(cases: list[TestCase], options: dict[str, Any]) -> list[ValidationFinding]:
|
|
20
|
+
return validate_test_records(cases, options)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def write_artifacts(cases: list[TestCase], ctx: RunContext, options: dict[str, Any]) -> list[Artifact]:
|
|
24
|
+
ctx.output_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
findings: list[ValidationFinding] = ctx.metadata.get("findings") or validate_records(cases, options)
|
|
26
|
+
|
|
27
|
+
tests_jsonl = ctx.output_dir / "tests.jsonl"
|
|
28
|
+
index_md = ctx.output_dir / "index.md"
|
|
29
|
+
validation_md = ctx.output_dir / "validation_report.md"
|
|
30
|
+
coverage_md = ctx.output_dir / "coverage_report.md"
|
|
31
|
+
not_running_md = ctx.output_dir / "not_running.md"
|
|
32
|
+
|
|
33
|
+
write_jsonl(tests_jsonl, [c.model_dump() for c in cases])
|
|
34
|
+
write_markdown(index_md, _render_index(cases, options))
|
|
35
|
+
write_markdown(validation_md, _render_validation(cases, findings))
|
|
36
|
+
write_markdown(coverage_md, _render_coverage(cases))
|
|
37
|
+
write_markdown(not_running_md, _render_not_running(cases))
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
Artifact(name="tests.jsonl", path=tests_jsonl, kind="jsonl"),
|
|
41
|
+
Artifact(name="index.md", path=index_md, kind="markdown"),
|
|
42
|
+
Artifact(name="validation_report.md", path=validation_md, kind="markdown"),
|
|
43
|
+
Artifact(name="coverage_report.md", path=coverage_md, kind="markdown"),
|
|
44
|
+
Artifact(name="not_running.md", path=not_running_md, kind="markdown"),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _render_index(cases: list[TestCase], options: dict[str, Any]) -> str:
|
|
49
|
+
lines = ["# Test Inventory", ""]
|
|
50
|
+
lines.append(f"- Test files: {options.get('_file_count', 0)}")
|
|
51
|
+
lines.append(f"- Tests: {len(cases)}")
|
|
52
|
+
no_assert = sum(1 for c in cases if not c.has_assert and not c.is_skipped and not c.is_xfail)
|
|
53
|
+
lines.append(f"- Tests with no assertions: {no_assert}")
|
|
54
|
+
lines.append("")
|
|
55
|
+
if not cases:
|
|
56
|
+
lines.append("_No tests found._")
|
|
57
|
+
return "\n".join(lines) + "\n"
|
|
58
|
+
lines.append("| Test | Asserts | Skipped | Xfail | Param | Location |")
|
|
59
|
+
lines.append("|---|---:|:--:|:--:|:--:|---|")
|
|
60
|
+
for c in cases:
|
|
61
|
+
lines.append(
|
|
62
|
+
f"| `{c.qualname}` | {c.assertion_count} "
|
|
63
|
+
f"| {'yes' if c.is_skipped else '-'} | {'yes' if c.is_xfail else '-'} "
|
|
64
|
+
f"| {'yes' if c.is_parametrized else '-'} | `{c.file}:{c.lineno}` |"
|
|
65
|
+
)
|
|
66
|
+
return "\n".join(lines) + "\n"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _render_validation(cases: list[TestCase], findings: list[ValidationFinding]) -> str:
|
|
70
|
+
lines = ["# Validation Report", ""]
|
|
71
|
+
lines.append(f"- Tests: {len(cases)}")
|
|
72
|
+
lines.append(f"- Findings: {len(findings)}")
|
|
73
|
+
lines.append("")
|
|
74
|
+
by_sev = Counter(f.severity for f in findings)
|
|
75
|
+
lines.append("## Severity Breakdown")
|
|
76
|
+
lines.append("")
|
|
77
|
+
for sev in ("error", "warning", "info"):
|
|
78
|
+
lines.append(f"- {sev}: {by_sev.get(sev, 0)}")
|
|
79
|
+
lines.append("")
|
|
80
|
+
if findings:
|
|
81
|
+
lines.append("## Findings")
|
|
82
|
+
lines.append("")
|
|
83
|
+
for f in findings[:300]:
|
|
84
|
+
lines.append(f"- **{f.severity.upper()}** `{f.code}`: {f.message}")
|
|
85
|
+
return "\n".join(lines)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _render_coverage(cases: list[TestCase]) -> str:
|
|
89
|
+
lines = ["# Coverage Report", ""]
|
|
90
|
+
lines.append(f"- Tests: {len(cases)}")
|
|
91
|
+
if not cases:
|
|
92
|
+
return "\n".join(lines) + "\n"
|
|
93
|
+
skipped = sum(1 for c in cases if c.is_skipped)
|
|
94
|
+
xfail = sum(1 for c in cases if c.is_xfail)
|
|
95
|
+
param = sum(1 for c in cases if c.is_parametrized)
|
|
96
|
+
no_assert = sum(1 for c in cases if not c.has_assert and not c.is_skipped and not c.is_xfail)
|
|
97
|
+
lines.append(f"- Skipped: {skipped}")
|
|
98
|
+
lines.append(f"- Xfail: {xfail}")
|
|
99
|
+
lines.append(f"- Parametrized: {param}")
|
|
100
|
+
lines.append(f"- No assertions: {no_assert}")
|
|
101
|
+
lines.append("")
|
|
102
|
+
by_file = Counter(c.file for c in cases)
|
|
103
|
+
lines.append("## Tests per file")
|
|
104
|
+
lines.append("")
|
|
105
|
+
for path, n in by_file.most_common():
|
|
106
|
+
lines.append(f"- `{path}`: {n}")
|
|
107
|
+
return "\n".join(lines) + "\n"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _render_not_running(cases: list[TestCase]) -> str:
|
|
111
|
+
inactive = [c for c in cases if c.is_skipped or c.is_xfail]
|
|
112
|
+
lines = ["# Tests Not Effectively Running", ""]
|
|
113
|
+
lines.append("Skipped and xfail tests — present but not protecting anything right now.")
|
|
114
|
+
lines.append("")
|
|
115
|
+
if not inactive:
|
|
116
|
+
lines.append("_All discovered tests are active._")
|
|
117
|
+
return "\n".join(lines) + "\n"
|
|
118
|
+
for c in inactive:
|
|
119
|
+
tag = "skip" if c.is_skipped else "xfail"
|
|
120
|
+
lines.append(f"- **{tag}** `{c.qualname}` ({c.file}:{c.lineno})")
|
|
121
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from tessera_tests.scan import discover_test_files, extract_tests
|
|
7
|
+
from tessera_tests.schema import TestCase
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_test_records(input_path: Path, options: dict[str, Any]) -> list[TestCase]:
|
|
11
|
+
root = input_path if input_path.is_dir() else input_path.parent
|
|
12
|
+
cases: list[TestCase] = []
|
|
13
|
+
parse_errors: list[str] = []
|
|
14
|
+
files = discover_test_files(root)
|
|
15
|
+
for f in files:
|
|
16
|
+
found, err = extract_tests(root, f)
|
|
17
|
+
cases.extend(found)
|
|
18
|
+
if err:
|
|
19
|
+
parse_errors.append(err)
|
|
20
|
+
options["_parse_errors"] = parse_errors
|
|
21
|
+
options["_file_count"] = len(files)
|
|
22
|
+
options["_root"] = str(root)
|
|
23
|
+
return cases
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from tessera_core.jobpack import JobPack
|
|
7
|
+
from tessera_core.models import Artifact, RunContext, ValidationFinding
|
|
8
|
+
|
|
9
|
+
from tessera_tests.compiler import load_records, validate_records, write_artifacts
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestsPack(JobPack):
|
|
13
|
+
name = "tests"
|
|
14
|
+
version = "0.3.1"
|
|
15
|
+
|
|
16
|
+
def normalize(self, input_path: Path, options: dict[str, Any]) -> list[Any]:
|
|
17
|
+
return load_records(input_path, options)
|
|
18
|
+
|
|
19
|
+
def validate(
|
|
20
|
+
self,
|
|
21
|
+
records: list[Any],
|
|
22
|
+
options: dict[str, Any],
|
|
23
|
+
) -> list[ValidationFinding]:
|
|
24
|
+
return validate_records(records, options)
|
|
25
|
+
|
|
26
|
+
def generate(
|
|
27
|
+
self,
|
|
28
|
+
records: list[Any],
|
|
29
|
+
ctx: RunContext,
|
|
30
|
+
options: dict[str, Any],
|
|
31
|
+
) -> list[Artifact]:
|
|
32
|
+
return write_artifacts(records, ctx, options)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_pack() -> TestsPack:
|
|
36
|
+
return TestsPack()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Discover tests and their hygiene facts via ast (no imports, no execution)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tessera_tests.schema import TestCase
|
|
9
|
+
|
|
10
|
+
_IGNORE = {
|
|
11
|
+
".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache",
|
|
12
|
+
"dist", "build", ".tox", "target",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_test_file(rel: Path) -> bool:
|
|
17
|
+
n = rel.name
|
|
18
|
+
if not n.endswith(".py"):
|
|
19
|
+
return False
|
|
20
|
+
if n.startswith("test_") or n.endswith("_test.py"):
|
|
21
|
+
return True
|
|
22
|
+
parts = [p.lower() for p in rel.parts[:-1]]
|
|
23
|
+
return any(p in ("test", "tests") for p in parts) and n != "__init__.py"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def discover_test_files(root: Path) -> list[Path]:
|
|
27
|
+
out: list[Path] = []
|
|
28
|
+
for p in sorted(root.rglob("*.py")):
|
|
29
|
+
rel = p.relative_to(root)
|
|
30
|
+
if any(part in _IGNORE for part in rel.parts):
|
|
31
|
+
continue
|
|
32
|
+
if is_test_file(rel):
|
|
33
|
+
out.append(p)
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _decorator_str(node: ast.expr) -> str:
|
|
38
|
+
if isinstance(node, ast.Call):
|
|
39
|
+
return _decorator_str(node.func)
|
|
40
|
+
if isinstance(node, ast.Attribute):
|
|
41
|
+
return f"{_decorator_str(node.value)}.{node.attr}"
|
|
42
|
+
if isinstance(node, ast.Name):
|
|
43
|
+
return node.id
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _count_assertions(fn: ast.AST) -> int:
|
|
48
|
+
count = 0
|
|
49
|
+
for node in ast.walk(fn):
|
|
50
|
+
if isinstance(node, ast.Assert):
|
|
51
|
+
count += 1
|
|
52
|
+
elif isinstance(node, ast.Call):
|
|
53
|
+
target = node.func
|
|
54
|
+
if isinstance(target, ast.Attribute):
|
|
55
|
+
attr = target.attr.lower()
|
|
56
|
+
if attr.startswith("assert"):
|
|
57
|
+
count += 1
|
|
58
|
+
elif attr in ("raises", "warns", "deprecated_call"):
|
|
59
|
+
count += 1
|
|
60
|
+
return count
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _make_case(fn: ast.AST, rel: str, kind: str, prefix: str) -> TestCase:
|
|
64
|
+
decorators = [d for d in (_decorator_str(x) for x in fn.decorator_list) if d]
|
|
65
|
+
lowered = " ".join(decorators).lower()
|
|
66
|
+
n = _count_assertions(fn)
|
|
67
|
+
return TestCase(
|
|
68
|
+
name=fn.name,
|
|
69
|
+
qualname=f"{prefix}{fn.name}",
|
|
70
|
+
kind=kind,
|
|
71
|
+
file=rel,
|
|
72
|
+
lineno=fn.lineno,
|
|
73
|
+
decorators=decorators,
|
|
74
|
+
is_skipped="skip" in lowered,
|
|
75
|
+
is_xfail="xfail" in lowered,
|
|
76
|
+
is_parametrized="parametrize" in lowered,
|
|
77
|
+
assertion_count=n,
|
|
78
|
+
has_assert=n > 0,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def extract_tests(root: Path, path: Path) -> tuple[list[TestCase], str | None]:
|
|
83
|
+
rel = path.relative_to(root).as_posix()
|
|
84
|
+
try:
|
|
85
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
86
|
+
except (OSError, UnicodeDecodeError, SyntaxError) as exc:
|
|
87
|
+
return [], f"{rel}: {exc}"
|
|
88
|
+
|
|
89
|
+
cases: list[TestCase] = []
|
|
90
|
+
for node in tree.body:
|
|
91
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test"):
|
|
92
|
+
cases.append(_make_case(node, rel, "function", ""))
|
|
93
|
+
elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
|
|
94
|
+
for sub in node.body:
|
|
95
|
+
if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) and sub.name.startswith("test"):
|
|
96
|
+
cases.append(_make_case(sub, rel, "method", f"{node.name}."))
|
|
97
|
+
return cases, None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestCase(BaseModel):
|
|
9
|
+
"""A discovered test. Serialized to ``tests.jsonl``."""
|
|
10
|
+
|
|
11
|
+
# Tell pytest not to collect this domain model as a test class.
|
|
12
|
+
__test__ = False
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
qualname: str = ""
|
|
16
|
+
kind: str = "function" # function / method
|
|
17
|
+
file: str = ""
|
|
18
|
+
lineno: int = 0
|
|
19
|
+
decorators: list[str] = Field(default_factory=list)
|
|
20
|
+
is_skipped: bool = False
|
|
21
|
+
is_xfail: bool = False
|
|
22
|
+
is_parametrized: bool = False
|
|
23
|
+
assertion_count: int = 0
|
|
24
|
+
has_assert: bool = False
|
|
25
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tessera_core.models import ValidationFinding
|
|
6
|
+
|
|
7
|
+
from tessera_tests.schema import TestCase
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_test_records(cases: list[TestCase], options: dict[str, Any]) -> list[ValidationFinding]:
|
|
11
|
+
findings: list[ValidationFinding] = []
|
|
12
|
+
|
|
13
|
+
for err in options.get("_parse_errors", []):
|
|
14
|
+
findings.append(ValidationFinding(severity="error", code="parse_error",
|
|
15
|
+
message=f"could not parse: {err}", field=None))
|
|
16
|
+
|
|
17
|
+
if not cases:
|
|
18
|
+
if not options.get("_parse_errors"):
|
|
19
|
+
findings.append(ValidationFinding(severity="info", code="no_tests_found",
|
|
20
|
+
message="no test functions discovered", field=None))
|
|
21
|
+
return findings
|
|
22
|
+
|
|
23
|
+
for c in cases:
|
|
24
|
+
loc = f"{c.file}:{c.lineno}"
|
|
25
|
+
|
|
26
|
+
def f(severity: str, code: str, message: str) -> ValidationFinding:
|
|
27
|
+
return ValidationFinding(severity=severity, code=code, message=message,
|
|
28
|
+
field="tests", metadata={"file": c.file, "lineno": c.lineno, "name": c.qualname})
|
|
29
|
+
|
|
30
|
+
if not c.has_assert and not c.is_skipped and not c.is_xfail:
|
|
31
|
+
findings.append(f("warning", "no_assertion_test",
|
|
32
|
+
f"{loc}: test `{c.qualname}` has no assertions"))
|
|
33
|
+
if c.is_skipped:
|
|
34
|
+
findings.append(f("info", "skipped_test", f"{loc}: test `{c.qualname}` is skipped"))
|
|
35
|
+
if c.is_xfail:
|
|
36
|
+
findings.append(f("info", "xfail_test", f"{loc}: test `{c.qualname}` is expected to fail"))
|
|
37
|
+
|
|
38
|
+
return findings
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from tessera_core.models import RunContext
|
|
7
|
+
|
|
8
|
+
from tessera_tests.pack import TestsPack
|
|
9
|
+
from tessera_tests.scan import extract_tests, is_test_file
|
|
10
|
+
from tessera_tests.schema import TestCase
|
|
11
|
+
|
|
12
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
13
|
+
SAMPLE = REPO_ROOT / "examples" / "tests"
|
|
14
|
+
SAMPLE_FILE = SAMPLE / "sample" / "test_sample.py"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------- discovery / extraction ----------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_is_test_file():
|
|
21
|
+
assert is_test_file(Path("tests/test_x.py"))
|
|
22
|
+
assert is_test_file(Path("foo_test.py"))
|
|
23
|
+
assert not is_test_file(Path("src/app.py"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_extract_flags():
|
|
27
|
+
cases, err = extract_tests(SAMPLE, SAMPLE_FILE)
|
|
28
|
+
assert err is None
|
|
29
|
+
by = {c.qualname: c for c in cases}
|
|
30
|
+
|
|
31
|
+
assert by["test_passes_with_assert"].has_assert
|
|
32
|
+
assert by["test_no_assertions"].has_assert is False
|
|
33
|
+
assert by["test_skipped"].is_skipped
|
|
34
|
+
assert by["test_expected_fail"].is_xfail
|
|
35
|
+
assert by["test_parametrized"].is_parametrized and by["test_parametrized"].has_assert
|
|
36
|
+
assert by["TestThing.test_method_with_assert"].kind == "method"
|
|
37
|
+
assert by["TestThing.test_method_with_assert"].has_assert # self.assertEqual
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------- end-to-end ----------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _run(tmp_path: Path):
|
|
44
|
+
out = tmp_path / "tests_pack"
|
|
45
|
+
ctx = RunContext(job_name="tests", output_dir=out)
|
|
46
|
+
TestsPack().run(input_path=SAMPLE, ctx=ctx, options={})
|
|
47
|
+
return out, ctx
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_findings(tmp_path: Path):
|
|
51
|
+
_, ctx = _run(tmp_path)
|
|
52
|
+
codes = {f.code for f in ctx.metadata["findings"]}
|
|
53
|
+
assert "no_assertion_test" in codes # test_no_assertions
|
|
54
|
+
assert "skipped_test" in codes
|
|
55
|
+
assert "xfail_test" in codes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_no_assertion_excludes_skipped(tmp_path: Path):
|
|
59
|
+
"""A skipped test with no real assertion must not be double-flagged as no_assertion."""
|
|
60
|
+
_, ctx = _run(tmp_path)
|
|
61
|
+
no_assert = [f for f in ctx.metadata["findings"] if f.code == "no_assertion_test"]
|
|
62
|
+
flagged = {f.metadata.get("name") for f in no_assert}
|
|
63
|
+
assert "test_skipped" not in flagged
|
|
64
|
+
assert "test_no_assertions" in flagged
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_artifacts_and_not_running(tmp_path: Path):
|
|
68
|
+
out, _ = _run(tmp_path)
|
|
69
|
+
names = {p.name for p in out.iterdir()}
|
|
70
|
+
assert {
|
|
71
|
+
"tests.jsonl", "index.md", "validation_report.md",
|
|
72
|
+
"coverage_report.md", "not_running.md",
|
|
73
|
+
} <= names
|
|
74
|
+
not_running = (out / "not_running.md").read_text()
|
|
75
|
+
assert "test_skipped" in not_running
|
|
76
|
+
assert "test_expected_fail" in not_running
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_round_trip(tmp_path: Path):
|
|
80
|
+
out, _ = _run(tmp_path)
|
|
81
|
+
for line in (out / "tests.jsonl").read_text().splitlines():
|
|
82
|
+
c = TestCase.model_validate_json(line)
|
|
83
|
+
assert c.name
|