ADR-163 — Les tests vivent dans un fichier à eux : ce que la métrique de couverture mesure vraiment
Statut : Proposé (2026-08-22). Décide où vit le code de test d’une crate, et pourquoi c’est une question de mesure et non de style. Corrige un défaut découvert le 2026-08-22 : le périmètre de la métrique de couverture dépend aujourd’hui d’un choix de mise en page laissé à l’auteur.
Catégorie : Outillage qualité, transverse. S’appuie sur ADR-035 (taxonomie des exceptions), ADR-031 (mesure en root) et ADR-143 (mesure par couche).
Contexte
La couverture de branches du workspace est un cliquet : xtask/couverture-reference.toml
porte un chiffre mesuré, et toute PR qui mesure moins échoue. Le fichier interdit explicitement
de le baisser à la main.
En instruisant une prétendue régression, le 2026-08-22, on a découvert que ce chiffre ne mesure pas ce qu’on croyait.
Ce que l’outil exclut, et que personne n’a écrit
cargo-llvm-cov passe de lui-même à llvm-cov un filtre par défaut. Extrait, relevé sur la
commande réelle :
^…/air(/.*)?/(tests|examples|benches)/
|^…/air(/.*)?/(tests\.rs|[0-9a-zA-Z_-]+[_-]tests\.rs)$
Sont donc écartés du rapport : tout répertoire tests/, examples/, benches/, et tout fichier
nommé tests.rs, *_tests.rs ou *-tests.rs.
Cette règle ne figure nulle part dans le dépôt. Ce n’est pas une décision d’Air : c’est un
défaut de l’outil, dont on hérite sans l’avoir choisi ni documenté. Le xtask reçoit un chiffre
déjà filtré et ne sait pas ce qui en a été retiré.
La conséquence : la métrique dépend d’un choix de mise en page
| Où l’auteur a mis ses tests | Compté dans la métrique ? | Volume au 2026-08-22 |
|---|---|---|
Fichier séparé (tests.rs, *_tests.rs) | non | 169 fichiers, 88 317 lignes |
Répertoire crates/<c>/tests/ | non | 27 crates |
#[cfg(test)] mod tests { … } inline | oui | 234 fichiers, 253 modules, 57 798 lignes |
Deux crates également testées n’obtiennent donc pas le même chiffre : celle dont l’auteur a écrit
mod tests { … } au bas du fichier voit son code de test mesuré, celle qui a écrit
mod tests; à côté ne le voit pas. Rien dans le produit ne les distingue.
Preuve directe que l’inline compte : à la mesure d’AirThreadManager, la seule ligne non
couverte de crates/air-thread/src/manager.rs était la 317 — le corps unreachable!() d’un
test, à l’intérieur du mod tests inline. Le code de test était bien dans le rapport.
Pourquoi c’est pire qu’un simple biais
La couverture de branches compte chaque opérande de court-circuit : if a && b && c ne vaut
pas deux branches mais six. Un test écrit avec des conditions composées en crée donc beaucoup,
et n’en exerce qu’une partie — un test ne cherche pas à couvrir ses propres gardes.
Sous le régime actuel, écrire un test peut faire baisser le chiffre, si l’on a le malheur de l’écrire inline. Un cliquet qui pénalise l’écriture de tests mesure l’inverse de ce qu’il prétend.
Décisions
D1 — Le code de test vit dans un fichier dédié
#[cfg(test)] mod tests { … } inline est interdit dans crates/*/src. La forme retenue est
la déclaration plus le fichier voisin :
#![allow(unused)]
fn main() {
// dans foo.rs
#[cfg(test)]
mod tests; // → foo/tests.rs
}
Les répertoires crates/<crate>/tests/ (tests d’intégration) et les benches/ restent inchangés :
ils sont déjà hors du rapport.
D1-bis — Deux exceptions que D1 ne prévoyait pas (2026-08-22)
La migration les a révélées, chacune par un rouge. Elles ne relâchent pas D1 : elles disent
ce qui n’est pas du code de test, et ce que devient un module de test qui ne s’appelle pas
tests.
a) Un module de test qui porte un autre nom
Le filtre par défaut de cargo-llvm-cov (§ Ce que l’outil exclut) écarte tests.rs,
*_tests.rs, *-tests.rs et les répertoires tests/. Il n’écarte pas un proptests.rs
posé en voisin : ses lignes retombent dans la mesure de production.
Six modules étaient dans ce cas — air-sys-types::{device, ebpf}, air-thread::{channel, sync, thread}, air-sys-syscall::io_uring::slab —, et ils ont fait rougir couverture-pr sur deux
crates, régression confirmée sur trois mesures.
Un module de test dont le nom n’est pas
testsva sous<parent>/tests/<nom>.rs, déclaré par#[path].
#[path] déplace le fichier sans toucher l’arbre des modules : super:: désigne toujours le
parent, et le contenu des tests n’a pas à changer. Écrire mod proptests; sans #[path] ferait
chercher <parent>/proptests.rs — le fichier mesuré qu’on veut éviter.
b) Un #[cfg(test)] mod qui n’est pas du code de test
Certains #[cfg(test)] mod X { … } sont des substituts de production sous test, appariés à
un #[cfg(not(test))] mod X { … } qui porte la vraie implémentation :
air-runtime::thread_control_block::{destructor_storage, errno_storage} (où la version de test
utilise std::thread_local!) et air-sys-syscall::io_uring::syscall::imp.
Ils restent inline. Les déplacer est une faute, à deux titres : ce n’est pas du test, et leur chemin de fichier est celui que
COV_IGNORE_LINESnomme — le changer les fait échapper à une exclusion qui les visait.
Le gate les reconnaît à une propriété lisible dans la source, non à une liste d’exemptions :
un substitut a toujours son pendant #[cfg(not(test))] du même nom ; un module de test n’en a
jamais. Un pendant n’exonère que le même nom — sans quoi un vrai mod tests voisin
passerait à travers.
c) Un fichier de test qu’aucun mod ne déclare est muet, et rien ne le dit
C’est la classe de défaut que cette règle elle-même crée, et il faut la nommer ici plutôt que de la laisser naître en silence.
Tant que les tests vivaient dans le fichier de production, les perdre était impossible :
supprimer le bloc se voyait au diff. Séparés, ils tiennent à une ligne de déclaration —
et si cette ligne manque, le fichier n’appartient tout simplement pas à la crate. Aucune
erreur, aucun avertissement, aucun dead_code : un test qui ne s’exécute pas ressemble
exactement à un test qui passe.
Constaté le 2026-08-23, et ce n’était pas une hypothèse : crates/air-sandbox/src/ceiling/tests.rs
— 428 lignes, vingt-deux tests — n’était pas compilé. [ADR-150] D3 avait déménagé
ceiling.rs d’air-bundle vers air-sandbox en emportant ses tests, mais la déclaration
mod tests;, bien présente côté air-bundle, s’était perdue au passage. Ils sont restés muets
du merge jusqu’à ce qu’une mesure de couverture s’étonne de seize lignes non couvertes —
lignes que leurs propres tests auraient dû atteindre.
Le coût ne s’est pas arrêté là : la dette apparente d’air-sandbox est passée de 3 à 17, et a
été inscrite au registre du cliquet comme une dette structurelle héritée du déménagement.
Elle décrivait fidèlement ce qui était mesuré, et se trompait entièrement de cause.
Un
tests.rssoussrc/qu’aucunmodne déclare est une régression, au même titre qu’un module inline.
Ce que check-tests-isoles ne fait pas : il
cherche des modules inline, pas des fichiers orphelins. Le gate qui refuse un tests.rs
non déclaré était la contrepartie que D1 devait à la migration qu’elle impose.
Écrit le 2026-08-24 : modules-orphelins
reconstruit l’arbre des modules depuis chaque racine de crate — src/lib.rs, src/main.rs
et chaque src/bin/*.rs, qui sont autant de racines distinctes — et refuse tout fichier
.rs sous src/ que cet arbre n’atteint pas. Il est bloquant dès sa naissance, pour la
raison qui l’autorisait à l’être : il naît à zéro, l’unique orphelin connu ayant été
raccordé avant lui. Vérifié en le remettant en défaut : le gate rend le fichier et sort en 1.
Ce qu’il a coûté d’écrire, et qui vaut d’être consigné : trois défauts dans le gate lui-même, tous trouvés par son propre test sur le workspace réel, aucun par relecture.
#[path = "…"]était annoncé « honoré » dans l’en-tête du module alors que la fonction rendait toujours « pas unpath» — le NO GO n°2 commis par l’outil qui l’applique.- Une fois câblé, il partait du mauvais répertoire :
#[path]se résout depuis le répertoire du fichier qui porte la déclaration, pas depuis celui de ses sous-modules. Le test qui devait le couvrir partait delib.rs, où les deux coïncident : il ne pouvait pas voir la faute.air-threadetair-sshétaient accusés à tort. - Le
{d’unmod x { … }étant consommé par la reconnaissance, le compteur d’accolades ne le voyait jamais : deux modules inline successifs s’empilaient, etair-base-core/src/encoding/hex/tests.rshéritait du préfixe debase64.
Un quatrième point n’était pas un défaut du code mais de sa documentation : l’en-tête
affirmait que le workspace ne contenait aucun include! sous src/. Il en contient
quatorze, et neuf passent par include!($chemin) dans un macro_rules!, où le littéral
n’existe qu’à l’expansion. Le gate les couvre par un filet assumé — un chemin .rs écrit en
toutes lettres vaut référence —, dont le sens d’erreur est délibéré : se taire de trop
plutôt qu’accuser à tort, un gate qui accuse à tort cessant d’être lu.
Ce qu’il ne sait pas voir est écrit dans son en-tête, pas ici : il mesure l’atteignabilité
syntaxique, non la compilation effective, et ne suit pas les mod d’un fichier inclus.
Ce que ces TROIS cas enseignent
D1 énonçait une règle de nom de fichier, réputée infaillible là où une heuristique de régions se trompe (D6). Elle l’est — mais elle ne dit rien de quels fichiers le filtre attrape, ni de ce qui mérite d’être déplacé, ni de ce qui garantit qu’un fichier séparé est encore lu. Les trois exceptions sont exactement ces trois angles morts.
Et toutes trois se sont manifestées par une mesure rouge, jamais par une relecture. C’est le plus instructif : chacune était invisible à l’œil, et lisible dans un chiffre.
D2 — La raison est la mesure, pas le goût
Ce n’est pas une règle de style : ce qu’un cliquet mesure ne doit pas dépendre de l’endroit où un auteur a mis une accolade. Une fois D1 tenue, le périmètre de la métrique est exactement le code de production, et il l’est par construction — non par la vigilance d’un relecteur.
C’est l’application du Principe 11 à notre propre outillage : une propriété qui se lit dans la disposition vaut mieux qu’une propriété qu’il faut détecter.
D3 — Le gate naît consultatif à sa ligne de base, puis devient bloquant à zéro
cargo xtask check-tests-isoles signale tout #[cfg(test)] mod … { ouvrant un bloc dans
crates/*/src. Il publie son compte, consultatif tant que la dette n’est pas soldée, et
bloquant dès qu’elle atteint zéro — le régime déjà appliqué à check-dependencies et
check-safety-comments.
Ligne de base au 2026-08-22, mesurée par le gate lui-même : 234 fichiers, 253 modules, 57 798 lignes.
Rectification du 2026-08-22. Ce document annonçait d’abord 211 fichiers, 51 454 lignes. Ce relevé, fait à la main, ne reconnaissait que le
#[cfg(test)]littéral et manquait 21 fichiers portant#[cfg(all(test, target_os = "linux"))]— la forme d’air-signal,air-poll,air-process. Le chiffre qui fait foi est celui du gate, parce que c’est lui qui décide du rouge et du vert.
D4 — Rien n’est perdu à déplacer
Un module enfant atteint les éléments privés de son parent par super::, qu’il soit inline ou
dans un fichier voisin : la visibilité est celle du module, pas celle du fichier. La migration
est donc mécanique — déclarer mod tests;, déplacer le bloc — et ne change aucune propriété du
code de production.
D5 — La référence de couverture sera re-mesurée après migration, et sa baisse ne sera pas une régression
La métrique cessera de compter 57 798 lignes de code de test.
Contre-rectification du 2026-08-23 — la mesure en CI rétablit la décision d’origine.
Le job
couverture-couchesde la PR #820, en root et en mode complet, rend 76,14 % contre 77,52 % avant migration — une baisse, stable sur trois mesures. La décision d’origine avait donc raison, et la rectification ci-dessous avait tort.La faute était méthodologique : le relevé qui montrait le code de test moins bien couvert (51,95 %) avait été fait hors root, où les suites root ne s’exécutent pas — leur code de test y paraît non couvert. En root, il est mieux couvert que la production, et le retirer fait baisser la moyenne. C’est la même faute que mesurer sur la pile gnu au lieu de la cible : un environnement qui n’est pas celui de la preuve ne démontre rien.
Ce que la migration a bel et bien fait, et qui se lit dans le même journal : les lignes non couvertes résidant dans du code de test passent de 529 à 20, tandis que la dette de production ne bouge pas — 744 avant, 744 après. La métrique a cessé de mélanger ; c’est cela qu’on cherchait, pas un meilleur chiffre.
La rectification erronée est conservée ci-dessous plutôt qu’effacée : elle dit comment on se trompe, et la trace vaut mieux que la propreté.
Rectification du 2026-08-22 (ERRONÉE — voir ci-dessus) — cette décision prédisait une baisse ; la mesure dit l’inverse. Le raisonnement d’origine — « le code de test est mieux couvert que la production, puisqu’il s’exécute par définition » — est faux. Mesuré sur le workspace, branches dédupliquées par emplacement source :
branches non couvertes couverture Production 9 482 2 060 78,27 % Test inline 668 321 51,95 % Le code de test est le moins bien couvert des deux — c’est l’effet des conditions composées dans les assertions (§ Pourquoi c’est pire qu’un biais) : un
assert!(a && b)crée des branches qu’aucun test ne cherche à couvrir. Après migration, la référence montera d’environ un point, et les branches non couvertes affichées passeront de ~2 381 à ~2 060, soit −13,5 %.Ces 2 060 branches sont la vraie dette, et elle est concentrée : sept des dix fichiers les plus troués sont dans
air-sshd, dont six relèvent du forwarding. La migration ne les fera pas disparaître — elle les rendra visibles.
Cette baisse ne se traitera pas comme une régression et ne se rattrapera pas par des tests : c’est le même produit, mesuré honnêtement. La référence sera reposée une fois, en CI, dans les conditions d’[ADR-031], avec la mention explicite qu’elle succède à une mesure de périmètre différent.
D6 — Le détecteur heuristique de régions de test devient superflu — mais ne se retire qu’après
couverture_production.rs porte regions_test() / dans_le_test() : un scanner de lignes
qui cherche #[cfg(test)] puis un mod, et qui sert à séparer « lignes non couvertes (prod) » de
« (test-resident) ».
Il a déjà échoué en production : le 2026-08-22 sur air-device, un #[cfg(test)] séparé de
son mod par dix lignes de documentation — le scanner abandonne après trois lignes — a fait
compter 184 lignes de harnais uinput comme du code de production.
Une fois D1 tenue, ce détecteur n’a plus d’objet : le nom du fichier suffit. Il reste en place jusqu’à la fin de la migration, parce que le retirer avant laisserait la métrique de lignes sans aucune séparation.
Ce que cet ADR ne décide pas
- L’ordre et le calendrier de la migration. 57 798 lignes touchant des crates scellées (couches 0 et 1) : chaque crate se traite pour elle-même, et l’effet du déplacement sur les sceaux se vérifie crate par crate avant de commencer. Ce document ne préjuge pas du résultat de cette vérification.
- Les doc-tests, qui vivent dans un autre target et ne sont pas concernés.
- La cadence de confrontation de la référence — le défaut, distinct, que
couverture-reference.tomldécrit lui-même à deux reprises (2026-08-09 et 2026-08-19) et qui reste ouvert.
Conséquences
- 234 fichiers à migrer, 253 modules, 57 798 lignes. Concentration mesurée :
air-sys-syscall30 fichiers / 16 256 lignes,air-sshd14 / 3 874,air-sys-types15 / 3 084,air-ssh-proto12 / 2 790,air-base-capi1 / 2 738. - Un gate de plus (
check-tests-isoles), et un de moins à terme (le détecteur heuristique de D6). - La métrique de branches devient du code de production seulement, et le devient de façon vérifiable — ce qu’elle n’est aujourd’hui ni l’un ni l’autre.
- Le filtre par défaut de
cargo-llvm-covcesse d’être une dépendance implicite : Air s’aligne volontairement dessus, et le documente ici.
Alternatives rejetées
- Étendre le détecteur de régions aux branches. Techniquement faisable — le JSON de
llvm-covdonne la ligne de chaque branche, etdans_le_test()existe déjà. Rejetée : ce détecteur est une heuristique de lignes qui s’est déjà trompée (D6). Bâtir la métrique de branches dessus lui prêterait une garantie qu’elle n’a pas — le défaut même que le NO GO n°1 vise. - Ajouter des motifs à
COV_IGNORE_BRANCHES. Sans effet : ce filtre porte sur des noms de fichiers, et un module inline partage son fichier avec du code de production. - Accepter le mélange et baisser la référence. Laisse un cliquet qui pénalise l’écriture de tests, et un chiffre dont personne ne peut dire ce qu’il mesure.
- Interdire les conditions composées dans les tests. Traiterait le symptôme en dégradant les tests, pour protéger une mesure qui n’aurait pas dû les regarder.
Licence du document : MPL 2.0