5. Tests miroir 100 %¶
Règle. Pour chaque
app/<module>.py, il existetests/<module>.py(l'arbretests/reflèteapp/). Couverture 100 % lignes + branches sur tout le code source. Lemake gaterefuse 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 :
- 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.
- Injection : passer la dépendance externe en argument plutôt que de
l'instancier dedans. Un
time.sleepdevient unsleep_fninjectable. Unsmtplib.SMTPdevient unsmtp_factory. # pragma: no coveren 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¶
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.