Tests et qualité¶
La chaîne qualité tourne à chaque push (CI) et localement via ./mvnw. Cette page est la
référence structurée ; le repo-root garde un mémo
TESTING.md.
Les commandes¶
| Commande | Effet |
|---|---|
./mvnw test |
Toute la suite de tests |
./mvnw test -Dtest=SitesViewModelTest |
Une seule classe de test |
./mvnw test -Dtest=SitesViewModelTest#charger_lit_par_lot |
Une seule méthode |
./mvnw verify |
Build complet : tests + couverture + contrôles (PMD/JaCoCo non bloquants) |
./mvnw -Pquality-gate verify |
Rend PMD bloquant sur toute violation. Ne passe pas sur ce dépôt : le portail se tient par le cliquet de l'ADR 4617, pas par failOnViolation. |
env -u DISPLAY ./mvnw -Pmutation test-compile org.pitest:pitest-maven:mutationCoverage -DtargetClasses=… -DtargetTests=… |
Tests de mutation PIT (lent, ciblé, à la demande). -Pmutation test ne mute rien : le profil n'a aucune liaison de phase, donc le goal n'est jamais invoqué - la commande lance toute la suite et rend zéro rapport, sans le dire. |
./mvnw -B test-compile pmd:pmd |
Le rapport PMD. Il ne juge pas : le verdict est le cliquet de l'ADR 4617. |
python3 scripts/adr/4617-code-mort-et-zone-de-test.py |
Le verdict du portail, par zone. Refuse si le rapport manque. |
./mvnw spotless:check / spotless:apply |
Vérifie / applique le formatage |
./mvnw javafx:run |
Lance l'application |
Quand lancer clean ?
Le build Maven est incrémental : ./mvnw verify réutilise target/. Après certains
changements, une classe périmée peut y subsister et provoquer une erreur trompeuse (à la
compilation ou au packaging) que ./mvnw clean verify fait disparaître. Réflexe : en cas
d'erreur inexpliquée alors que le code semble correct, relancer avec clean. Cas typiques :
- suppression ou renommage d'une classe/méthode : l'ancien
.classreste danstarget/; - changement de dépendances (
pom.xml).
La CI part toujours d'un checkout propre : ce piège est purement local. Le cas du
packaging est réglé depuis #1188 : le fat-jar est attaché sous le classifier shaded, le shade
ne re-traite plus sa propre sortie (packaging idempotent, garde-fou en CI).
IHM testée en headless (sans X11 ni xvfb)¶
Les tests TestFX tournent en mémoire grâce à la Headless Platform de JavaFX : aucun xvfb,
aucun display. La config vit dans le bloc Surefire du pom.xml, rien à régler :
<glass.platform>Headless</glass.platform> <!-- plateforme Glass logicielle -->
<prism.order>sw</prism.order> <!-- rendu logiciel -->
<java.awt.headless>true</java.awt.headless>
<testfx.robot>glass</testfx.robot> <!-- robot piloté par Glass -->
On ne met pas testfx.headless=true : ce flag réactiverait l'ancien bootstrap Monocle. Le
headless vient de glass.platform=Headless, pas de TestFX. Le argLine ajoute les --add-opens /
--add-exports requis par JavaFX (accès aux internes com.sun.javafx.* pour TestFX) + l'agent JaCoCo
+ l'agent Mockito (voir ci-dessous).
Préparation à Java 26+
Sous Java 25 tout passe, mais deux signaux annoncent la bascule 26 :
- Agent Mockito explicite (fait) : Mockito 5 s'auto-attachait dynamiquement (« A Java agent
has been loaded dynamically… will be disallowed by default in a future release »). On passe
désormais
byte-buddy-agenten-javaagentviamaven-dependency-plugin:properties(${net.bytebuddy:byte-buddy-agent:jar}dans l'argLine) : plus d'auto-attachement, prêt pour le JDK 26. -
sun.misc.Unsafe(à surveiller) : le warning ne vient pas du code du projet, mais il ne vient pas non plus de Guava. Mesuré le 2026-08-06 (#2747), sur JDK 25.0.3, en lançantAppTest:WARNING: sun.misc.Unsafe::staticFieldBase has been called by com.google.inject.internal.aop.HiddenClassDefiner (guice-7.0.0.jar) WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future releaseC'est Guice 7.0.0, dans la génération de proxys AOP. La version précédente de cette page accusait Guava : c'est cette erreur qui a fait attendre une montée de Guava, laquelle a bien eu lieu (33.4.8-jre, #2740) sans rien changer - elle ne pouvait pas.
Ce n'est pas « rien à faire pour l'instant », c'est une échéance. La JEP 498 retirera l'accès, et
--sun-misc-unsafe-memory-access=allow(posé aupom.xmlpourjavafx:runet le lanceur jpackage, et au manifeste Flatpak) ne fait que la repousser. Le jour où le drapeau disparaît, l'application ne démarre plus.Ce qui la lèverait : une version de Guice qui cesse d'utiliser
Unsafe. 7.0.0 est la dernière publiée ; Dependabot suit la dépendance et proposera la montée. Le drapeau se retire le jour où ce warning cesse d'apparaître, et pas avant : le vérifier se fait en une commande,./mvnw test -Dtest=AppTestpuis chercher « sun.misc.Unsafe » dans la sortie.
Lancer les tests avec le bon JDK
Utilisez un JDK 25 standard (25.0.2-open / Temurin), pas un JDK packagé FX (fx-zulu) :
ce dernier embarque JavaFX 25, masque les jars Maven FX 26 et fait échouer le headless
(NPE com.sun.glass.ui.PlatformFactory.getPlatformFactory() : la Headless Platform n'existe qu'en
FX 26). Comme la CI :
L'écran headless est figé à 1000×1000¶
La Headless Platform de JavaFX 26 rend dans un écran codé en dur à 1000×1000 px
(HeadlessApplication.staticScreen_getScreens, avec le stride de HeadlessWindow) : aucune
propriété ni variable d'environnement ne le change, et le framebuffer est un ByteBuffer de
1000*1000*4 octets alloué une fois pour toutes.
Conséquence : une fenêtre qui, une fois affichée, dépasse 1000 px - typiquement une modale qui
grandit quand un bandeau se révèle et que sizeToScene la redimensionne - fait déborder le rendu :
java.lang.IndexOutOfBoundsException
at com.sun.glass.ui.headless.HeadlessWindow.blit(HeadlessWindow.java:333)
at javafx.stage.Window.sizeToScene(...)
C'est un artefact du test, pas un défaut de production : un vrai écran (≥ 1000 px) et un vrai gestionnaire de fenêtres n'ont pas ce framebuffer figé.
La bonne réponse est de faire tenir la fenêtre sous 1000 px, ce qui corrige du même coup le vrai bug côté utilisateur (une fenêtre trop grande déborde aussi les petits portables). C'est ce qu'a fait
2496 pour RattachementModale : corps dans un ScrollPane, pied épinglé, la fenêtre reste bornée¶
(cf. ADR 2493). À privilégier systématiquement.
Dernier recours : agrandir le framebuffer par réflexion
Pour un écran vraiment irréductible - un test dont la fenêtre ne peut pas descendre sous
1000 px sans dénaturer ce qu'il vérifie - on peut agrandir le seul nombre de lignes du
framebuffer au bootstrap du test (le stride reste à 1000, on ajoute des lignes). En test, JavaFX
est chargé dans le module sans nom (classpath, useModulePath=false), donc la réflexion
atteint le champ privé sans --add-opens :
/// Agrandit le framebuffer de la Headless Platform (lignes seulement, stride inchangé).
/// À appeler sur le fil JavaFX, après le démarrage du toolkit et avant tout rendu.
/// DERNIER RECOURS : couple le test aux internes de glass. À éviter si la fenêtre peut être bornée.
static void agrandirEcranHeadless(int lignes) throws ReflectiveOperationException {
Object app = Class.forName("com.sun.glass.ui.Application")
.getMethod("GetApplication").invoke(null);
if (app == null) {
return;
}
java.lang.reflect.Field champ = app.getClass().getDeclaredField("frameBuffer");
champ.setAccessible(true);
champ.set(app, java.nio.ByteBuffer.allocate(1000 * lignes * 4));
}
Validé pendant #2496 : sur RattachementModale câblée, agrandirEcranHeadless(3000) rend ses
12 tests verts au lieu du blit qui débordait. Coûts : ~1000 * lignes * 4 octets par fork ;
dépendance à un champ privé (frameBuffer) et à un nom de méthode (GetApplication) qui peuvent
changer d'une version de JavaFX à l'autre. D'où « dernier recours » : préférer borner la
fenêtre.
Les butoirs TestFX sont des coupe-circuits, pas des budgets¶
FxToolkit borne deux attentes : le démarrage du toolkit JavaFX (testfx.launch.timeout) et la mise
en place d'un test, c'est-à-dire l'exécution de la méthode @Start par
ApplicationExtension.beforeEach (testfx.setup.timeout). Leur seul rôle est d'empêcher un fil FX
bloqué de figer le build indéfiniment. Ils ne mesurent rien : dépasser un butoir ne dit pas que le
code est lent, seulement que la machine n'a pas rendu la main à temps.
Les deux valeurs sont posées dans le pom.xml à 120 s, et non laissées aux défauts de TestFX
(30 s et 60 s) :
<testfx.setup.timeout>120000</testfx.setup.timeout>
<testfx.launch.timeout>120000</testfx.launch.timeout>
Ce n'est pas un confort. Les défauts de TestFX supposent une JVM seule sur sa machine, alors que la
suite lance une JVM par coeur (surefire.forkCount=1C) sur un runner partagé. Les deux décisions
avaient été prises séparément, et le calcul ne tombait pas juste (#2120) :
| Grandeur | Mesure |
|---|---|
| Mise en place la plus lente de la suite, 4 coeurs / 4 forks, machine au repos | 6,9 s (SonsValidationViewTest#basculer_reference) |
| Marge sous l'ancien butoir de 30 s | 4,3x |
Durée du job build sur 27 runs CI consécutifs |
de 370 s à 2260 s, soit 5,4x |
La marge était plus petite que la variation de la machine. Le butoir vivait donc dans le bruit, et expirait au hasard des runs. À 120 s, la marge est de 17x sur le nominal, soit 3x au-delà de la pire dégradation observée, et un vrai interblocage est toujours coupé en deux minutes.
Un » Timeout en CI ne se lit pas comme un échec de test
Surefire l'affiche sous cette forme, sans distinguer une assertion fausse d'une attente expirée :
La trace le tranche en trois lignes : ApplicationExtension.beforeEach puis
FxToolkit.setupApplication puis WaitForAsyncUtils.waitFor signifient que le test n'a jamais
commencé. Le premier réflexe est alors de regarder la durée du job, pas le diff : si le build
a mis trois fois son temps habituel, c'est le runner qu'on observe, pas le code.
Le piège est ailleurs : un rouge intermittent qu'on prend l'habitude d'écarter finit par couvrir celui qui compte. C'est ce précédent, et non le temps perdu, qui a motivé #2120.
ButoirsTestFxTest vérifie que ces deux valeurs atteignent bien la JVM forkée. Elles passent par
systemPropertyVariables : posées sur la JVM de Maven, elles n'auraient aucun effet, et TestFX
retomberait en silence sur ses défauts.
La taxonomie des tests¶
Les tests vivent sous src/test/java/fr/univ_amu/iut/, en miroir des paquets de production.
| Catégorie | Emplacement | Vérifie |
|---|---|---|
| Unitaires métier | <feature>/model/, <feature>/dao/, commun/persistence/, commun/model/ |
Entités, services, DAO, migrations. Sans JavaFX. |
| ViewModel | <feature>/viewmodel/ |
État observable + logique de présentation, sans composant graphique. |
| Intégration de vue (TestFX) | <feature>/view/*VueIntegrationTest |
La vue FXML se lie au ViewModel et réagit (headless). |
| Geste (TestFX) | <feature>/view/*ViewTest |
Le bouton est cliqué, et on vérifie son effet (#1405). |
| Bout en bout | fr.univ_amu.iut.e2e.*, <feature>/e2e/Parcours*E2ETest |
Le scénario complet : IHM → ViewModel → service → base. |
| CLI shell (bats) | src/test/bats/*.bats (fixtures partagées helper.bash) |
La CLI empaquetée, au niveau processus, telle qu'un utilisateur l'appelle : depuis le lanceur bin/vigiechiro de l'app-image (#4071), et non plus par java -cp sur le fat-jar : arguments picocli, texte d'aide, codes de sortie, refus métier, ce que les tests Java in-process ne voient pas. cli.bats éprouve les commandes du chantier #1565 ; cli-surface.bats couvre le contrat hors-ligne de chaque sous-commande (aide, refus des options requises manquantes, exécution locale, refus sans jeton) ; cli-reseau.bats pointe le client sur un serveur stub (processus Python stub_vigiechiro.py) via la surcharge VIGIECHIRO_URL (ConnexionModule#urlDeBase) et prouve le chemin réseau sans jeton réel ni Internet. Reste à étoffer : contrats métier réseau sur fixtures Eve réalistes (#1592). Lancés en CI après le garde-fou app-image, dont ils utilisent le lanceur (#1572 pour l'amorce, #4071 pour la bascule). Un seul cas ne peut s'y éprouver et le DIT en sautant : celui qui abaisse une borne par propriété JVM, que le lanceur n'accepte pas (#4075). |
| Architecture (ArchUnit) | architecture/ArchitectureTest |
Les 6 règles de frontière MVVM (cf. Architecture). |
| Documentation | documentation/DocumentationAJourTest |
Toute commande CLI a sa ligne, tout écran a sa fiche (#1458). |
| Recette (traçabilité) | recette/ |
Le lien entre les scripts de session et le code qui les couvre : CorrespondanceRecetteTest confronte les deux, RepartitionDesCas trie les cas en trois bacs, CasDeRecette porte la citation (#3667). |
| Scénario perceptif | <feature>/view/Scenario*Test |
Joue un geste sans l'asserter : le verdict revient à qui regarde le clip (jugement = HUMAIN). Il asserte seulement qu'il a joué. |
Outils : JUnit 5 + AssertJ + Mockito ; ApprovalTests pour les sorties verbatim (CSV Tadarida
_Vu : le premier run produit un *.received, à approuver en *.approved).
Ce qu'une assertion ne voit pas : le harnais de recette filmée (#3667)¶
Certains cas de recette ne se tranchent pas par une assertion : « la modale s'ouvre sans saut », « rien ne se redimensionne pendant la récupération ». Une assertion voit un contenu correct une fois posé, jamais le chemin pour y arriver.
Le harnais leur donne un dispositif, dont la règle centrale est de ne jamais compter un cas joué comme un cas prouvé.
| Pièce | Rôle |
|---|---|
CasDeRecette |
un test déclare le ou les cas qu'il couvre, et ce qu'il prétend prouver (Jugement.AUTOMATIQUE par défaut, HUMAIN pour un scénario qui ne fait que jouer) |
FixtureDeRecette |
marque les classes d'exemple qui citent un cas sans rien couvrir - sans elle, elles gonfleraient l'index |
RepartitionDesCas |
trie en asserté / perceptif / non couvert, et signale les désaccords entre le script et le code |
ReperesDeSeance + JournalDesReperes |
pendant une séance filmée, consignent l'instant de chaque test (currentTimeMillis, jamais nanoTime) |
Seance |
dit à un scénario s'il est filmé, pour qu'il ne prenne ses respirations que là |
Attente |
attend qu'une condition devienne vraie, et dit ce qu'elle attendait en expirant. queSurLeFil pour un prédicat qui touche la scène. À ne pas confondre avec Respiration, qui ne s'arrête que si l'on filme et ne tient donc rien en CI (#4694) |
Deux bancs tournent ces clips, et ils ne se ressemblent pas.
Le banc historique filme l'écran : ffmpeg -f x11grab sur un Xvfb, avec openbox et xdotool.
Son montage et son index par cas vivent donc côté script
(CI/CD), et il ne tourne que
sous Linux.
Le banc en Java pur filme la scène : Scene.snapshot poussé dans ffmpeg, depuis la JVM du test.
Il tourne sous Linux, Windows et macOS, sans serveur X ni gestionnaire de fenêtres, et il porte donc
son montage et son index en Java :
Pièce (recette/film/) |
Rôle |
|---|---|
CameraDeScene |
prend la scène à intervalle régulier et compose les fenêtres sur une toile unique |
EnregistreurDeFilm |
extension JUnit : ouvre l'encodeur autour d'un test, un fichier par test |
Encodeur |
pousse les images brutes dans ffmpeg, et sait dire ce qui manque quand il est absent |
IndexDesCas |
l'index par cas, écrit en fragments par JVM et reconstruit sous verrou (ADR 4249) |
CartonDeTitre |
le carton d'ouverture : le cas, son libellé, le test qui le joue |
Gestes + CalqueDesGestes |
observent les événements et dessinent le halo, la flèche et le badge (ADR 4248) |
PoliceDuBanc |
la typographie du produit, chargée dans AWT depuis le jar |
Lequel des deux tourne n'est pas encore tranché. Le comparatif des deux bancs est dans Comparer les deux bancs, et cette page-là est datée : elle mourra avec la décision.
Ce qui lui survivra, c'est Comparer deux tournages : depuis que chaque version porte les clips des deux bancs sur son tag, la comparaison se refait à la demande sur le produit du jour, au lieu d'être figée dans une page.
Trois états, pas deux. Un cas « perceptif » n'est ni couvert ni à couvrir : le script le
marque *perceptif*, et le mot « couvert » reste réservé à ce que la CI prouve. Compter un scénario
joué parmi les couverts fabriquerait exactement le vert creux que ce harnais combat.
Un calcul alimenté par le classpath ne se voit pas rougir. CorrespondanceRecetteTest balaie
les annotations compilées : on ne peut lui présenter aucun jeu fabriqué, donc rien ne prouve qu'il
range un cas du bon côté. Le tri vit pour cette raison dans RepartitionDesCas, qui reçoit des
ensembles ordinaires - et ses tests lui montrent les situations que le dépôt ne contient pas, et ne
contiendra jamais volontairement. La règle se généralise : quand un garde tire sa matière d'un
balayage (classpath, disque, dépôt), en sortir la décision, sans quoi son verdict est indémontrable.
Tester un geste, pas un bouton¶
Un test qui vérifie qu'un bouton est présent et actif ne dit rien de ce qu'il fait. C'était
pourtant tout ce qu'on avait sur les actions irréversibles - restaurer la base, supprimer un
passage et sa nuit, réimporter par-dessus les validations de l'observateur. Et pas par négligence : un showAndWait() fige un test headless, donc le clic
était impossible.
Les dialogues d'une action sont désormais des ports remplaçables (Confirmateur, Notificateur,
SelecteurFichier, DemandeurDeChoix : cf.
Patrons).
Un test de geste les remplace par des doubles, déclenche l'action, et vérifie ce qui s'est
passé :
controleur.confirmateur().definir(message -> { confirmations.add(message); return confirme; });
controleur.notificateur().definir((niveau, entete, message) -> annonces.add(entete));
robot.interact(() -> robot.lookup("#boutonSupprimer").queryButton().fire());
assertThat(sitesEnBase()).isEmpty(); // l'effet, pas « un mock a été appelé »
Trois exigences, dans l'ordre d'importance :
- Le refus. Sur une action irréversible, « Annuler annule vraiment » est le test qui compte le plus - et c'est celui qui manquait partout.
- L'effet réel. Quand la fixture le permet (vrai injecteur + vraie base), asserter que la ligne a disparu de la base, pas qu'un mock a reçu un appel.
- Le message de confirmation est un contenu. Sur une suppression en cascade, c'est le seul avertissement que l'utilisateur recevra : vérifier qu'il annonce le gain, ce qui est conservé, et ce qui est définitivement perdu.
- Renoncer n'est pas abandonner. Quand un dialogue offre plusieurs issues, l'une d'elles détruit souvent quelque chose et une autre ne fait rien. Les deux ferment le dialogue. Un test doit les distinguer - c'est le piège le plus coûteux de tout ce chantier.
Ce qu'aucun test ne verra
Trois défauts d'IHM de #1431 n'ont été trouvés qu'en regardant une capture : un libellé tronqué, un emoji qui ne se rend pas (#700), et une réplique de dialogue qui avait dérivé du vrai écran. Un geste testé n'est pas un écran regardé : rendez la capture, et ouvrez-la.
Semer une nuit : JeuDeDonneesPassage¶
Le schéma est profond : une observation référence une sequence, qui référence une
recording_session, qui référence un passage, qui référence un point, un site, un recorder et un
user. Écrire un test sur une observation obligeait donc à connaître sept tables - et
soixante-quinze fichiers de test resemaient cette même chaîne à la main, en trois styles SQL
différents (#1258).
Ce n'était pas de la rigueur, c'était du bruit : le test parlait de la plomberie au lieu de parler de ce qu'il vérifie. Et chaque migration de schéma coûtait autant de retouches que de copies.
JeuDeDonneesPassage jeu = JeuDeDonneesPassage.dans(source)
.carre("130711")
.point("Z41")
.semer();
long douteuse = jeu.ajouterObservation("Pipkuh");
long validee = jeu.ajouterObservationValidee("Nyclei");
long corrigee = jeu.ajouterObservationCorrigee("Pipkuh", "Pippip");
Valeurs par défaut : utilisateur u-1, carré 640380, point A1, enregistreur SN-1 - celles que les
tests utilisaient déjà. Tout se surcharge avant semer().
Migration opportuniste, pas de big bang : on bascule un fichier quand on le retouche. Les trois styles SQL et les jeux de colonnes variables rendent une conversion mécanique risquée, et un test converti trop vite est un test qu'on ne relit plus.
Un cliquet empêche de l'oublier
Une migration opportuniste sans garde-fou est une migration qu'on oublie - le même défaut que la
doc, qui dérivait parce que rien ne rougissait. CliquetFixturePassageTest épingle la liste des
tests qui sèment encore un passage à la main (le compte de référence vit dans le test lui-même,
pas ici, pour ne pas diverger), et elle ne peut que rétrécir :
- ajouter un semeur de plus → CI rouge (c'est le cas qui compte : sans lui, la dette repousserait aussi vite qu'on la coupe) ;
- en migrer un → CI rouge aussi, jusqu'à ce qu'on retire son nom de la liste. Le geste est trivial, et c'est ce qui rend le progrès visible.
Le compteur restant est donc toujours exact, sans que personne ait à s'en souvenir.
Deux limites, assumées :
- la fixture ne migre pas le schéma (les tests ne l'obtiennent pas tous de la même façon) et ne sème
aucun taxon : le référentiel réel est déjà posé par
V02__seed_taxons.sql, et réinsérerPipkuhviole la clé primaire ; - les outils de capture (
src/main/.../outils) restent autonomes : ce sont des exécutables indépendants, la fixture de test ne leur est pas accessible, on accepte leur duplication.
La documentation est tenue par un test¶
Une doc qui ment est pire qu'une doc absente : on la croit. Le dépôt l'avait déjà tranché pour les
captures, défendues par quatre garde-fous (cf. Captures). Les commandes et les
écrans, eux, n'avaient rien - et ils ont dérivé, en silence : dev-docs/cli.md a documenté jusqu'à
22 commandes sur 29, et l'écran « Audit de cohérence » a vécu sans aucune fiche de sa livraison
(#1133) à la clôture de l'EPIC #1154. Aucune CI n'a rougi. Une relecture à la main les a trouvés.
DocumentationAJourTest comble l'asymétrie, en confrontant la doc non pas à une liste tenue à la main
(c'est exactement ce qui dérive) mais à la vérité du câblage :
| Ce qui est confronté | À quoi | Ce que ça empêche |
|---|---|---|
Les sous-commandes de l'annotation @Command de CommandeRacine |
Le tableau de dev-docs/cli.md |
Une commande livrée, testée, verte en CI… et introuvable dans sa propre doc |
Les ActiviteAccueil liées dans l'injecteur |
La fiche docs/ecrans/<pageDoc>.md |
Un écran entier offert à l'utilisateur, sans page |
| Les fiches présentes sur le disque | La nav de mkdocs.yml et le tableau de docs/ecrans/index.md |
Une page que le site ne publie pas, ou qu'on ne peut atteindre depuis l'index de sa section |
Les chiffres balisés <!--inv:clé-->N<!--/inv--> (#2385) |
L'inventaire réel : le code le plus souvent (contrats Ouvrir*, états de StatutWorkflow, features, sous-commandes, catalogues de critères de filtre), ou le document qui fait autorité sur sa population quand le code ne sait rien (ADR 5169) |
Un décompte figé dans la prose qui dérive après un ajout : « 43 sous-commandes » quand le code en câble 44 |
Depuis #4336, les confrontations qui portent sur les ADR lisent leur en-tête OKF et non plus des
puces : article:, verification:, enforced_by: ou loupe:, chantier:. Une confrontation s'y
est ajoutée, et elle vaut d'être dite parce qu'elle a servi deux fois dans la semaine de sa pose :
| Ce qui est confronté | À quoi | Ce que ça empêche |
|---|---|---|
Le champ title: de l'en-tête d'une ADR |
Le titre de page de la même ADR | Deux titres pour une seule décision : le premier alimente le site et les matrices, le second la lecture |
Et un avertissement, payé une fois. Ce test avait cessé de vérifier quoi que ce soit après la
conversion des en-têtes : son motif ne trouvait plus rien, et son continue sur absence de
déclaration avalait les 194 ADR. Il passait au vert. Un garde qui cesse de détecter ne rougit
pas, il se tait ; c'est pourquoi ses confrontations sont éprouvées et non supposées.
Deux détails qui comptent :
- Les commandes sont lues sur l'annotation, par réflexion - jamais instanciées. Leurs constructeurs
tirent des
Providerqui ouvrent la base : les instancier ferait de l'E/S pour rien. ActiviteAccueil.pageDoc()est une méthode du contrat, pas une convention. Le nom de la fiche ne se déduit ni du titre (« Sons de référence » se documente dansvalidation.md) ni du paquet (la featureaudioaussi) : il faut le dire. Le compilateur force donc à choisir une fiche, et le test refuse qu'elle soit absente.
Ancrer un chiffre : les balises d'inventaire¶
Certains nombres de la doc décrivent le code : le nombre de contrats Ouvrir*, d'états du workflow,
de features, de sous-commandes CLI. Écrits en dur, ils dérivent au premier ajout (un contrat de plus,
un état de plus) sans que rien ne rougisse. Une balise d'inventaire les ancre à un décompte que le
test recalcule. On écrit le nombre entre deux commentaires :
Règle de rédaction : un chiffre d'inventaire ne s'écrit pas en dur
Si le code sait recalculer un chiffre, la documentation le porte en balise. Sinon, elle ne l'écrit pas du tout.
La raison n'est pas l'élégance, c'est une mesure : « les 21 tests bats » vivait à trois endroits quand il y en avait 89 - un facteur quatre, accumulé sans que rien ne le dise (#2749). Et personne n'avait mal fait : un chiffre juste le jour où on l'écrit devient faux tout seul.
Trois, et non deux : la correction n'en avait trouvé que deux, et le troisième a survécu une journée de plus, dans un encadré du même fichier. Un chiffre faux a des jumeaux, et le balayage qui corrige n'est pas celui qui compte - c'est l'audit d'harmonisation de la clôture qui a rendu le troisième.
Quatre cas, quatre gestes :
- le code sait compter → balise
<!--inv:clé-->N<!--/inv-->, plus une entrée dansDocumentationAJourTest; - le code ne sait pas, mais un document du dépôt fait autorité sur cette population → même
balise, adossée à ce document, et le document dit alors ce que le chiffre compte. C'est le cas
de
REMERCIEMENTS.md, dont la liste nominative est la seule source qui existe sur le nombre de contributeurs et d'équipes (ADR 5169). Le garde tient une cohérence interne : il attrape la dérive de l'annonce, jamais l'oubli dans la liste ; - le code ne sait pas, et rien d'autre ne fait autorité (un commentaire de workflow, une note de PR) → écrire la phrase sans le nombre. « les tests bats, qui lancent chacun un JVM » dit ce qu'il faut sans rien promettre ;
- le chiffre est une mesure datée (« 66 aperçus sur 138 différaient le 6 août ») → il reste en dur, et c'est juste : ce n'est pas un inventaire, c'est un constat, et un constat a une date.
Le troisième cas se confond avec le premier au premier coup d'œil. Un balayage a compté « 51, 66, 108 aperçus » comme des inventaires divergents : c'étaient trois deltas d'ADR, et le total, 138, était juste partout. Lire la phrase entière avant de conclure à une dérive.
où N est le chiffre (15 aujourd'hui). Un commentaire HTML ne s'affiche pas : la phrase se lit
« 15 features » comme avant, mais chaque_chiffre_balise_egale_l_inventaire_reel relit N et le
confronte au code. Une divergence fait rougir la CI, le message portant le vrai chiffre. Clés
reconnues :
| Clé | Décompte réel |
|---|---|
ouvrir |
fichiers commun/view/Ouvrir*.java |
etats-workflow |
valeurs de l'enum StatutWorkflow |
features |
dossiers de fr.univ_amu.iut hors commun, cli, perf |
cli |
sous-commandes câblées dans CommandeRacine |
workflows-ci |
fichiers .yml de .github/workflows |
migrations |
fichiers V*.sql de db/migration |
criteres-validation |
fabriques CritereFiltre de CriteresAudio |
criteres-analyse |
fabriques CritereFiltre de CriteresAnalyse |
criteres-activite |
fabriques CritereFiltre de CriteresActivite |
criteres-multisite |
fabriques CritereFiltre de CriteresMultisite |
criteres-audit |
fabriques CritereFiltre de CriteresAudit |
Les cinq clés criteres-* (#3105) comptent les fabriques par nom distinct : plusieurs catalogues
offrent des surcharges du même critère (groupe, heure), qui restent une seule puce à l'écran.
La réflexion s'arrête au type de retour, sans rien invoquer : pas de toolkit JavaFX, pas de données.
Ces cinq-là existent parce que la dérive s'était déjà produite : « Douteux » et « Non identifiés »
ont vécu deux paliers sans figurer dans validation.md, et un commentaire de FiltresVuesAudio
annonçait deux critères là où le code en câblait dix. Un décompte réécrit en prose finit toujours par
mentir - la règle est donc de nommer la source dans un commentaire, et d'ancrer le nombre ici.
Poser une balise sur une clé non listée échoue aussi : on ajoute d'abord la clé et son décompte au
test (une clé = un fait que le code sait recalculer). Et le test exige qu'au moins une balise subsiste
par clé, pour qu'un inventaire ne perde pas discrètement son ancre. Enfin, aucune_commande_documentee_n_a_disparu_de_la_cli
fait le trajet inverse du tableau CLI : une commande décrite dans cli.md mais absente du câblage
(renommée, supprimée) fait rougir tout autant.
Les outils qualité¶
| Outil | Rôle | Bloquant ? |
|---|---|---|
| ArchUnit | Frontières MVVM + absence de cycles | Oui (tests) |
| Spotless (Palantir) | Format du code, via un hook pre-commit silencieux | Oui (spotless:check en CI) |
| PMD | Code smells | Bloquant sous -Pquality-gate |
| JaCoCo | Couverture | Seuils bloquants sous -Pquality-gate |
| PIT | Qualité des tests par mutation | Non (à la demande, -Pmutation) |
SonarQube for IDE (facultatif, à configurer)¶
L'extension SonarQube for IDE (ex-SonarLint) analyse à la frappe et complète utilement PMD :
elle voit des bugs et des fuites de ressources que le ruleset ne cherche pas. Mais PMD fait foi :
c'est lui qui bloque la CI. Or, laissée par défaut, l'extension applique le profil « Sonar way »
(542 règles Java) et contredit trois seuils délibérément arbitrés dans
pmd-ruleset.xml :
| Règle Sonar | Défaut | Ce que dit le ruleset PMD | Remontées sur src/main/java |
|---|---|---|---|
java:S107 (nb de paramètres) |
7 | ExcessiveParameterList 11 : les @Provides Guice agrègent leurs collaborateurs |
30 |
java:S3776 (complexité cognitive) |
15 | pendant de CyclomaticComplexity 24 : les parseurs écrits à la main montent à 27 |
9 |
java:S106 (sortie standard) |
actif | aucun équivalent : **/outils/** et la CLI picocli écrivent sur stdout |
28 fichiers |
Le réglage qui les réaligne ne peut pas être versionné : sonarlint.rules est de scope
application, donc VS Code le lit uniquement depuis les réglages utilisateur et ignore
silencieusement un bloc placé dans .vscode/settings.json. À recopier dans ses réglages personnels :
"sonarlint.rules": {
"java:S107": { "level": "on", "parameters": { "maximum": "11", "constructorMax": "11" } },
"java:S3776": { "level": "on", "parameters": { "Threshold": "30" } },
"java:S106": { "level": "off" }
}
Trois points à ne pas redécouvrir :
- La clé de
java:S3776prend une majuscule (Threshold). Écrite en minuscule, elle est ignorée sans le moindre message et le seuil reste à 15. java:S107ne visite ni les records : les 27 records à 8 composants ou plus, dontLigneObservationAudioet ses 30 composants, ne remontent pas. Inutile de relever le seuil pour eux.- On ne coupe que ces trois règles. Le reste de « Sonar way » est un complément, pas un doublon ; le désactiver en bloc reviendrait à ne garder que ce que PMD sait déjà faire.
Pour une configuration réellement partagée entre contributeurs, la seule voie serait le
connected mode (SonarQube Cloud, gratuit sur dépôt public) avec un profil qualité côté serveur.
Le coût est un second référentiel de règles à tenir en phase avec pmd-ruleset.xml ; tant que
l'écart tient en trois lignes, le bloc ci-dessus suffit.
Couverture et mutation¶
- JaCoCo : sous
-Pquality-gate, seuils bloquants au niveauBUNDLE. Leurs valeurs, la raison de chacune et la recette pour re-mesurer la couverture vivent dans lepom.xml, seule source : les répéter ici les ferait diverger au premier resserrage. Les**/outils/**(capture d'écran, bancs de mesure) sont exclus : ils sont validés par exécution, pas par tests unitaires. -
PIT (
-Pmutation) évalue si les tests détectent des mutations du code. Lent sur tout le dépôt, mais rapide ciblé sur la classe qu'on vient d'écrire - et c'est ainsi qu'il sert le mieux :
test-compile n'est pas une commodité, c'est ce qui fait démarrer le minion. PIT hérite de
l'argLine de Surefire, qui contient deux valeurs posées par des greffons liés à des phases :
@{jacocoArgLine} (jacoco:prepare-agent) et ${net.bytebuddy:byte-buddy-agent:jar}
(dependency:properties, phase initialize). Invoquer le but seul
(./mvnw -Pmutation org.pitest:...:mutationCoverage) n'exécute aucune phase : les deux restent
littérales, le minion reçoit -javaagent:${net.bytebuddy:byte-buddy-agent:jar}, refuse de démarrer, et
PIT ne rapporte que :
Ce message ne dit pas la cause, et il est identique quelle que soit la classe visée - y compris une classe pure sans JavaFX ni SQLite. D'où la conclusion tentante, et fausse, que « PIT ne marche pas sur ce dépôt ». Il marche : toujours enchaîner une phase, comme dans les exemples ci-dessus.
Sur une classe de vue (TestFX), rien de plus à faire. Le profil mutation du pom.xml passe déjà
les quatre propriétés headless au minion (glass.platform=Headless, prism.order=sw,
java.awt.headless=true, testfx.robot=glass) : sans elles, PIT lançait ses minions hors headless et
s'arrêtait sur « tests did not pass without mutation ». Ne pas les repasser à la main via
-DjvmArgs=… : la ligne de commande remplace la liste du profil au lieu de s'y ajouter, et une liste
incomplète ramène l'échec que le profil avait supprimé.
PIT couvre donc aussi la couche view, ce que l'échec brut laissait croire impossible.
Deux mesures tournent toutes seules, chaque nuit, et leurs périmètres ne se recouvrent pas.
Elles étaient hebdomadaire et mensuelle : les cycles complets prenaient 17 semaines et 15 mois. Quinze mois, pour les vues d'une feature, c'est plus long que le chantier qui y introduirait une régression. En quotidien les mêmes cycles prennent 17 et 15 jours, et le dépôt étant public, les minutes d'Actions sont illimitées sur les runners standard : la fréquence ne coûte que du temps machine.
Deux limites à garder en tête. La rotation est aveugle au diff : elle avance d'un paquet par jour quoi qu'on ait touché, donc elle mesure plus souvent, pas plus juste. Et un bilan quotidien que personne ne lit vaut moins qu'un bilan hebdomadaire qu'on lit - la fréquence ne crée pas l'attention. Les deux sont non bloquantes, comme le rapport ADR dont elles sont le calque : un survivant n'est pas un défaut mais une question posée à un humain, et bloquer une fusion là-dessus ferait cocher au hasard.
| Workflow | Quand | Ce qu'elle mute | Avec quels tests |
|---|---|---|---|
mutation-model.yml |
chaque nuit (3 h UTC), un paquet par tour (cycle de 17 jours) | fr.univ_amu.iut.<feature>.model.* |
tous, sauf e2e et commun.api |
mutation-ihm.yml |
chaque nuit (5 h UTC), une feature par tour (cycle de 15 jours) | fr.univ_amu.iut.<feature>.view.* |
ceux de la feature, sauf e2e |
Chacune publie son bilan dans le résumé du job ; le rapport HTML détaillé est conservé 30 jours en artefact.
Pourquoi les E2E sont exclus des deux. Un E2E est large en couverture et pauvre en jugement.
ParcoursDepotE2ETest couvre à lui seul 8 539 blocs : PIT le retient comme test candidat pour des mutants
situés dans des centaines de classes, et rejoue le parcours entier - des minutes de TestFX - pour
apprendre ce qu'un test unitaire dit en millisecondes. Le premier passage sur le dépôt entier a produit
19 expirations en une heure, toutes autour de ce parcours.
Les exclure ne cache rien, et c'est le point : un mutant que seul un E2E tuerait est, par définition, un mutant qu'aucun test unitaire ne détecte. Il ressort désormais en survivant, c'est-à-dire en question posée à un humain, au lieu d'être tué silencieusement.
Pourquoi le modèle aussi se mesure un paquet à la fois. Les paquets model d'un seul coup n'ont pas
fini : le job a été tué à 300 minutes pile, après 335 unités sur ~4 657 mutants. Ce n'est pas la
lenteur d'un test qui l'a tué, c'est le volume - 4 657 mutants à quelques secondes chacun font cinq
heures quelle que soit la finesse des tests. Un paquet seul, en revanche, tient sans peine :
saison.model a rendu 86 mutants et 97 % de détection en 12 minutes, sans expiration.
Le plus gros paquet tient, et confortablement. commun.model (96 classes) a été mesuré : 2 h 35
sur les 5 heures de budget, 746 mutants, 92 % de détection. L'extrapolation à partir de saison
donnait 4 h 45 - elle était pessimiste d'un facteur deux, parce que le coût se compte en mutants, pas
en classes : le périmètre entier en produisait 4 657, commun.model seulement 746.
Reste passage (93 classes) comme seul inconnu, et il devrait se comporter comme commun. La maille n'a
donc pas à descendre d'un cran.
À noter, parce que cela contre-indique une maille plus fine : le calcul de couverture est un coût fixe
payé à chaque tour, autour de 14 minutes quelle que soit la taille du paquet - 12 minutes sur les 12 de
saison, 14 sur les 155 de commun. Découper davantage ferait payer ce préambule plus souvent, pour un
rendement moindre.
Pourquoi commun.api est écarté côté tests. Nuance qui compte : ce n'est pas une classe mutée, c'est
la suite du réessai gradué (ADR 2354),
la plus lente du dépôt (5,5 s). Son sujet est l'attente : tout mutant qui fait boucler le réessai une
fois de plus dépasse le butoir de PIT (~11 s), et l'expiration y est un effet du sujet muté, pas un
défaut d'outillage. Les 16 expirations du run tué pointaient vers elle.
Pourquoi l'IHM se mesure une feature à la fois. Muter une vue coûte ~9 s par mutant : chaque mutant
rejoue des tests TestFX, qui démarrent un toolkit JavaFX. Les quelques centaines de classes de vue de l'application
demanderaient des dizaines d'heures, quand la borne d'un job est à 300 minutes. La mesure est donc
complète sur un cycle, pas sur un mois : la rotation se déduit du mois, sans état à écrire ni relire,
et le tour se fait en 15 mois. Elle vaut le détour - sur saison, 80 mutants, 36 % tués, 40 % de
survivants.
Ne pas allonger le butoir pour « laisser le temps » aux tests graphiques
C'est la correction qui vient à l'esprit devant une expiration, et elle rendrait le chiffre faux dans le sens rassurant : PIT compte une expiration comme une détection. Vérifié en rendant le butoir absurde sur une classe de vue - 21 mutants, 21 expirations, score annoncé 100 %, là où la mesure honnête donne 43 %.
Le butoir par défaut (4 s + 1,25 × durée normale) suffit une fois les E2E écartés : sur le
périmètre des vues, 1 expiration sur 280 mutants. C'est le périmètre qu'il fallait corriger, pas
le butoir (#2768).
PIT n'a plus d'analyse incrémentale. Depuis la version 1.25.x, le stockage de l'historique est un
greffon commercial (arcmutate). Les options historyInputFile/historyOutputFile figurent toujours
au descripteur du greffon Maven, mais les passer sans lui ne les fait pas ignorer : PIT refuse de
démarrer. Le descripteur dit que l'option existe, pas que la fonction est là (#2768).
Le même bilan se lit en local, sur n'importe quel rapport :
Il trie les classes par nombre de survivants plutôt que d'afficher un pourcentage : le score situe, la liste travaille.
Lire le rapport, pas le résumé. target/pit-reports/mutations.xml écrit ses attributs en
apostrophes simples (status='SURVIVED'). Un filtre écrit en guillemets doubles ne matche rien et
annonce « 0 survivant » sur n'importe quel rapport - y compris sur une classe dont sept mutants
survivaient. Le résumé imprimé en fin de course (Generated N Killed M) est la référence à recouper.
Rapport HTML dans `target/pit-reports/`. Un **mutant survivant** désigne une ligne que rien ne
vérifie vraiment.
Un garde-fou de non-régression se vérifie en le voyant rouge¶
Un test écrit pour empêcher un défaut de revenir ne vaut que si l'on a constaté qu'il échoue quand le défaut est là. Les suites de l'EPIC #1863 ont produit quatre contre-exemples en une seule session, tous verts et tous creux :
- un test d'alias CLI qui passait avec et sans l'alias (
--helpsur une commande inconnue déclenche l'aide de la racine, qui liste justement la commande cherchée) ; - une sonde live dont la remise en état allait échouer en silence ;
- une garde « n'écrire que si la saisie a changé », posée pour corriger un défaut constaté, que rien n'avait jamais verrouillée ;
- un test de boucle d'horodatage qui refaisait lui-même la moitié du calcul qu'il prétendait vérifier.
Deux gestes, pas un. Ils ne couvrent pas la même chose :
| Ce que ça couvre | Ce que ça ne voit pas | |
|---|---|---|
PIT (-Pmutation ciblé) |
l'espace entier des mutations d'une classe : conditions inversées, bornes, retours neutralisés | tout ce qui n'est pas du code Java mutable - attribut d'annotation (aliases), câblage Guice, FXML, sonde réseau |
| La mutation à la main | n'importe quoi : réintroduire le défaut d'origine, retirer une annotation, casser un binding | une seule hypothèse à la fois, celle qu'on a pensé à tester |
PIT est plus exhaustif là où il s'applique ; le geste manuel est plus large. Les trois premiers
contre-exemples ci-dessus sont hors de portée de PIT ; le quatrième, en revanche, était exactement dans
sa cible - et un -Pmutation ciblé l'aurait signalé sans qu'on ait à deviner lequel mutant écrire.
En pratique : à la passe 6 d'une clôture, lancer PIT ciblé sur les classes que le chantier a introduites, et vérifier à la main les garde-fous que PIT ne peut pas atteindre.
Demander à un garde ce qu'il est : --contrat¶
Où vivent les fonds communs des gardes¶
Il y en a DEUX, et chacun sert son arbre. scripts/_commun/ porte le vocabulaire des corpus, les
verdicts, l'aide --contrat, l'assertion des auto-tests et, depuis #5420, le lecteur d'arbre
arbre.py : six gardes lui délèguent la lecture de la structure du Java plutôt que de compter des
accolades. .github/scripts/_forge.py porte ce que
les cliquets de forge partagent : la lecture du cliquet dans un en-tête d'ADR, l'interrogation de la
forge avec son refus quand gh ne répond pas, et le banc de leurs cas (#4954).
Le second n'est pas une copie du premier, et la mesure a décidé de leur séparation. Verser de la
logique de forge dans scripts/_commun/ en ferait une bibliothèque de domaine, ce que #5216 avait
mesuré comme non justifié : « le partage réel est plus étroit qu'il n'y paraît ; ce que tout le monde
a, c'est l'auto-test, le reste se répartit par tiers ».
Le premier s'atteint par une seule forme, depuis n'importe quel garde :
Il a vécu dans scripts/adr/ jusqu'à #5216, c'est-à-dire chez l'un de ses usagers, et quatre formes de chemin coexistaient pour l'atteindre. La quatrième n'a été trouvée ni par le motif de réécriture ni par la relecture du diff, mais par un relevé des 54 auto-tests avant et après : un refactoring à comportement constant se prouve en comparant deux mesures.
Soixante-seize points d'entrée répondent à --contrat et déclarent six champs : les 47 de
scripts/adr, les 28 de scripts/methode, et la porte elle-même, scripts/batterie.py, qui
vit à la racine de scripts/. Cette troisième famille ne compte qu'un fichier, et la page l'a ignorée
tant qu'elle n'énumérait que deux dossiers : c'est le même angle mort que l'issue #5397 relève pour
les bancs de mutation, où un garde à la racine tombe entre eux.
Relevé à la clôture de #5402, en lançant les points d'entrée un par un plutôt qu'en recopiant le
chiffre. Le geste n'est pas décoratif : chercher sort_si_contrat_demande dans le texte en rend
75, un de moins, parce qu'un script atteint l'aide sans écrire son nom. Une mention n'est pas un
dispatch, et une absence de mention n'est pas une absence de capacité.
Le chiffre a valu 68 à la clôture de #5257, quand la page annonçait « 43, 24, et le dernier garde
shell » alors qu'aucun des deux scripts shell restants ne répond à --contrat. La réponse s'obtient sans rien
lire du dépôt : la branche s'imprime avant tout le reste, pour qu'un garde dont une dépendance
manque rende quand même sa déclaration.
verifie_contrat_obligatoire.py refuse un point d'entrée de ces deux dossiers qui n'en
déclarerait pas. Ce n'est pas un cliquet : il n'y a pas de marge à relever, et l'échappatoire est une
liste d'exceptions nommées, vide aujourd'hui.
CONTRAT | garde=scripts/adr/0008-echec-silencieux.py
geste: échec silencieux : catch au corps vide
population: PRODUCTION + TESTS
dispositif: cliquet
seuil: 0, polarite=descend
temoin: scripts/adr/verifie_scripts.py#test_0008_echec_silencieux
decision: ADR 0008
| Champ | Ce qu'il dit |
|---|---|
geste |
ce que le garde cherche, en une phrase |
population |
ce qu'il balaie. Les arbres Java se nomment PRODUCTION et TESTS ; le reste s'écrit en clair |
dispositif |
ce qu'il est, et le vocabulaire est clos (ADR 5125). Quatre disent comment il juge : cliquet (28), invariant (25), loupe (7), plancher (1). Trois disent qu'il ne juge pas, et ce qu'il fait à la place : rapport (4), generateur (2), harnais (1) |
seuil |
la marge et sa polarité, ou (sans objet) |
temoin |
ce qui l'éprouve : <fichier> --auto-test, ou verifie_scripts.py#<fonction> |
decision |
l'ADR qui le fonde, ou « hygiène, sans décision » |
Un champ sans objet s'écrit, il ne s'omet pas. imprime_contrat refuse un contrat incomplet :
une ligne absente ne se distingue pas d'un oubli, alors que seuil: (sans objet) pour un invariant
dit quelque chose.
Ce que la déclaration remplace. contrats-des-gardes.py sait deviner la population, le seuil et
l'ADR en lisant le source. L'inférence marche souvent et se trompe sans le dire : elle lisait une
chaîne de fixture comme du code (#5103), et rendait « (non declaree) » dès qu'un import se repliait
au-delà de 100 caractères (#5128). Un contrat déclaré porte en plus ce qu'aucun motif ne dérive - la
population d'un garde qui interroge la forge, par exemple.
Et il est confronté. verifie_contrats_tiennent.py compare ce que le contrat déclare à ce que le
garde fait, et refuse la contradiction. La règle n'est pas l'égalité : RACINES et
PRODUCTION + TESTS nomment le même corpus, et un silence de l'inférence ne contredit rien.
Sans cette confrontation, un contrat serait un commentaire, et il dériverait dès le jour où on
l'écrit.
Ce qu'il ne dit pas : ce que le garde refuse vraiment. Un contrat déclare ce qu'un garde
est, jamais son prédicat. Et le champ temoin est vérifié existant, pas éprouvé : neuf des
quarante-et-un ne sont tenus que par cette vérification-là (#5134).
Ce qui bloque la CI¶
| Workflow | Commande | Bloquant ? |
|---|---|---|
| « Java CI » (maven.yml), tests + couverture + hygiène des dépendances | ./mvnw -B verify -Djacoco.haltOnFailure=true |
Oui |
| « Quality gate » (lint.yml), formatage | ./mvnw -B spotless:check |
Oui |
| « Quality gate », portail PMD | ./mvnw -B test-compile pmd:pmd puis les cliquets ADR, dont celui de l'ADR 4617 |
Oui |
L'hygiène des dépendances est verrouillée dans la première ligne : dependency:analyze-only est
lié à la phase verify et échoue sur écart (failOnWarning). Jusqu'à #3515 il n'était lié à
aucune phase et n'apparaissait dans aucun workflow - il ne tournait que si quelqu'un le tapait, et
signalait cinq écarts en terminant en succès.
Deux familles d'écarts, deux gestes opposés : utilisée sans être déclarée veut dire qu'on compile
grâce à une transitive, qui disparaîtra le jour où son porteur montera de version - la déclarer ;
déclarée sans être utilisée veut dire qu'on porte un artefact pour rien - le retirer, après avoir
vérifié que ce n'est pas un faux positif de l'analyseur, qui lit le bytecode et ne voit donc ni les
liaisons résolues à l'exécution ni les artefacts résolus avec un classifieur de plateforme. Les
exclusions du pom.xml nomment chacune sa raison ; une exclusion sans motif est le même bruit sous un
autre nom.
Ne pas mêler une déclaration à une montée de version : les deux dans la même PR rendent un éventuel rouge illisible.
lint.yml vérifie aussi la complétude des captures (cf. Captures). Une PR doit
passer les deux workflows (cf. CI/CD et release).
Deux invariants sont en plus verrouillés : un test fige le plan d'exécution des requêtes O5 (l'index ne doit pas régresser, cf. Performance et benchmarks), et les garde-fous PMD / ArchUnit ne se désactivent jamais pour « faire passer » un build (cf. Sécurité et données sensibles).
Écrire un nouveau test¶
- Un test de vue part d'un injecteur (réel ou partiel) Guice et d'une base jetable (workspace
temporaire +
MigrationSchema.migrer()), comme les*VueIntegrationTest/*E2ETestexistants. - TestFX : interroger les nœuds par
fx:id(lookup("#monId")), piloter via le robot Glass, asserter avec AssertJ. Préférerbouton.fire()à un clic robot quand c'est possible (plus stable en headless). - Pour une capture déterministe, voir Ajouter une fonctionnalité §7.
- Un test qui MESURE une géométrie monte sa scène avec
Habillage.scene(...), jamaisnew Scene. Sans lui, il mesure la police de la machine hôte au lieu de celle du produit, et son verdict dépend de ce qui a tourné avant lui dans le même fork.ScenesHabilleesTestle garde (#3773).
Un cas de recette montre un état ET son contraire¶
Un bouton grisé, un bandeau masqué, un voile qui a cédé, une liste qui n'a pas changé : ce sont des absences, et une absence n'a pas d'image. Un cas qui fait juger un état montre donc aussi le moment où la chose est là - sinon le spectateur regarde un écran immobile en cherchant ce qu'il devrait y voir.
Trois formes, selon ce que le cas éprouve :
| Forme | Exemple |
|---|---|
| le contraire dans le temps | S1-19 ouvre sur le carré où les deux commandes sont actives, avant celui où elles ne le sont pas |
| le contraire par le voisinage | S1-16 montre « Récupérer » grisé contre « + Nouveau site » en couleur |
| le jumeau, dans un autre cas | S1-25 montre qu'annuler n'ajoute rien, S1-13 que valider ajoute |
Corollaire : un clip qui remplace le geste par son effet ne montre pas le produit. Il montre une
conséquence dont la cause est hors champ. D'où la règle que le pointeur s'arrête sur sa cible avant
d'appuyer (GesteVisible), et que le motif d'un blocage paraît au lieu d'être lu par programme
(ADR 4234).
La règle n'oblige pas à fabriquer le contraire. Quand il semble introuvable, la question est
d'abord « sur quelles données ai-je mesuré ? ». S1-17 a été déclaré infilmable au vu d'une fixture de
deux sites ; sur cent cinquante carrés, le voile dure un demi-seconde.
Voir ADR 4166.
Certains défauts ne se voient pas depuis ce poste - il faut les sonder¶
Quatre fois sur la campagne #3802, une mesure locale a rendu vert sans rien dire : Noto Sans est
une police système sous Linux, ATOMIC_MOVE y réussit quoi qu'on tienne ouvert, la vue ACL n'y
existe pas, et PIT y tourne - donc laisse sans couverture toute branche que seul Windows emprunte.
La méthode qui marche, et qui coûte trois minutes depuis #3754 : une sonde jetable qui rapporte au lieu de juger, dispatchée sur la plateforme concernée, lue, puis traduite en assertion.
Les deux sondes de cette campagne ont contredit l'hypothèse qui les demandait. Une sonde qui aurait asserté l'hypothèse serait passée au rouge sans qu'on sache pourquoi.
Détail : ADR 3802.
Et le test qui RESTE déclare ce qu'il exige de la plateforme¶
La sonde dit quel comportement câbler. Le test qui subsiste doit, lui, être jouable partout : une fixture qui suppose son système meurt avant sa première assertion, et le rapport compte alors une erreur sur ce qu'elle préparait, jamais un rouge sur ce qu'elle éprouvait.
Files.setPosixFilePermissions et ses voisines jettent UnsupportedOperationException là où la vue
posix n'existe pas, NTFS en tête. Cinq écritures satisfont le garde, et ce sont celles que le
dépôt employait déjà :
| Forme | Quand l'employer |
|---|---|
@EnabledIf("fr.univ_amu.iut.fixture.SystemeDeFichiers#posixDisponible") |
la fixture EXIGE POSIX et le cas n'a pas de sens ailleurs. La forme préférée : elle se voit dans le rapport |
assumeTrue(SystemeDeFichiers.posixDisponible()) en tête de méthode |
même exigence, moins visible. Jamais au milieu : elle emporterait les assertions qui n'ont rien de POSIX |
try / catch (UnsupportedOperationException) |
le code se replie et n'éprouve rien de la plateforme |
| une aide privée dont tous les appelants du fichier déclarent | la délégation voulue par #3778, pour que l'hypothèse ne vive pas dans le helper |
if (SystemeDeFichiers.posixDisponible()) |
les deux branches sont éprouvées, l'une attendant le succès et l'autre le refus |
Le prédicat a une seule écriture, fixture.SystemeDeFichiers.posixDisponible(). Il en a eu cinq,
et une copie qui diverge de ses soeurs ne fait rougir personne.
scripts/adr/5437-fixture-suppose-la-plateforme.py refuse toute autre forme, cliquet à zéro. Ce qu'il
ne voit pas est déclaré : une aide appelée depuis un autre fichier, et un @EnabledIf dont le
prédicat rendrait toujours vrai, suivi par son nom et non par son corps.
Détail : ADR 5437.
La police d'un test n'est pas celle du produit, sauf si on la lui donne¶
Typographie.installer() garde un static boolean : l'enregistrement de la police embarquée est
global au JVM et fait une seule fois. Un test qui monte sa scène à la main voit donc la police du
produit si un voisin l'a installée avant lui, et celle du système sinon - avec reuseForks=true,
c'est l'ordre d'exécution qui décide.
Mesuré (#3773) : CartesAccueilTest a rendu vert à 8 h 14 et rouge à 15 h 34, sur le même
commit et la même image macos-26-arm64. Puis, joué seul sous macOS - donc sans voisin -, il
échoue. L'écart tient à 20,43 px contre 17,666 px selon la police effectivement rendue.
Ce défaut ne se voit pas depuis un poste Linux : Noto Sans y est une police système, donc
trouvée installée ou non. Une suite locale verte ne dit rien de cette propriété. Sur le runner Ubuntu,
l'ADR 3361 note que sans-serif se résout en « une police plus large » - ce que la CI voit exactement
n'a pas été mesuré.
Le remède ne dépend d'aucune machine : passer par Habillage.scene(...), qui installe la police et
pose le trio du chrome.
Cinq pièges récurrents¶
La fenêtre du harnais est PARTAGÉE : on ne l'emprunte pas
TestFX réutilise la même fenêtre primaire pour toutes les classes d'un même fork. Une classe qui la dimensionne, la déplace ou la ferme laisse cet état à toutes celles qui passent après elle - et seulement à celles-là, dans l'ordre où la répartition des forks les a mises. Le symptôme tombe alors sur une classe sans rapport, sous la forme d'un noeud « invisible ».
Le défaut est revenu cinq fois : #1940, #1967, #3452, #4130, #4145.
Rendre la valeur ne rend pas la propriété. setWidth fait passer un Stage en
dimensionnement explicite, et reposer la largeur d'entrée ne l'en fait pas sortir : il cesse
définitivement de suivre les scènes qu'on lui pose. Deux bancs appliquaient consciencieusement
cette restauration-là, en citant #3960 en commentaire, et figeaient quand même.
Un banc qui doit dimensionner, déplacer ou fermer une fenêtre ouvre la sienne :
new Stage(), initOwner(celle du harnais), et on la referme en @AfterEach. Un scénario filmé
passe par recette.FenetreDuBanc, qui demande la taille à la mise en page puis sizeToScene().
Voir l'ADR 4134.
assertThat(path).endsWith(Path) canonicalise
Cette forme appelle toRealPath et lève NoSuchFileException si le dossier n'existe pas (erreur
sur runner neuf). Préférer le booléen lexical : assertThat(p.endsWith(autre)).isTrue().
fire() est un no-op sur un contrôle désactivé
Button.fire() comme Hyperlink.fire() vérifient isDisabled() avant d'émettre. Un test qui
« clique » un contrôle grisé ne déclenche donc rien, et s'il attend un refus métier, il échoue
sans dire pourquoi. Le plus souvent, c'est le test qui a tort : quand l'affordance (#789) a
déjà fermé le geste, il n'y a plus de refus à annoncer, et c'est le grisage qu'il faut
asserter. On ne prévient pas après coup ce qu'on a déjà empêché.
Mutation hors fil JavaFX
Un handler qui modifie l'IHM depuis un thread d'arrière-plan lève Not on FX application thread,
souvent avalée (l'écran fige). Découper préparation (fil FX) / exécution (hors-thread)
/ retour sur le fil FX - c'est exactement le contrat du socle ExecuteurTache (#793, cf.
Patterns), synchrone par défaut en test de vue/ViewModel (liaison Guice
@ImplementedBy(ExecuteurTacheSynchrone.class), cf. Patterns) : avec lui,
bouton.fire() rend l'état terminal observable au retour du clic, sans attente.
Dès que l'exécuteur est asynchrone : attendre le signal, pas le retour du clic
Un test *E2ETest monte le vrai RacineInjecteur, pas le module de test qui rebranche
ExecuteurTacheSynchrone (#793).
Ce n'est plus une propriété des seuls E2E (#3667). Un test de vue peut brancher
ExecuteurTacheAsynchrone délibérément : ScenarioPerceptifConnexionTest le fait, parce que
le transitoire qu'il donne à voir - la zone de progression seule, avant le bandeau - n'existe pas
autrement. En synchrone, la récupération occupe le fil JavaFX : aucune image n'est rendue pendant
ce temps, et il n'y a rien à filmer. La règle ci-dessous porte donc sur l'exécuteur employé,
pas sur la famille du test. occupation.occuper(...) y tourne donc sur le vrai
ExecuteurTacheAsynchrone : thread virtuel + Platform.runLater. Après un robot.interact(...)
(ou un appel direct en @Start) qui déclenche ce chemin, waitForFxEvents() ne fait que vider la
file du fil FX - il n'attend pas le thread d'arrière-plan qui la remplira. L'assertion tombe alors
avant le callback de succès : un échec qui ne se reproduit que sur une machine lente, donc en CI
(#3668, #3717). Remède : Attente.que(prédicat, ceQuOnAttend) sur l'état attendu, jamais une
assertion immédiate après l'interact.
Cette classe existe parce que le remède avait été réinventé trois fois en privé, et jamais
partagé : dans AppTest pour la mise en page (#4504), dans ScenarioAccueilTest pour l'ouverture
d'un écran (#4408), et nulle part ailleurs, chaque banc écrivant son waitFor. Elle dit ce
qu'elle attendait en expirant, sans quoi l'échec arrive plus tard sur l'assertion, qui accuse le
code là où c'est la mise en place qui n'a pas eu lieu.
Attente.queSurLeFil quand le prédicat touche la scène : waitFor rappelle le prédicat depuis
le fil du test, et le graphe de scène n'est pas partageable.
Ce n'est plus une recommandation : scripts/adr/5278-attente-hors-du-fil.py la tient par un
cliquet à zéro, et l'ADR 5278 en porte la décision. Toucher la scène s'entend au sens large,
et les deux familles se valent : chercher un nœud (lookup(, queryAs) et lire une propriété
sur un nœud qu'on tient déjà (isVisible, isDisabled). La seconde s'oubliait, et elle a coûté
quatre chutes à MainViewTest après que #4694 l'eut réparé avec la mauvaise forme.
Quatre bancs de #4804 sont tombés faute de cette attente, entre 3 et 4 fois sur 1 150 passages,
chacun sous une forme différente : une respiration de tournage qui ne s'arrête que si l'on filme
(#4694), un commentaire qui conclut qu'il n'y a rien à attendre (#4813), rien du tout entre le
clic et l'assertion (#4814), et waitForFxEvents pris pour une attente de travail de fond (#4815).