Picarones / tests /architecture /test_no_broad_pytest_raises.py
Claude
test(audit): éliminer tous les pytest.raises(Exception) résiduels
0d00572 unverified
Raw
History Blame
4.06 kB
"""Phase 8 audit code-quality (2026-05) — ``pytest.raises(Exception)``
trop large interdit dans ``tests/``.
Catch-all sur ``Exception`` masque les régressions : un test qui
attend ``FrozenInstanceError`` (champ frozen modifié) mais reçoit
``KeyError`` (mauvais setup de la fixture) passera quand même au
vert. L'audit avait identifié ~15 sites avec ce pattern, tous
remplacés par la classe d'exception précise :
- ``FrozenInstanceError`` (dataclass frozen)
- ``ValidationError`` (Pydantic frozen ou validation)
- ``PicaronesError`` / sous-classes (erreurs métier)
- etc.
Ce test scanne ``tests/`` (hors fixtures, conftest et ce fichier
lui-même) et refuse toute nouvelle occurrence.
"""
from __future__ import annotations
import ast
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
TESTS_DIR = REPO_ROOT / "tests"
def _scan_broad_raises() -> list[tuple[Path, int]]:
"""Trouve les ``pytest.raises(Exception)`` ou ``pytest.raises(Exception, ...)``.
Le scan ignore aussi ``pytest.raises(BaseException)`` qui est
encore plus large (couvre ``KeyboardInterrupt`` etc.).
"""
findings: list[tuple[Path, int]] = []
for path in sorted(TESTS_DIR.rglob("test_*.py")):
if path == Path(__file__):
continue
try:
tree = ast.parse(path.read_text(encoding="utf-8"))
except SyntaxError:
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
# ``pytest.raises(...)``
if not (
isinstance(func, ast.Attribute)
and func.attr == "raises"
and isinstance(func.value, ast.Name)
and func.value.id == "pytest"
):
continue
if not node.args:
continue
first = node.args[0]
# ``pytest.raises(Exception)`` ou ``pytest.raises(BaseException)``
if isinstance(first, ast.Name) and first.id in {"Exception", "BaseException"}:
findings.append((path, node.lineno))
return findings
#: Baseline du nombre de ``pytest.raises(Exception)`` dans la suite.
#: La Phase 8 audit code-quality (2026-05) en a éliminé ~15 sites sur
#: les patterns évidents (FrozenInstanceError, ValidationError).
#: Le reste (~20 sites résiduels sur des cas plus subtils — chaînage
#: de validateurs Pydantic, exceptions custom au choix multiple) est
#: laissé pour une PR de polissage dédiée.
#:
#: Test ratchet : le compteur ne peut que **diminuer**. Pour le
#: faire baisser :
#:
#: 1. Remplacer ``pytest.raises(Exception)`` par la classe précise.
#: 2. Baisser :data:`BROAD_RAISES_BASELINE` du même montant.
BROAD_RAISES_BASELINE = 0
def test_broad_pytest_raises_below_baseline() -> None:
"""Le compteur ``pytest.raises(Exception)`` ne peut que baisser."""
findings = _scan_broad_raises()
count = len(findings)
if count > BROAD_RAISES_BASELINE:
lines = "\n".join(
f" {p.relative_to(REPO_ROOT)}:{ln}"
for p, ln in findings
)
raise AssertionError(
f"Sites ``pytest.raises(Exception)`` : {count} > "
f"baseline {BROAD_RAISES_BASELINE}.\n\n"
+ lines
+ "\n\nRemplacer par la classe d'exception précise "
"attendue (``FrozenInstanceError``, ``ValidationError``, "
"``PicaronesError``, etc.). Un catch-all masque les "
"régressions où une exception différente serait levée."
)
def test_baseline_must_be_tightened_when_progress_made() -> None:
"""Si le compteur est sous la baseline, abaisser :data:`BROAD_RAISES_BASELINE`."""
count = len(_scan_broad_raises())
assert count >= BROAD_RAISES_BASELINE, (
f"Sites ``pytest.raises(Exception)`` : {count} < baseline "
f"{BROAD_RAISES_BASELINE}.\n\nMets à jour BROAD_RAISES_BASELINE "
f"= {count} dans ce fichier (le gain est verrouillé)."
)