Tests et qualité logicielle
Les tests de performance comme discipline : SLO, benchmarks JMH et garde-fous en CI
Intermédiaire10 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Le travail de performance échoue quand il devient une phase en fin de projet. Ce guide l'intègre à la stratégie de test : rédiger des exigences non fonctionnelles capables de faire échouer un build, mesurer correctement le code avec JMH, profiler un test lent au lieu de deviner, et faire tourner des contrôles de régression dans un pipeline où les mesures sont bruitées.
Ce que vous allez apprendre
- Transformer une attente de performance vague en SLO qu'un test peut valider ou invalider
- Placer les contrôles de performance au bon niveau, du microbenchmark au test de charge complet
- Écrire des benchmarks JMH qui résistent au warm-up du JIT et à l'élimination de code mort
- Profiler un test ou un endpoint lent avec des profileurs par échantillonnage et JFR plutôt que de deviner
- Automatiser la détection des régressions de performance en CI malgré des mesures bruitées
Les problèmes de performance ont une forme caractéristique : personne ne remarque rien pendant des mois, puis une release rend une page quatre fois plus lente et la cause est enfouie dans trois cents commits. Ce n'est presque jamais faute d'un outil de test de charge. C'est que la performance a été traitée comme une activité de fin de projet, sur un autre environnement, menée par d'autres personnes, face à des exigences rédigées sous forme d'adjectifs.
L'intégrer à la stratégie de test change la donne. Les attentes de performance deviennent des nombres capables de faire échouer un contrôle, les mesures se font à plusieurs niveaux de granularité, et une régression est détectée par le pipeline qui l'a produite. Ce guide couvre le versant méthodologique : objectifs, niveaux, benchmarks, profilage et garde-fous en CI. Pour la modélisation de la charge, les profils de montée en charge et la mécanique d'un test de charge à grande échelle, consultez le guide sur la méthodologie des tests de charge.
Des exigences capables de faire échouer un build
« Le système doit être rapide » ne se teste pas. Un énoncé testable comporte quatre éléments : quelle opération, quelle statistique, quel seuil et dans quelles conditions.
L'endpoint
POST /ordersrépond en moins de 400 ms au 95e percentile et en moins de 900 ms au 99e, sous une charge soutenue de 150 requêtes par seconde, sur deux instances de 2 vCPU, avec une base contenant 5 millions de commandes.
C'est un objectif de niveau de service (SLO), et il est réfutable. Voyez ce qu'apporte chaque élément. Nommer l'opération empêche une moyenne sur tous les endpoints de masquer celui qui est lent. Choisir un percentile reconnaît que les utilisateurs subissent la traîne : une moyenne de 120 ms est parfaitement compatible avec une requête sur cinquante qui prend quatre secondes. Préciser la charge rend le nombre reproductible. Préciser l'environnement et le volume de données évite la mésaventure classique du système rapide sur dix mille lignes et quadratique sur dix millions.
Trois autres grandeurs accompagnent la latence, et ce sont souvent elles qui posent réellement problème : le débit (le nombre de requêtes traitées par seconde avant que la latence ne se dégrade), la marge de ressources (utilisation du CPU, de la mémoire et du pool de connexions à la charge cible) et le taux d'erreur sous stress (la proportion de requêtes qui échouent quand le système est poussé au-delà de sa zone de confort, et s'il se dégrade progressivement ou s'effondre).
La place des contrôles de performance dans la stratégie
La performance n'est pas une activité unique mais une famille d'activités, de portées et de coûts différents.
| Niveau | Ce qui est mesuré | Coût | Exécution |
|---|---|---|---|
| Microbenchmark (JMH) | Une méthode ou un algorithme, de la nanoseconde à la microseconde | de quelques secondes à quelques minutes | À la demande, ou à chaque commit pour les chemins critiques |
| Benchmark de composant | Un appel de service de bout en bout, dans le même processus | quelques secondes | À chaque commit |
| Assertion dans un test fonctionnel | Nombre de requêtes, nombre d'allocations, borne sur le temps écoulé | gratuit | À chaque build |
| Test de charge | Le système déployé sous une charge modélisée | de quelques minutes à quelques heures | Chaque nuit, à chaque release |
| Endurance et stress | Stabilité sur plusieurs heures, comportement au-delà de la limite | plusieurs heures | À chaque release |
| Observation en production | Trafic réel, données réelles | en continu | En permanence |
L'entrée la moins chère et la plus sous-utilisée de ce tableau est la troisième. Beaucoup de régressions de performance n'ont rien de subtil : une requête dans une boucle, un parcours complet de table, une comparaison en O(n²) introduite par mégarde. Un test fonctionnel peut les attraper en vérifiant une propriété dénombrable plutôt qu'une durée :
@Test
void loadingAnOrderPageIssuesAFixedNumberOfQueries() {
QueryCounter counter = QueryCounter.attachTo(dataSource);
orderPageService.render(customerWith(50).orders());
assertThat(counter.count())
.as("N+1 regression: one query per order has crept back in")
.isLessThanOrEqualTo(3);
}
Compter les requêtes, les allocations ou les appels HTTP est déterministe, donc l'assertion n'est jamais flaky, contrairement à une borne sur le temps écoulé, qui finira tôt ou tard par échouer sur un runner de CI chargé. Chaque fois que vous corrigez un problème N+1, laissez derrière vous un test de comptage pour qu'il ne puisse pas revenir en silence.
Microbenchmarks avec JMH
Quand la question est « cette routine de parsing est-elle plus rapide que celle-là ? », il vous faut un harnais de benchmark, car une boucle de chronométrage écrite à la main mesure l'opinion du compilateur JIT sur votre code plutôt que le code lui-même.
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g"})
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
public class IbanValidationBenchmark {
@Param({"FR7630006000011234567890189", "DE89370400440532013000"})
public String iban;
private IbanValidator regexValidator;
private IbanValidator arithmeticValidator;
@Setup(Level.Trial)
public void setUp() {
regexValidator = new RegexIbanValidator();
arithmeticValidator = new ArithmeticIbanValidator();
}
@Benchmark
public boolean regex() {
return regexValidator.isValid(iban);
}
@Benchmark
public boolean arithmetic() {
return arithmeticValidator.isValid(iban);
}
@Benchmark
public void consumesExplicitly(Blackhole blackhole) {
blackhole.consume(arithmeticValidator.normalise(iban));
}
}
Ces annotations ne sont pas là pour la forme : chacune protège contre une manière précise dont la mesure peut mentir.
@Warmup écarte les itérations pendant lesquelles la méthode tourne encore en mode interprété ou est en cours de recompilation. Sans lui, vous mesurez le démarrage.
@Fork exécute le benchmark dans des JVM neuves. La pollution de profil est bien réelle : exécuter le benchmark A avant le benchmark B dans la même JVM peut laisser au compilateur un site d'appel polymorphe qui fait paraître B plus lent qu'il ne l'est. Deux forks révèlent aussi la variance d'une exécution à l'autre, qu'une JVM unique dissimule.
Retourner une valeur ou utiliser Blackhole empêche l'élimination de code mort. Un benchmark dont le résultat n'est pas utilisé peut être entièrement supprimé par l'optimiseur, et le symptôme classique est une routine qui semble prendre 0,3 nanoseconde, soit moins qu'un seul accès mémoire : une impossibilité physique plutôt qu'une optimisation spectaculaire.
@State contrôle ce qui est partagé et à quel moment l'état est créé. Les entrées ne doivent pas être des constantes connues à la compilation, sinon l'optimiseur réduira tout le calcul à une constante ; @Param les fournit à l'exécution et, ce qui est bien pratique, parcourt toute la matrice.
Lancez-le depuis un module séparé (mvn clean verify puis java -jar target/benchmarks.jar) plutôt que depuis un IDE, et lisez la sortie marge d'erreur comprise : JMH fournit un intervalle de confiance, et une amélioration plus petite que cet intervalle n'en est pas une.
Même un benchmark correct peut induire en erreur. Il mesure une méthode isolée, après warm-up, avec un cache chaud et sans contention. En production, cette méthode tourne peut-être une fois par requête sur un cache froid pendant que quarante threads se disputent la bande passante mémoire. Un gain de 40 % sur une routine qui représente 0,2 % du temps de requête ne vaut strictement rien. Consultez toujours le profil avant d'optimiser, et revérifiez le chiffre de bout en bout ensuite.
Profiler plutôt que deviner
Face à une lenteur, le réflexe est d'inspecter le code et d'échafauder une théorie. Les théories sur la performance sont généralement fausses, parce que le coût se niche dans un endroit sans gloire : un appel de log qui formate une chaîne à chaque itération, une expression régulière recompilée à chaque requête, un bloc synchronized qui sérialise huit threads.
Les profileurs par échantillonnage interrompent périodiquement les threads et enregistrent leurs piles d'appels. Leur surcoût est faible et ils produisent un flame graph où la largeur représente le temps. C'est le bon premier outil pour savoir « où passe le temps », et il fonctionne aussi bien sur un processus de production en cours d'exécution que sur un test.
JDK Flight Recorder est intégré à la JVM et peut être activé le temps d'une seule exécution de test :
java -XX:StartFlightRecording=duration=60s,filename=slow-test.jfr,settings=profile \
-jar target/app.jar
L'enregistrement contient les sites d'allocation, les pauses du garbage collector, la contention sur les verrous, les exceptions levées, l'état des threads et l'activité du JIT. En l'ouvrant, vous voyez ce qu'un profileur de piles seul ne montre pas : que 30 % du temps écoulé est une pause GC, ou qu'un verrou est détenu pendant des millisecondes sur un chemin critique.
Le profilage des allocations est celui qu'on néglige le plus souvent et qui s'avère le plus souvent décisif. Un excès d'allocations ne ressemble pas à un goulet d'étranglement dans un profil CPU ; il se manifeste par une pression sur le garbage collector et des défauts de cache. Si une requête alloue deux cents mégaoctets, aucun réglage algorithmique ne corrigera le symptôme.
Pour un test lent, les mêmes outils s'appliquent, mais posez-vous d'abord une question plus simple : le test est-il lent parce que le code l'est, ou parce qu'il démarre un conteneur, construit un contexte applicatif ou fait des pauses ? Dans une suite d'intégration, c'est presque toujours la seconde réponse, et il s'agit alors d'un problème de suite plutôt que de performance : voyez la section sur la maîtrise des coûts du guide des tests d'intégration.
Mémoire, fuites et comportement dans la durée
Certaines défaillances n'apparaissent qu'avec le temps. Un cache sans politique d'éviction, un thread-local jamais nettoyé, une liste de listeners qui ne fait que grossir, un pool de connexions qui fuit sur un chemin d'erreur particulier : aucun de ces défauts ne fait échouer un test fonctionnel, et tous font tomber un déploiement en production au quatrième jour.
Deux contrôles automatisables en attrapent la majorité :
@Test
void repeatedRenderingDoesNotRetainDocuments() {
ReferenceQueue<RenderedDocument> queue = new ReferenceQueue<>();
List<PhantomReference<RenderedDocument>> refs = new ArrayList<>();
for (int i = 0; i < 200; i++) {
RenderedDocument doc = renderer.render(sampleInvoice());
refs.add(new PhantomReference<>(doc, queue));
}
System.gc();
await().atMost(Duration.ofSeconds(5)).until(() -> drain(queue) >= 190);
}
Un test d'endurance (soak test), où le système est maintenu sous une charge modérée pendant plusieurs heures tandis qu'on enregistre l'occupation du tas, le nombre de threads, les descripteurs de fichiers et le nombre de connexions, attrape le reste. Le signal n'est pas la valeur absolue mais la pente : un plancher de tas qui monte régulièrement après chaque garbage collection est une fuite, quel que soit l'aspect du pic. Deux heap dumps pris à deux moments différents et comparés par histogramme de classes désignent normalement le coupable en quelques minutes.
Automatiser la détection des régressions en CI
La difficulté avec la performance dans un pipeline, c'est que la mesure est bruitée. Les runners partagés ont des voisins, la fréquence des CPU varie, les limites des conteneurs diffèrent. Un garde-fou naïf (« échouer au-delà de 400 ms ») soit se déclenche en permanence, soit est réglé si largement qu'il ne se déclenche jamais.
Voici les techniques qui le rendent exploitable :
Comparer plutôt qu'affirmer des valeurs absolues. Exécutez la référence et la version candidate dans le même job, en alternance, et comparez-les. Le bruit commun affecte les deux, si bien que le ratio est bien plus stable que chacun des deux chiffres.
Bloquer sur un seuil large, alerter sur un seuil étroit. Ne faites échouer le build que sur une régression importante, disons 25 %, et émettez un rapport non bloquant pour tout ce qui dépasse 10 %. Le pipeline reste utilisable tout en rendant la dérive visible.
Suivre la tendance, pas l'exécution. Stockez chaque mesure avec son commit et tracez la courbe. La performance régresse rarement d'un seul coup spectaculaire ; elle se dégrade de 2 % à la fois sur quarante commits, et seule une courbe de tendance le montre. Une marche sur le graphique désigne le commit responsable.
Préférer des indicateurs déterministes quand c'est possible. Le nombre de requêtes, le nombre d'allocations et la taille des objets ne varient pas avec la charge de la machine : on peut donc les contrôler avec des seuils serrés sans tests flaky.
Isoler ce qui peut l'être. Épinglez les CPU, désactivez la variation de fréquence, utilisez un runner dédié aux benchmarks. Quand c'est impossible, augmentez le nombre de répétitions et prenez la médiane plutôt que la moyenne.
performance:
stage: verify
script:
- java -jar target/benchmarks.jar -rf json -rff target/jmh-result.json
- ./scripts/compare-benchmarks.sh baseline/jmh-result.json target/jmh-result.json --fail-over 25 --warn-over 10
artifacts:
paths: ["target/jmh-result.json"]
Quel que soit le garde-fou, enregistrez le résultat même quand il passe. C'est l'archive des mesures qui permet de répondre à « quand est-ce devenu lent ? », une question posée bien plus souvent que « ce build est-il lent ? », comme le défend le guide de l'intégration continue à propos de la durée des builds en général.
Quand les chiffres trompent
Voici une courte liste des façons dont les résultats de performance trompent leur monde ; la plupart ont déjà coûté une semaine à quelqu'un :
- Mesurer une JVM chaude et déployer une JVM froide. Si les instances redémarrent souvent, le temps de démarrage et de warm-up fait partie de l'expérience utilisateur.
- Tester avec un jeu de données minuscule. Tout est rapide sur mille lignes. Le comportement des index, les plans de jointure et les taux de succès du cache changent tous avec le volume.
- Générer la charge depuis une seule machine qui sature la première. Si le CPU du générateur est à 100 %, c'est le générateur que vous mesurez.
- Ignorer l'omission coordonnée. Un outil de charge qui attend une réponse avant d'envoyer la requête suivante sous-estime la latence précisément quand le système est en difficulté, car les requêtes qui auraient été lentes n'ont jamais été envoyées.
- Optimiser les mauvais 3 %. Profilez d'abord, toujours, et confirmez ensuite le gain de bout en bout.
- Une exécution unique. Un seul chiffre n'est pas une mesure. Répétez, rapportez la variance et méfiez-vous de tout effet plus petit qu'elle.
Points clés à retenir
- Les exigences de performance ne deviennent testables que sous forme de SLO : opération nommée, percentile, seuil, charge et environnement.
- Placez des contrôles de performance à plusieurs niveaux : comptages déterministes de requêtes et d'allocations dans les tests fonctionnels, JMH pour le code critique, tests de charge pour le système.
- Le warm-up, les forks et les blackholes de JMH protègent chacun contre un mensonge de mesure précis ; une boucle de chronométrage maison n'en reproduit aucun.
- Profilez avant d'optimiser, avec un profileur par échantillonnage et JDK Flight Recorder, et examinez les allocations autant que le CPU.
- Détectez les fuites avec des tests d'endurance et surveillez le plancher du tas après GC plutôt que le pic.
- En CI, comparez à une référence dans le même job, bloquez large, alertez serré, et stockez chaque mesure pour que la tendance désigne le commit fautif.
Sources et lectures complémentaires
Questions fréquentes
- Pourquoi ne pas simplement chronométrer une boucle avec System.nanoTime ?
- Parce que la JVM sera plus maligne que vous. Les mille premières itérations s'exécutent en mode interprété avant que le JIT ne compile la méthode : une phase de warm-up non chronométrée est donc indispensable. Une fois le code compilé, l'optimiseur peut sortir votre appel de la boucle, le réduire à une constante ou le supprimer purement et simplement si le résultat n'est pas utilisé. Le garbage collector et la variation de fréquence du CPU ajoutent un bruit supérieur à la plupart des effets que vous cherchez à mesurer. JMH existe précisément pour gérer tout cela.
- Faut-il lancer les tests de performance à chaque commit ?
- Les tests de charge complets, non : ils exigent un environnement proche de la production et prennent trop de temps pour une pull request. Un petit ensemble de benchmarks ciblés sur les chemins critiques peut tourner à chaque commit si le runner est assez stable, et un test de charge nocturne sur un environnement dédié attrape le reste. L'essentiel est qu'un signal de performance arrive moins d'une journée après le changement responsable d'une régression, tant que le diff est encore petit.
- Quelle différence entre une moyenne et un p99, et lequel utiliser ?
- La moyenne est dominée par le cas courant et masque la traîne ; un p99 signifie que 1 % des requêtes ont été plus lentes que cette valeur. Les utilisateurs subissent la traîne, et sur une page qui déclenche vingt appels backend, le p99 de chaque appel touche une grande partie des chargements de page. Fixez vos objectifs sur des percentiles, typiquement p95 et p99, plus un maximum, et considérez la moyenne comme un contrôle de cohérence plutôt que comme une cible.
- Mon benchmark dit que le nouveau code est 15 % plus rapide. Dois-je le croire ?
- Seulement si l'écart est grand par rapport à la variance d'une exécution à l'autre et que vous le reproduisez sur des exécutions répétées, idéalement sur une machine au repos ou du bare metal. Rapportez l'intervalle de confiance fourni par JMH, exécutez les deux versions en alternance plutôt que l'une après l'autre, et méfiez-vous de toute amélioration inférieure à environ 10 % sur un runner de CI partagé. Vérifiez aussi que le microbenchmark reflète un chemin qui compte réellement en production.