Aller au contenu
BackJavaquarkusGatlingTesting

La taverne à l'épreuve du siège : tester la résilience avec Gatling

Quand la taverne est prise d’assaut, ce n’est plus la théorie qui parle, mais l’épreuve du feu. Avec Gatling, on mesure si elle tient vraiment : sous la pression, face au chaos, et malgré l’afflux, sans jamais rompre.

La taverne doit tenir la charge

Dans l'article précédent, la taverne que nous avons développé en quarkus avait été équipée de ses premiers garde-fous : un comptoir sous contrôle, un tavernier qui retente sa chance dans les escaliers, une cuisine capable de proposer un repas de substitution. Tout semblait en ordre.

Mais une taverne ne se juge pas à sa sérénité un mardi matin. Elle se juge à sa capacité de tenir un samedi soir, quand les nains arrivent en masse, que les affamés réclament leur plat simultanément, et que l'aventurier du fond ne cesse de commander des bières depuis vingt minutes.

C'est là qu'intervient Gatling — non pas comme une arme, mais comme une mise à l'épreuve organisée, méthodique, presque chirurgicale.

Gatling, ou l'art de simuler le chaos

Gatling est un outil de test de charge écrit en Scala, mais qui expose une API Java fluide depuis sa version 3.7. Son rôle est simple : simuler des centaines, voire des milliers d'utilisateurs simultanés qui frappent une application, et mesurer comment elle se comporte sous pression.

Là où un test unitaire vérifie qu'une méthode se comporte correctement, Gatling vérifie que l'application entière tient la route quand tout le monde frappe à la porte en même temps.

Pour l'intégrer dans un projet Maven :

<dependency>
    <groupId>io.gatling.highcharts</groupId>
    <artifactId>gatling-charts-highcharts</artifactId>
    <version>${gatling.version}</version>
    <scope>test</scope>
</dependency>

la dépendance pour gatling

Toute simulation Gatling étend la classe Simulation. C'est le point d'entrée depuis lequel on déclare les scénarios, les protocoles HTTP, et les règles d'assertion.

public class TavernLoadTest extends Simulation {
    // ...
}

Le protocole : mettre la taverne en condition

Avant de lâcher les aventuriers, il faut leur donner un point de ralliement — l'adresse de la taverne, et quelques réglages communs à tous les scénarios :

HttpProtocolBuilder httpProtocol = http
    .baseUrl("http://localhost:8080")
    .acceptHeader("text/plain")
    .userAgentHeader("Gatling/Tavern-Resilience-Test");

on commence à configurer les bases

Ce bloc est partagé par tous les scénarios. Il évite de répéter l'URL de base à chaque requête, et permet à Gatling de tracer chaque appel avec un en-tête d'identification reconnaissable dans les logs.

Quatre scénarios, quatre épreuves

L'aventurier ordinaire

Le premier scénario simule un parcours réaliste : un aventurier entre dans la taverne, commande une bière, puis descend chercher une bouteille à la cave, et finit par réclamer son plat du jour.

ScenarioBuilder parcoursClassique = scenario("Parcours classique d'un aventurier")
    .exec(
        http("Commande de bière")
            .post("/taverne/commande")
            .check(status().is(200))
    )
    .pause(1, 3)
    .exitHereIfFailed()
    .exec(
        http("Reset cave")
            .get("/taverne/cave")
            .queryParam("reset", "true")
            .check(status().is(200))
    )
    .pause(1, 2)
    .exec(
        http("Descente à la cave avec retry")
            .get("/taverne/cave")
            .check(status().is(200))
    )
    .pause(1, 3)
    .exec(
        http("Plat du jour (normal)")
            .get("/taverne/plat-du-jour")
            .queryParam("empty", "false")
            .check(status().is(200))
            .check(bodyString().is("Voici un délicieux ragoût de sanglier !"))
    );

scénario nominal

Deux détails méritent attention. D'abord, exitHereIfFailed() : si l'aventurier est rejeté au comptoir faute de quota disponible, il quitte la taverne sans aller à la cave ni réclamer son plat. C'est un comportement métier réaliste — un client rejeté ne poursuit pas son parcours. Ensuite, pause(1, 3) introduit une pause aléatoire entre une et trois secondes, imitant le temps qu'un vrai utilisateur prendrait entre deux actions.

Le bataillon de nains

Le deuxième scénario est moins subtil : mille nains assoiffés frappent simultanément au comptoir, sans pause, sans attente.

ScenarioBuilder burstDeNains = scenario("Burst de nains assoiffés")
    .exec(
        http("Commande massive de bière")
            .post("/taverne/commande")
            .check(status().is(200))
    );

scénario de burst

Son injection est volontairement brutale :

burstDeNains.injectOpen(
    nothingFor(5),
    atOnceUsers(1000)
)

1000 nains assoiffés après une journée à la mine

nothingFor(5) laisse cinq secondes au scénario classique pour démarrer en douceur avant que le déluge ne s'abatte. atOnceUsers(1000) déclenche ensuite mille requêtes en une seule salve. L'objectif n'est pas de mesurer la performance de ce scénario, mais de vérifier que le rate limiting fonctionne et que l'application ne s'effondre pas sous la charge.

Les affamés face à la rupture

Le troisième scénario teste le fallback : cent cinquante aventuriers réclament le plat du jour alors que la marmite est vide.

ScenarioBuilder affames = scenario("Affamés face à la rupture")
    .exec(
        http("Plat du jour vide → fallback")
            .get("/taverne/plat-du-jour")
            .queryParam("empty", "true")
            .check(status().is(200))
            .check(bodyString().is(
                "Désolé l'ami, la marmite est vide... Tiens, un morceau de pain dur et du fromage sec en lot de consolation."
            ))
    );

on veut le plat du jour

La vérification du corps de réponse est ici délibérée : il ne s'agit pas seulement de s'assurer qu'on reçoit un HTTP 200, mais bien que le fallback produit exactement le bon message. Un 200 vide ou un message d'erreur masqué passerait le check de statut mais trahirait une régression.

Le retry sous pression

Le quatrième scénario isole le mécanisme de retry. Cinq utilisateurs par seconde pendant vingt secondes tentent de remonter une bouteille de la cave — chaque appel ayant soixante pour cent de chances d'échouer avant que le retry ne prenne le relais.

ScenarioBuilder testRetry = scenario("Test isolé du retry")
    .exec(
        http("Reset cave")
            .get("/taverne/cave")
            .queryParam("reset", "true")
            .check(status().is(200))
    )
    .pause(1)
    .exec(
        http("Retry complet")
            .get("/taverne/cave")
            .check(status().is(200))
    );

Un point important ici : la version utilisée dans les tests de charge abandonne le compteur déterministe de l'article précédent au profit d'une probabilité aléatoire de soixante pour cent par tentative. La raison est simple — avec un AtomicInteger global partagé entre des dizaines de threads concurrents, les compteurs s'emmêlent et le comportement devient imprévisible. La version probabiliste garantit un comportement indépendant par requête, quel que soit le niveau de concurrence.

Les injections : choisir le profil de trafic

Gatling distingue deux modèles d'injection : injectOpen et injectClosed.

injectOpen génère des utilisateurs sans se soucier de combien sont encore actifs — on injecte un flux, indépendamment du rythme de traitement. C'est le modèle adapté aux APIs publiques où les utilisateurs arrivent sans attendre que les précédents soient servis.

injectClosed maintient un nombre constant d'utilisateurs actifs simultanément — quand l'un part, un autre entre. C'est plus proche des systèmes à file d'attente ou des connexions maintenues.

Pour la taverne, injectOpen suffit : les aventuriers n'attendent pas leur tour, ils poussent la porte dès qu'ils le décident.

Les principales fonctions d'injection disponibles :

rampUsers(200).during(30)          // 200 utilisateurs sur 30 secondes, montée progressive
atOnceUsers(1000)                  // 1000 utilisateurs en une seule salve
constantUsersPerSec(5).during(20)  // 5 utilisateurs par seconde pendant 20 secondes
nothingFor(5)                      // pause de 5 secondes avant le prochain injecteur

les profils d'injection possible

Les assertions : définir ce que "ça tient" veut dire

Une simulation sans assertions ne fait que produire des chiffres. Les assertions transforment ces chiffres en verdict.

.assertions(
    global().responseTime().percentile3().lt(500),
    global().successfulRequests().percent().gt(15.0),

    details("Commande massive de bière")
        .failedRequests().percent().gt(95.0),

    details("Retry complet")
        .responseTime().percentile3().gt(200),

    details("Plat du jour vide → fallback")
        .failedRequests().count().is(0L)
)

ce que l'on attend

percentile3() est la notation Gatling pour le 95ème percentile — la valeur en dessous de laquelle se trouvent 95% des temps de réponse. C'est un indicateur plus robuste que la moyenne, qui masque les pics.

Chaque assertion cible un aspect précis du contrat de résilience :

  • le p95 global sous 500ms garantit que l'application reste réactive malgré la charge ;
  • le seuil de succès global à 15% tient compte des mille requêtes de nains massivement rejetées, qui font mécaniquement chuter le taux global — l'assertion reste donc volontairement basse pour ne sanctionner que les vraies régressions ;
  • les 95% d'échecs sur le burst valident que le rate limiting est bien en place et effectif ;
  • le p95 supérieur à 200ms sur le retry prouve que le délai configuré entre les tentatives est bien respecté en production — un retry sans délai ne ferait que surcharger le service en difficulté ;
  • le zéro échec sur le fallback garantit que cette route ne peut jamais produire d'erreur, quelle que soit la pression.

Lancer la simulation

Avec le plugin Gatling pour Maven :

mvn gatling:test

À l'issue de l'exécution, Gatling génère un rapport HTML dans target/gatling/. Il contient les courbes de temps de réponse, la distribution des percentiles, le détail par scénario et par requête, et le résultat de chaque assertion sous forme de verdict vert ou rouge.

Ce que les chiffres racontent

le dashboard gatling avec nos résultats

Un rapport Gatling bien lu ne répond pas seulement à la question "est-ce que ça passe ?". Il révèle la forme du comportement de l'application sous charge.

Un p95 qui monte progressivement avec le nombre d'utilisateurs indique une dégradation linéaire, souvent signe d'une ressource partagée qui sature — un pool de connexions, une file d'attente. Un p95 stable jusqu'à un certain seuil puis qui s'effondre brutalement est plus préoccupant : il suggère un point de rupture, souvent lié à la mémoire ou à un verrou.

Pour le scénario de retry, un p95 autour de 400 à 600ms est attendu — deux tentatives ratées à 200ms de délai chacune, puis une réussie. Un p95 à 2000ms trahirait des retries en cascade ou un délai mal configuré. Un p95 à 50ms signalerait que le retry ne fonctionne pas, ou que les tentatives n'échouent jamais.

Conclusion

La taverne est maintenant capable de se défendre.

Elle sait refuser une commande quand le rythme dépasse ses capacités, retenter sa chance quand les escaliers résistent, et servir dignement même quand la cuisine est à sec. Ce n'était là que la première étape — s'assurer que ces mécanismes fonctionnent en isolation.

Gatling complète le tableau en posant une question plus difficile : est-ce que tout cela tient ensemble, quand les nains, les affamés et les aventuriers pressés arrivent en même temps ?

La réponse, désormais, n'est plus une intuition. C'est un rapport.


Tout le code relatif à cet article est trouvable ici :

GitHub - ErwanLT/quarkus-demo: Demo project for quarkus possibility
Demo project for quarkus possibility. Contribute to ErwanLT/quarkus-demo development by creating an account on GitHub.

Dernier