Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 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 testsCompté dans la métrique ?Volume au 2026-08-22
Fichier séparé (tests.rs, *_tests.rs)non169 fichiers, 88 317 lignes
Répertoire crates/<c>/tests/non27 crates
#[cfg(test)] mod tests { … } inlineoui234 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-covCe 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 tests va 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_LINES nomme — 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.rs428 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.rs sous src/ qu’aucun mod ne 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.

  1. #[path = "…"] était annoncé « honoré » dans l’en-tête du module alors que la fonction rendait toujours « pas un path » — le NO GO n°2 commis par l’outil qui l’applique.
  2. 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 de lib.rs, où les deux coïncident : il ne pouvait pas voir la faute. air-thread et air-ssh étaient accusés à tort.
  3. Le { d’un mod x { … } étant consommé par la reconnaissance, le compteur d’accolades ne le voyait jamais : deux modules inline successifs s’empilaient, et air-base-core/src/encoding/hex/tests.rs héritait du préfixe de base64.

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-couches de 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 :

branchesnon couvertescouverture
Production9 4822 06078,27 %
Test inline66832151,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.toml dé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-syscall 30 fichiers / 16 256 lignes, air-sshd 14 / 3 874, air-sys-types 15 / 3 084, air-ssh-proto 12 / 2 790, air-base-capi 1 / 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-cov cesse 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-cov donne la ligne de chaque branche, et dans_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