Aller au contenu

5. Tests miroir 100 %

Règle. Pour chaque app/<module>.py, il existe tests/<module>.py (l'arbre tests/ reflète app/). Couverture 100 % lignes + branches sur tout le code source. Le make gate refuse en dessous.

Pourquoi

"On a 92 % de couverture" = on a 8 % de code qui ne tourne jamais sous test. Statistiquement, ces 8 % contiennent les bugs en prod (chemins d'erreur, branches rares).

100 % lignes + branches force à :

  • Penser à chaque branche conditionnelle dès l'écriture du code.
  • Tester les chemins d'erreur avec autant de soin que les chemins heureux.
  • Refactoriser au lieu d'ignorer quand une branche est intestable — c'est souvent le signe d'un design à revoir.

La convention "miroir"

src/                                tests/
└── mic/                            └── ...
    ├── contracts/                      ├── contracts/
    │   ├── strict_schema.py        ←→  │   ├── test_strict_schema.py
    │   └── envelope.py             ←→  │   └── test_envelope.py
    ├── http/                           ├── http/
    │   ├── route_spec.py           ←→  │   ├── test_route_spec.py
    │   └── _resolve.py             ←→  │   └── test_resolve.py
    ├── fastapi/                        ├── fastapi/
    │   └── router.py               ←→  │   └── test_router.py
    ├── litestar/                       ├── litestar/
    │   └── router.py               ←→  │   └── test_router.py
    └── ...                             └── ...

Un fichier source ↔ un fichier de tests. Cherche-t-on à tester mic.contracts.strict_schema.with_changes() ? On va dans tests/contracts/test_strict_schema.py. Pas de chasse au trésor.

Configuration pytest + couverture

# pyproject.toml
[tool.pytest.ini_options]
addopts = "-ra --strict-markers --cov=src/mic --cov-branch --cov-report=term-missing"

[tool.coverage.report]
fail_under = 100
exclude_lines = [
    "pragma: no cover",
    "raise NotImplementedError",
    "if TYPE_CHECKING:",
    "@overload",
]

--cov-branch est non négociable : sans ça, un if x: ... testé seulement avec x=True compte comme couvert à 100 % lignes mais 50 % branches.

Quand un chemin est dur à couvrir

Trois solutions, dans cet ordre :

  1. Refactoriser : si une branche est intestable, c'est qu'elle est probablement aussi inutile ou trop couplée. Changer le code souvent éclaircit l'intention.
  2. Injection : passer la dépendance externe en argument plutôt que de l'instancier dedans. Un time.sleep devient un sleep_fn injectable. Un smtplib.SMTP devient un smtp_factory.
  3. # pragma: no cover en dernier recours, avec un commentaire qui justifie. Pas un blanc-seing.
# Entrypoint worker — testé indirectement par un test d'intégration ;
# pragma justifié car uvicorn.run() fait de l'I/O réseau pure.
if __name__ == "__main__":   # pragma: no cover
    main()

Test miroir ≠ test unitaire pur

Un test miroir n'est pas qu'un test unitaire pur. Pour les modules qui orchestrent (worker, factory de routes), on peut tester end-to-end via SQLite in-memory et le TestClient. Ce qui compte :

  • Chaque module source a SON fichier de test miroir.
  • Chaque module source est couvert à 100 % (par ses tests miroir + ceux des autres qui l'exercent indirectement).

Le make gate qui refuse en dessous

.PHONY: test-cov
test-cov:
    $(VENV)/bin/pytest --cov-fail-under=100

Si la couverture descend à 99,7 %, le gate plante. Vous ne pouvez pas fusionner sans soit ajouter le test manquant, soit refactoriser, soit ajouter un # pragma: no cover justifié.

Validé en pratique

Repo Tests Couverture
mic-struct (mic-core + mic-datastore + mic-platform) 1634 100 %
mail-out 158 100 %
audit 198 100 %
search 319 100 %
Total 2309 100 %

Compte des fonctions test_* (snapshot). Le gate --cov-fail-under=100 maintient les 100 % à chaque MR.