Aller au contenu

Créer un script JMeter compatible PerfShop

Ce guide explique comment créer un scénario JMeter depuis l'interface graphique JMeter sur votre poste, puis l'uploader dans PerfShop avec le monitoring Prometheus correctement configuré.

Il intègre les leçons apprises lors de la mise au point des scénarios livrés avec PerfShop (compatibilité JMeter 5.5 + XStream, pièges XML, extraction JSON sans plugin, remontée Grafana).


Prérequis

  • JMeter 5.5 installé localement (téléchargement)
  • Plugin Prometheus Listener installé dans JMeter local (optionnel pour l'édition, obligatoire pour les tirs PerfShop)
  • Accès à l'interface PerfShop JMeter UI (http://localhost:3005 ou URL publique)

Étape 1 — Structure du plan de test

1.1 Structure recommandée avec TransactionController

Pour que les métriques remontent par nom de transaction dans le dashboard Grafana, chaque étape métier doit être enveloppée dans un TransactionController avec parent=true :

Test Plan
└── Thread Group
    ├── HTTP Request Defaults
    ├── CSV Data Set Config          ← comptes utilisateurs
    ├── Cookie Manager               ← gère JSESSIONID automatiquement
    ├── Header Manager               ← Content-Type / Accept globaux
    ├── TransactionController [T01_Homepage]  ← parent=true
    │   ├── GET /api/products
    │   └── GET /api/products/categories
    ├── GaussianRandomTimer          ← think time entre transactions
    ├── TransactionController [T02_SearchProduct]
    │   └── GET /api/products/search?q=...
    │       └── JSR223PostProcessor  ← extraction JSON (voir §4)
    ├── ... (autres transactions)
    └── Prometheus Listener          ← enfant du Thread Group

TransactionController parent=true — clé de la remontée Grafana

Avec parent=true, le TransactionController génère un seul sample agrégé dont le label est le nom de la transaction (ex: T01_Homepage). C'est ce label qui est utilisé par le plugin Prometheus pour ventiler les métriques dans Grafana. Sans parent=true, chaque sampler HTTP interne remonte individuellement — le dashboard devient illisible avec de nombreuses requêtes.

1.2 Nommer les transactions pour Grafana

Le nom du TransactionController devient le label Prometheus. Adoptez une convention :

T01_Homepage
T02_SearchProduct
T03_AddToCart
T04_Login
T05_PasserCommande
...

Le dashboard Grafana filtre automatiquement les samplers techniques via :

label!~"null|Pr[ée]parer.*|BeanShell.*|JSR223.*|Setup.*|Teardown.*|DEBUG.*|__.*"

Évitez donc ces préfixes pour vos transactions métier.

1.3 Configurer les variables de cible

N'écrivez jamais l'hôte et le port en dur. Utilisez les propriétés JMeter injectées par PerfShop :

Dans HTTP Request Defaults :

Champ Valeur à saisir
Server Name or IP ${__P(target_host,perfshop-app)}
Port Number ${__P(target_port,8080)}
Protocol ${__P(target_protocol,http)}

Étape 2 — Compatibilité JMeter 5.5 + XStream (pièges XML)

Trois règles critiques pour la compatibilité JMeter 5.5

Ces règles font suite à des erreurs XStream rencontrées en production. Les ignorer provoque des NumberFormatException ou NullPointerException au chargement du JMX.

2.1 ThreadGroup.duration doit être un stringProp

Problème : JMeter 5.5 utilise XStream pour désérialiser les JMX. Si ThreadGroup.duration est déclaré en <longProp>, XStream tente de parser la valeur en long avant d'évaluer les fonctions JMeter — ce qui provoque :

Caused by: java.lang.NumberFormatException: For input string: "${__P(duration,300)}"

Règle : Toujours déclarer ThreadGroup.duration en <stringProp>, jamais <longProp> :

<!-- ✅ Correct -->
<stringProp name="ThreadGroup.duration">${__P(duration,300)}</stringProp>

<!-- ❌ Provoque NumberFormatException avec les variables ${__P(...)} -->
<longProp name="ThreadGroup.duration">${__P(duration,300)}</longProp>

2.2 Les stringProp dans les collectionProp doivent avoir un attribut name

Problème : Dans les ResponseAssertion, les codes HTTP sont stockés dans une collectionProp. Chaque <stringProp> enfant doit avoir un attribut name correspondant au hashCode Java de sa valeur, sinon XStream lève :

Caused by: java.lang.NullPointerException
    at ConversionHelp.getUpgradePropertyName(ConversionHelp.java:253)

Règle : Utiliser le hashCode Java de la valeur comme attribut name :

<!-- ✅ Correct -->
<collectionProp name="Asserion.test_strings">
    <stringProp name="49586">200</stringProp>
</collectionProp>

<!-- ❌ Provoque NullPointerException -->
<collectionProp name="Asserion.test_strings">
    <stringProp>200</stringProp>
</collectionProp>

Hashcodes des codes HTTP courants (Java String.hashCode()) :

Code HTTP name à utiliser
200 49586
201 49587
400 51508
401 51509
403 51511
404 51512
409 51517
500 52469
503 52472

Étape 3 — Extraction JSON sans plugin (JSR223PostProcessor)

JSONPathExtractor non disponible dans l'image PerfShop

JSONPathExtractor est fourni par le plugin bzm-json (JMeter Plugins Manager). Ce plugin n'est pas installé dans l'image justb4/jmeter:5.5 utilisée par PerfShop. Son utilisation provoque :

Caused by: com.thoughtworks.xstream.mapper.CannotResolveClassException: JSONPathExtractor

Solution : Utiliser un JSR223PostProcessor avec Groovy natif. Groovy et JsonSlurper sont intégrés dans JMeter 5.5 sans aucun plugin.

3.1 Pattern d'extraction JSON

<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor"
    testname="Extract securityToken" enabled="true">
  <stringProp name="scriptLanguage">groovy</stringProp>
  <stringProp name="parameters"></stringProp>
  <stringProp name="filename"></stringProp>
  <stringProp name="cacheKey">true</stringProp>
  <stringProp name="script">
try {
    def json = new groovy.json.JsonSlurper().parseText(prev.getResponseDataAsString())
    def val = json.securityToken
    vars.put("securityToken", val != null ? val.toString() : "NO_TOKEN")
} catch(Exception e) {
    vars.put("securityToken", "NO_TOKEN")
}
  </stringProp>
</JSR223PostProcessor>
<hashTree/>

3.2 Exemples de chemins JSON courants

JSONPath équivalent Accès Groovy
$.securityToken json.securityToken
$.content[0].id json.content[0].id
$.orderNumber json.orderNumber
$.totalPages json.totalPages
$.id json.id

3.3 Dans JMeter GUI

Pour créer ce post-processor graphiquement : clic droit sur le sampler → Add → Post Processors → JSR223 Post Processor


Étape 4 — Ajouter le Prometheus Listener

4.1 Position dans l'arbre

Le PrometheusListener doit être enfant direct du Thread Group :

Thread Group
└── Prometheus Listener  ← ✅ collecte toutes les métriques du ThreadGroup

Et non :

Test Plan
└── Prometheus Listener  ← ❌ hors ThreadGroup → métriques muettes

4.2 Configuration dans le JMX

<com.github.johrstrom.listener.PrometheusListener
    guiclass="com.github.johrstrom.listener.gui.PrometheusListenerGui"
    testclass="com.github.johrstrom.listener.PrometheusListener"
    testname="Prometheus Metrics Exporter" enabled="true">
  <stringProp name="prometheus.port">${__P(prometheus.port,9270)}</stringProp>
  <stringProp name="prometheus.save.threads">true</stringProp>
  <stringProp name="prometheus.save.jvm">false</stringProp>
  <collectionProp name="prometheus.collector_definitions">
    <!-- jmeter_transactions_total (COUNTER) -->
    <elementProp name="" elementType="com.github.johrstrom.listener.ListenerCollectorConfig">
      <stringProp name="collector.metric_name">jmeter_transactions_total</stringProp>
      <stringProp name="collector.type">COUNTER</stringProp>
      <collectionProp name="collector.labels">
        <stringProp name="102727412">label</stringProp>
        <stringProp name="3059181">code</stringProp>
      </collectionProp>
      <stringProp name="listener.collector.listen_to">samples</stringProp>
      <stringProp name="listener.collector.measuring">CountTotal</stringProp>
    </elementProp>
    <!-- jmeter_response_time (SUMMARY P50/P95/P99) -->
    <elementProp name="" elementType="com.github.johrstrom.listener.ListenerCollectorConfig">
      <stringProp name="collector.metric_name">jmeter_response_time</stringProp>
      <stringProp name="collector.type">SUMMARY</stringProp>
      <collectionProp name="collector.labels">
        <stringProp name="102727412">label</stringProp>
      </collectionProp>
      <stringProp name="collector.quantiles_or_buckets">0.5,0.05|0.95,0.01|0.99,0.001</stringProp>
      <stringProp name="listener.collector.listen_to">samples</stringProp>
      <stringProp name="listener.collector.measuring">ResponseTime</stringProp>
    </elementProp>
    <!-- jmeter_success_ratio -->
    <elementProp name="" elementType="com.github.johrstrom.listener.ListenerCollectorConfig">
      <stringProp name="collector.metric_name">jmeter_success_ratio</stringProp>
      <stringProp name="collector.type">SUCCESS_RATIO</stringProp>
      <collectionProp name="collector.labels">
        <stringProp name="102727412">label</stringProp>
      </collectionProp>
      <stringProp name="listener.collector.listen_to">samples</stringProp>
      <stringProp name="listener.collector.measuring">SuccessRatio</stringProp>
    </elementProp>
  </collectionProp>
</com.github.johrstrom.listener.PrometheusListener>
<hashTree/>

Étape 5 — CSV Data Set et variables

Pour les scénarios multi-utilisateurs, utilisez un CSVDataSet avec shareMode.thread (un compte différent par thread) :

<CSVDataSet guiclass="TestBeanGUI" testclass="CSVDataSet"
    testname="Comptes utilisateurs" enabled="true">
  <stringProp name="filename">/scripts/users.csv</stringProp>
  <stringProp name="fileEncoding">UTF-8</stringProp>
  <stringProp name="variableNames">userEmail,userPassword,userName</stringProp>
  <stringProp name="delimiter">,</stringProp>
  <boolProp name="ignoreFirstLine">true</boolProp>
  <boolProp name="recycle">true</boolProp>
  <boolProp name="stopThread">false</boolProp>
  <stringProp name="shareMode">shareMode.thread</stringProp>
</CSVDataSet>
<hashTree/>

Le fichier users.csv est monté dans le container sur /scripts/users.csv.


Étape 6 — Uploader et lancer dans PerfShop

  1. Sauvegarder le .jmx : File → Save Test Plan As
  2. Ouvrir PerfShop JMeter UI → 🗂️ Gérer
  3. Choisir le dossier cible → 📤 → sélectionner le .jmx
  4. Lancer avec ▶ Lancer le tir

Les métriques apparaissent dans le dashboard Grafana perfshop-jmeter-live au bout de ~10 secondes, ventilées par nom de TransactionController.


Récapitulatif — Checklist avant upload

Point de contrôle ✅ Attendu
target_host ${__P(target_host,perfshop-app)}
target_port ${__P(target_port,8080)} — jamais 9080
ThreadGroup.duration <stringProp> — jamais <longProp> avec ${__P(...)}
stringProp dans collectionProp Attribut name avec hashCode Java de la valeur
Extraction JSON JSR223PostProcessor Groovy — jamais JSONPathExtractor (plugin absent)
TransactionController parent=true sur chaque étape métier
PrometheusListener Enfant du Thread Group, pas du Test Plan
Port Prometheus ${__P(prometheus.port,9270)}
Think time GaussianRandomTimer entre chaque transaction
Pas d'URL en dur Ni localhost, ni IP fixe, ni hostname public

Étape 7 — Guide pas-à-pas via JMeter GUI

Ce guide montre comment créer un scénario parcours métier complet depuis l'interface graphique JMeter, sans éditer le XML à la main.

7.1 Créer le plan de test

  1. Lancer JMeter : jmeter.bat (Windows) ou jmeter (Linux/Mac)
  2. File → New pour un plan vierge
  3. Cliquer sur Test Plan → renommer : PerfShop — Mon scénario
  4. Dans le panneau du bas, cocher Functional Test Mode : Non

7.2 Ajouter un Thread Group

Clic droit sur Test Plan → Add → Threads (Users) → Thread Group

Paramètre Valeur recommandée
Number of Threads ${__P(users,5)}
Ramp-up period ${__P(ramp_time,30)}
Duration ${__P(duration,300)}
Loop Count -1 (infini avec scheduler)
Scheduler ✅ Coché
On Sample Error Continue

Ne jamais mettre un nombre fixe pour users/duration

Utilisez toujours ${__P(users,5)} — cela permet à l'interface JMeter UI de PerfShop de piloter le nombre de vUsers et la durée sans modifier le JMX.

7.3 Ajouter les éléments de configuration

Clic droit sur Thread Group → Add :

HTTP Request Defaults (Config Element → HTTP Request Defaults)

Champ Valeur
Server Name ${__P(target_host,perfshop-app)}
Port ${__P(target_port,8080)}
Protocol ${__P(target_protocol,http)}
Encoding UTF-8

HTTP Header Manager (Config Element → HTTP Header Manager)

Ajouter deux headers :

Name Value
Content-Type application/json
Accept application/json

HTTP Cookie Manager (Config Element → HTTP Cookie Manager)

  • Clear cookies each iteration : ✅

CSV Data Set Config (Config Element → CSV Data Set Config)

Champ Valeur
Filename /scripts/users.csv
Variable Names userEmail,userPassword,userName
Delimiter ,
Ignore first line
Recycle on EOF
Share Mode shareMode.all

shareMode.all — évite les collisions de comptes

Avec shareMode.thread, chaque thread progresse indépendamment et plusieurs threads peuvent tomber sur le même compte simultanément. Avec shareMode.all, tous les threads partagent un curseur global qui avance séquentiellement — chaque thread reçoit un compte différent à chaque itération. Indispensable pour éviter l'erreur "Commande en double détectée" du backend PerfShop (cooldown anti-doublon de 3 secondes par user+panier).

7.4 Ajouter une transaction

Pour chaque étape métier (T01, T02...):

  1. Clic droit sur Thread Group → Add → Logic Controller → Transaction Controller
  2. Nommer : T01_Homepage
  3. Cocher Generate parent sample : ✅ (c'est le parent=true indispensable pour Grafana)
  4. Clic droit sur la transaction → Add → Sampler → HTTP Request
  5. Configurer la requête : méthode, path, body

7.5 Ajouter un think time (GaussianRandomTimer)

Clic droit sur Thread Group → Add → Timer → Gaussian Random Timer

Champ Valeur
Constant Delay 2000 (ms)
Deviation 600 (ms)

Placer ce timer après chaque TransactionController (au niveau du Thread Group, pas à l'intérieur).

7.6 Ajouter une extraction JSON (JSR223PostProcessor)

Clic droit sur le sampler HTTP → Add → Post Processors → JSR223 Post Processor

  • Script Language : groovy
  • Cache compiled script if possible : ✅
  • Script :
try {
    def json = new groovy.json.JsonSlurper().parseText(prev.getResponseDataAsString())
    def val = json.securityToken
    vars.put("securityToken", val != null ? val.toString() : "NO_TOKEN")
} catch(Exception e) {
    vars.put("securityToken", "NO_TOKEN")
}

7.7 Ajouter une assertion de réponse

Clic droit sur le sampler HTTP → Add → Assertions → Response Assertion

  • Field to Test : Response Code
  • Pattern Matching Rules : Equals
  • Patterns to test : 200

Pour vérifier 200 ou 201 (OR) : - Patterns to test : ajouter 200 et 201 - Pattern Matching Rules : OR (cocher "OR")

7.8 Ajouter une assertion Groovy (JSR223Assertion)

Pour des vérifications complexes (ex: canCheckout = true) :

Clic droit sur le sampler → Add → Assertions → JSR223 Assertion

try {
    def json = new groovy.json.JsonSlurper().parseText(prev.getResponseDataAsString())
    if (json.canCheckout != true) {
        AssertionResult.setFailure(true)
        AssertionResult.setFailureMessage("canCheckout = false")
    }
} catch(Exception e) {
    AssertionResult.setFailure(true)
    AssertionResult.setFailureMessage("JSON invalide : " + e.getMessage())
}

Jamais JSONPathAssertion dans PerfShop

Même règle que JSONPathExtractor : le plugin bzm-json est absent. Remplacer systématiquement par JSR223Assertion Groovy.

7.9 Ajouter un JSR223PreProcessor (avant un sampler)

Pour préparer des variables dynamiques avant l'exécution d'un sampler :

Clic droit sur le sampler → Add → Pre Processors → JSR223 Pre Processor

Exemple — sélectionner un mode de livraison au hasard sans __chooseRandom :

def methods = ["standard", "standard", "express", "premium"]
def r = new Random()
vars.put("shippingMethodToUse", methods[r.nextInt(methods.size())])

PreProcessor enfant du sampler : s'exécute AVANT le sampler

Un JSR223PreProcessor positionné comme enfant d'un HTTPSamplerProxy s'exécute avant ce sampler. Les variables qu'il définit sont disponibles dans le body de ce sampler.

7.10 Ajouter le Prometheus Listener

Clic droit sur Thread Group → Add → Listener → jp@gc - Prometheus Listener

  • Port : ${__P(prometheus.port,9270)}
  • Save threads : ✅
  • Save JVM : ☐

Ajouter trois métriques (bouton +) :

Metric Name Type Labels Measuring
jmeter_transactions_total COUNTER label, code CountTotal
jmeter_response_time SUMMARY label ResponseTime
jmeter_success_ratio SUCCESS_RATIO label SuccessRatio

Pour jmeter_response_time, ajouter les quantiles : 0.5,0.05|0.95,0.01|0.99,0.001

7.11 Sauvegarder et tester localement

  1. File → Save Test Plan As → nommer mon-scenario.jmx
  2. Lancer localement : Run → Start (ou Ctrl+R)
  3. Vérifier dans View Results Tree que les assertions passent
  4. Uploader dans PerfShop JMeter UI → 🗂️ Gérer → 📤

Étape 8 — Pièges supplémentaires découverts sur PerfShop

Ces pièges sont issus de la mise au point du scénario parcours-metier-nominal.jmx.

❌ Piège 6 — Scripts Groovy sur une seule ligne avec \n littéraux

Symptôme :

startup failed:
Script166.groovy: 1: Unexpected character: '\' @ line 1, column 6.
   try {\n    def json = new groovy.json.JsonSlurper()...

Cause : Quand un JMX est édité manuellement et que le script Groovy est mis sur une seule ligne avec des \n littéraux (backslash + n), JMeter 5.5 / Groovy 3.0 ne les interprète pas comme des sauts de ligne. Le Groovy est alors syntaxiquement invalide.

<!-- ❌ MAUVAIS — \n littéraux, script sur une ligne -->
<stringProp name="script">try {\n    def json = ...\n} catch(Exception e) {\n}</stringProp>

<!-- ✅ CORRECT — vrais sauts de ligne dans le XML -->
<stringProp name="script">
try {
    def json = new groovy.json.JsonSlurper().parseText(prev.getResponseDataAsString())
    vars.put("myVar", json.myField?.toString() ?: "default")
} catch(Exception e) {
    vars.put("myVar", "default")
}
</stringProp>

Règle : Les scripts Groovy dans les stringProp doivent toujours contenir de vrais sauts de ligne XML.

❌ Piège 7 — __chooseRandom avec virgule finale génère des valeurs vides

Symptôme : Requêtes avec 400/422, body JSON avec des champs vides ("street":"").

Cause : __chooseRandom(val1,val2,val3,) — la virgule finale est interprétée comme un item supplémentaire vide. Quand JMeter tire le dernier item, la variable vaut "" et le backend rejette le JSON.

# ❌ MAUVAIS — virgule finale
${__chooseRandom(standard,express,premium,)}

# ✅ CORRECT — sans virgule finale (mais déconseillé, voir ci-dessous)
${__chooseRandom(standard,express,premium)}

# ✅ ENCORE MIEUX — JSR223PreProcessor avec tableau Groovy
def methods = ["standard", "express", "premium"]
vars.put("method", methods[new Random().nextInt(methods.size())])

Règle : Ne jamais utiliser __chooseRandom dans PerfShop — toujours utiliser un JSR223PreProcessor avec un tableau Groovy.

❌ Piège 8 — && dans les fonctions JMeter inline cause XmlPullParserException

Symptôme :

Caused by: org.xmlpull.v1.XmlPullParserException: entity reference names can not start with character '&'

Cause : Le caractère & dans un attribut XML doit être échappé en &amp;. Dans un stringProp, && (AND logique Groovy) est du XML invalide. Le parser XStream (XmlPullParser) le rejette.

<!-- ❌ MAUVAIS — && invalide dans stringProp XML -->
<stringProp name="Argument.value">{"id":${__groovy(vars.get('a') != null && !vars.get('a').equals('0') ? vars.get('a') : '1',)}}</stringProp>

<!-- ✅ CORRECT — JSR223PreProcessor séparé AVANT le sampler -->
// Dans un JSR223PreProcessor enfant du sampler :
def a = vars.get("a")
vars.put("idToUse", (a != null && !a.equals("0")) ? a : "1")

Règle : Toute logique conditionnelle doit être dans un JSR223PreProcessor séparé. Ne jamais mettre de &&, ||, ou <> dans une stringProp sans échappement XML.

❌ Piège 9 — Protection anti-doublon du backend (HTTP 500)

Symptôme : POST /api/orders retourne 500 avec "Commande en double détectée" en charge.

Cause : Le backend PerfShop implémente une protection anti-double-clic (anomalie A5). En mode nominal (chaos level 0), si le même utilisateur crée deux commandes avec le même contenu de panier dans une fenêtre de 3 secondes, la deuxième est rejetée avec une RuntimeException → Spring retourne HTTP 500.

En charge avec plusieurs threads partageant le même compte (par ex. user1@perfshop.com), cette fenêtre est facilement dépassée.

Solution JMX : Utiliser shareMode.all dans le CSV Data Set pour que les threads se partagent les comptes de façon séquentielle et n'aient jamais le même utilisateur actif.

Solution assertion : Accepter le 500 doublon comme un succès conditionnel via JSR223Assertion :

def code = prev.getResponseCode()
if (code == "500") {
    def body = prev.getResponseDataAsString()
    if (body.contains("doublon") || body.contains("duplicate")) {
        // Doublon anti-double-clic — comportement normal en charge
        // Ne pas faire échouer la transaction
        return
    }
}
// Vérifier le succès normal
def json = new groovy.json.JsonSlurper().parseText(prev.getResponseDataAsString())
if (json.success != true) {
    AssertionResult.setFailure(true)
    AssertionResult.setFailureMessage("success=false : " + prev.getResponseDataAsString().take(200))
}

❌ Piège 10 — orderId vide dans l'URL cause URISyntaxException

Symptôme :

Non HTTP response code: java.net.URISyntaxException

Cause : Si T08 (création commande) échoue, ${orderId} reste vide. L'URL /api/orders//details est syntaxiquement invalide pour java.net.URI.

Solution : Ajouter un JSR223PreProcessor avant le sampler GET /api/orders/${orderIdToFetch}/details qui met un fallback "0" si orderId est absent :

def oid = vars.get("orderId")
if (oid == null || oid.equals("") || oid.equals("0")) {
    vars.put("orderIdToFetch", "0")
} else {
    vars.put("orderIdToFetch", oid)
}

Utiliser ${orderIdToFetch} dans le path au lieu de ${orderId}.


Récapitulatif complet — Règles définitives JMX PerfShop

Règle Description
shareMode.all CSV Data Set — distribution séquentielle des comptes entre threads
✅ Scripts Groovy multiligne Vrais sauts de ligne dans les stringProp script
JSR223PreProcessor pour la logique Jamais de &&, ||, __groovy() inline dans stringProp
JSR223PostProcessor pour l'extraction Jamais JSONPathExtractor (plugin absent)
JSR223Assertion pour les assertions JSON Jamais JSONPathAssertion (plugin absent)
JSR223PreProcessor pour les données aléatoires Jamais __chooseRandom avec virgule finale
✅ Fallback sur les variables critiques Vérifier orderId, foundProductId, foundProductPrice avant utilisation dans une URL
TransactionController parent=true Indispensable pour la remontée Grafana
${__P(...)} pour tous les paramètres pilotables Jamais de valeur en dur pour users, duration, target_host
<stringProp> pour ThreadGroup.duration Jamais <longProp> avec ${__P(...)}
✅ Attribut name hashCode sur les stringProp dans collectionProp Évite le NPE XStream
✅ PrometheusListener enfant du ThreadGroup Pas du TestPlan