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#chargeLesSites |
Une seule méthode |
./mvnw verify |
Build complet : tests + couverture + contrôles (PMD/JaCoCo non bloquants) |
./mvnw -Pquality-gate verify |
Portail qualité : PMD failOnViolation + seuils JaCoCo bloquants |
./mvnw -Pmutation test |
Tests de mutation PIT (lent, à la demande) |
./mvnw pmd:check |
Rapport PMD seul (rapide) |
./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 vient de Guava (transitif), pas du code du projet. Il disparaîtra avec une montée de version de Guava ; rien à faire ici pour l'instant.
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 :
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 (fat-jar shadé), au niveau processus : 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 smoke-test du fat-jar (#1572, amorce). |
| 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). |
Outils : JUnit 5 + AssertJ + Mockito ; ApprovalTests pour les sorties verbatim (CSV Tadarida
_Vu : le premier run produit un *.received, à approuver en *.approved).
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 (66 au 2026-07-14), 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 |
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.
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 :
Sur une classe de vue (TestFX), passer les options de la JVM headless. Le profil mutation ne
reprend pas l'argLine de Surefire : sans elles, PIT lance ses minions sans glass.platform=Headless,
la suite ne démarre pas et l'outil s'arrête sur « tests did not pass without mutation ». On les lui
donne à la main :
./mvnw -Pmutation test-compile org.pitest:pitest-maven:mutationCoverage \
-DtargetClasses=fr.univ_amu.iut.commun.view.Modales \
-DtargetTests=fr.univ_amu.iut.commun.view.ModalesTest \
-DjvmArgs="-Dglass.platform=Headless,-Dprism.order=sw,-Djava.awt.headless=true,-Dtestfx.robot=glass,--enable-native-access=ALL-UNNAMED"
PIT couvre donc aussi la couche view, ce que l'échec brut laissait croire impossible.
⚠️ 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.
Ce qui bloque la CI¶
| Workflow | Commande | Bloquant ? |
|---|---|---|
| « Java CI » (maven.yml) — tests + couverture | ./mvnw -B verify -Djacoco.haltOnFailure=true |
Oui |
| « Quality gate » (lint.yml) — formatage | ./mvnw -B spotless:check |
Oui |
| « Quality gate » — portail PMD | ./mvnw -B -Pquality-gate compile pmd:check |
Oui |
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.
Trois pièges récurrents¶
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 en test : avec lui, bouton.fire() rend l'état terminal
observable au retour du clic, sans attente.