Über unsMediaKontaktImpressum
Moritz Salein 20. Juni 2023

Continuous Performance-Testing

Ein Quality Gate mit JMeter in der CI/CD-Pipeline

Wer sich mit Performance-Testing genauer beschäftigt und gleichzeitig mit Themen wie DevOps und CI/CD arbeitet, scheint auf den ersten Blick mit zwei Extremen konfrontiert. Wie passen langlaufende Performance-Tests und die schnelle Ergebnisermittlung einer CI/CD-Pipeline zusammen? Welchen Ansatz gibt es, diesen beiden scheinbaren Gegensätzen mit einer guten Lösung zu begegnen? Mit diesem Artikel möchte ich aufzeigen, wie Sie mit JMeter und GitHub Actions den schwierigen Spagat schaffen und diese wichtige, nicht-funktionale Testart in Ihren kontinuierlichen Entwicklungsprozess integrieren können.

Continuous Performance-Testing

Eines gleich vorweg: Der Begriff Continuous Performance-Testing bzw. CPT wird in einigen Publikationen von Herstellern oder Lösungen aus dem Bereich Application Performance Monitoring (APM) verwendet [1]. Häufig ist mit dem Begriff eine ständige Überwachung der Systeme gemeint. Durch Setzen von geeigneten Schwellwerten soll diese APM Benachrichtigungen erzeugen, um daraus Maßnahmen abzuleiten, die Verbesserungen in Bezug auf die Performance ermöglichen. Ich möchte mit meinem Artikel nicht auf diese Möglichkeiten des APM eingehen, ebenso wenig die Wichtigkeit dieser Analysewerkzeuge in Frage stellen [2]. Der Artikel soll zeigen, wie die Testart Performance-Testing in einen hochintegrierten Prozess mit weitgehender Automatisierung integriert wird, um beispielsweise in den Lösungen der CI/CD-Pipeline und den DevOps-Ansätzen einen möglichen Weg zu finden [3].

Problemstellung

Seit einiger Zeit beschäftige ich mich mit dem Thema Performance-Testing innerhalb der Qualitätssicherung. Ich habe mich darauf spezialisiert, intensiv mit Vorgehensweisen und Werkzeugen zum Entwickeln von Performance-Tests zu arbeiten. Außerdem beschäftige ich mich stark mit der Art der Ausführung solcher Tests und der Aufbereitung und Darstellung in Reports, damit auch weniger fachkundige Interessierte die Ergebnisse lesen können. Denn viele meiner Performance-Tests aus der Vergangenheit lieferten eine große Zahl an Daten, Messreihen und darauf basierenden Auswertungen, aber nie waren die Ergebnisse ganz einfach mit einem "Test bestanden" bzw. "Test nicht bestanden" zu bewerten. Es waren immer relativierende und stark vom Kontext, also von den Erwartungen und Anforderungen, abhängige Ergebnisse.

Nun kam ich in Projekte, die hochintegrierte Entwicklungsprozesse eingeführt haben. Sie setzten weitestgehend auf Prozessautomatisierung, die in CI/CD-Pipelines abgebildet wurden. DevOps Engineering wurde immer häufiger zum Schlagwort und gab die Richtung der technischen Lösungen vor. Es war nicht mehr wie bei meinen ersten Performance-Test-Projekten, in denen ich eine gewisse Zeit meine Anforderungen an die Performanz ermittelt habe, dann die Skripte entwickelte und mehrfach eine ganze Reihe an gleichartigen aber auch unterschiedlichen Performance-Tests ausführen konnte und schließlich mehrere Tage an den Auswertungen und Aufbereitungen der gesammelten Daten saß, damit ich sprechende Bilder zu meinen Aussagen entwickeln konnte. Meine Aufgaben wandelten sich. Ich musste zusätzlich verstehen, wie CI/CD funktioniert und die Anforderungen auf die Performance-Tests ableiten, um daraus neue Lösungen zu entwickeln. Denn meine Performance-Tests-Puzzleteile, die ich aus Wasserfall und V-Modell gut verwenden konnte, passten nicht mehr so richtig in diese hoch integrierte Welt von DevOps und Pipeline. Auch wenn ich anfangs versuchte zu argumentieren, dass Performance-Tests ein anderes Puzzle sind als der Rest der Software-Entwicklung. Ich konnte mich nicht verwehren, neue Wege zu gehen, damit ich im komplexen Puzzle ein passendes Teil mit meinen Performance-Tests lieferte, um das Gesamtbild entstehen zu lassen.

Im Folgenden möchte ich ein paar grundsätzliche Definitionen über CI/CD-Pipelines in Bezug auf Performance-Tests geben. Dazu die geeigneten Performance-Testarten bestimmen und zuletzt meinen Ansatz einer Lösung beschreiben.

CI/CD-Pipeline

Prozesse steuern die Lösung. Um zu verstehen, welche Anforderungen an Performance-Testing eine CI/CD-Pipeline hat, muss ich mich erst mit dieser auseinandersetzen. Die CI/CD-Pipeline gibt für den Gesamtprozess der Software-Entwicklung vor, dass im Idealfall alle notwendigen Aktivitäten bis zur Fertigstellung des Produkts automatisiert werden. Für mich ist es ein Bild einer Fertigungsstraße. Es werden Build, statische Codeanalysen, Security-Checks, Unit-Test, das Erstellen eines ausführbaren Programms, funktionale Komponenten- und Integrationstests in diese Fertigungsstraße eingebaut, getaktet und nur im Zusammenspiel erhalten wir das fertige Produkt. Funktioniert eines dieser Bauteile nicht, dann fangen wir wieder von vorne an. Jede dieser Aktivitäten hat einen definierten Anfang, der nur starten kann, wenn die vorausgegangene Aktivität erfolgreich war. Somit muss jede Aktivität zum Ende mit einem eindeutigen "erfolgreich"/"nicht erfolgreich" abschließen. Bei nicht erfolgreicher Ausführung meldet die Aktivität das fehlerhafte Ergebnis einem Akteur, der das Ergebnis analysieren und in die Fertigungsstraße eingreifen kann oder das Produkt – also im Grunde den Quellcode der Anwendung – anpasst, um den kompletten Prozess von vorn beginnen zu lassen.

In diesen Gesamtprozess gehören auch vollumfängliche Qualitätssicherungsmaßnahmen. Damit sind Code-Analysen, entwicklungsnahe Unit-Tests, funktionale API- und UI-Tests gemeint. Aber auch ein Teil der Pipeline müssen Tests von nicht-funktionalen Anforderungen sein, wie das Messen der Performanz und andere Prüfungen von Produktqualitäts-Aspekten. Nur, wie lässt sich dies integrieren? Welche Schwierigkeiten existieren dabei?

Grundlagen – Performance-Testarten

Ich habe erst hier verstanden, dass ich mich trotz mehrjähriger Erfahrung im Bereich Performance-Testing nochmals ganz theoretisch mit diesen nicht-funktionalen Tests auseinandersetzen muss, damit ich verstehe, wie ich eine passende Lösung finde. Hier hilft natürlich ein Blick auf Normen und Zertifizierungen – wie ISO und ISTQB – die in diesem Bereich angeboten werden.

ISO 25010-2016

In den ISO-Normen sind Aspekte definiert worden, die zur Bestimmung der Software-Produktqualität dienen und den Bereich der Performanz betreffen. So ist in der ISO 25010 bei mindestens zwei der acht Hauptaspekte die Performanz adressiert. Zu den Performanz-Aspekten zählen Zuverlässigkeit und Effizienz und die dazugehörenden Unteraspekte, wie z. B. Antwortverhalten. All diese Aspekte sind normalerweise gut durch die Ausführung von Performance-Tests abprüfbar.

ISTQB

Die ISTQB, eine Organisation zur Standardisierung des Software-Tests, beschreibt in ihrem Ausbildungslehrplan zu Performance-Testing acht unterschiedliche Testarten [4].

Um zu verstehen, welche Testarten geeignet für eine CI/CD-Pipeline sind, habe ich die dafür wesentlichen Testarten auf eine Zeitachse gelegt. Diese zeigt gut, welchen zeitlichen Verlauf die unterschiedlichen Testarten für gewöhnlich nehmen. Viele von ihnen benötigen einen langen zeitlichen Verlauf. Nur welche davon können so verkürzt werden, dass noch eine sinnvolle Aussage erreicht wird? Im Folgenden möchte ich auf die Ziele der unterschiedlichen Testarten eingehen und bewerten, ob und wie diese Testarten in einem sehr kurzen Zeitraum zur Ausführung gebracht werden können.

1. Performanztest (Performance-Testing)

Im Wesentlichen ist es der Sammelbegriff aller unterschiedlicher Testarten, die sich mit den nicht-funktionalen Anforderungen zur Performanz und Last beschäftigen. Somit sehe ich hier keine Einordnung möglich.

2. Lasttest (Load-Testing)

Der Lasttest befasst sich mit einer realistisch zu erwartenden Last, die durch eine kontrollierte Anzahl an Benutzern erzeugt wird. Diese Last ist höher als die normal erwartete Auslastung bzw. Grundlast und geringer als die maximale Auslegungskapazität. Sie hat einen definierten Anfang und ein Ende. Eines der Ziele dabei ist, dass das zu testende System in seinen definierten Grenzen einwandfrei funktionieren soll. Als weiteres soll die Last nicht sofort erreicht, sondern ansteigend abgebildet werden, endend auf einem Plateau.. Idealerweise erhalten wir keine Fehler oder stark auffällige Abweichungen zum Normalen in einer späteren Auswertung der gesammelten Daten.

Somit kann diese Testart sehr gut in einer CI/CD Verwendung finden. Es ist in einer Pipeline gut abbildbar, kurzzeitig die definierte Last zu generieren und dann aus den gesammelten Daten herauszufinden, ob sie sich im definierten Bereich befinden.

3. Stresstest (Stress-Testing)

Bei einem Stresstest soll das Lastprofil, also die Anzahl der abgesendeten Anfragen, immer weiter erhöht werden. Somit werden unterschiedliche Aussagen zu dem Systemverhalten zu unterschiedlichen Lasten erreicht. Man geht dabei so weit, dass die maximale Auslegungskapazität überschritten wird. Damit können das Systemverhalten im Bereich der maximalen Auslegungskapazität und auftretende Fehler festgestellt werden. Welche Fehler sich zeigen, wenn über die Grenze gegangen wird. Außerdem erhalte ich die Möglichkeit, die gesammelten Daten zu unterschiedlichen Lasten zu vergleichen. Da hierzu eine ganze Reihe von Messungen erfolgen muss, kann diese Testart kaum in einer kurzen Zeitdauer ausgeführt werden und eignet sich nicht für die Ausführung in einer Pipeline, da auch die provozierten und teilweise unvorhersehbaren Fehler kaum auswertbar sind.

4. Lastspitzentest (Spike-Testing)

Hierbei prüfen wir die Auswirkung von kurzzeitig auftretenden Lastspitzen auf das System und ob nach dem Ereignis das Gesamtsystem zu einem stabilen Zustand zurückkehrt. Auf den ersten Blick scheint diese Testart geeignet zu sein – kurze Laufzeit, definierte Zustände. Aber durch die unvorhersehbare Auswirkung der Lastspitzen kann schlecht definiert werden, in welcher Zeit ein stabiles System erreicht wird. Außerdem sind Lastspitzen – so kurz sie auch erscheinen mögen – in der Simulation teilweise auch nur sehr aufwändig herzustellen, gerade in der Länge der Laufzeit.

5. Dauertest (Endurance-Testing)

Beim Dauertest sagt schon der Name die Langläufigkeit eines Tests aus. Damit scheidet auch diese Testart für eine CI/CD-Pipeline aus.

6. Kapazitätstest (Capacity-Testing)

Hierbei bestimme ich, wie viele Nutzer oder Transaktionen ein System unterstützt. Dadurchj prüfe ich die Grenzen und kann damit Aussagen treffen, in welchen Bereichen das Gesamtsystem befriedigende Leistungswerte erreicht. Somit stelle ich fest, wie weit die tatsächliche und die zuvor definierte Auslegungskapazität entfernt liegen. Diese Tests bedürfen meist mehrerer Testläufe, bei denen eine Annäherung an die Grenzen erfolgt. Deshalb sehe ich hier auch keinen Ansatzpunkt für eine CI/CD-Integration.

7. Skalierbarkeitstest (Scalability-Testing)

Skalierbarkeitstest sind für mich noch etwas speziellere Stresstests. Zum einen sollen die Grenzen der Skalierbarkeit, also die maximale Auslegungskapazität, festgestellt werden. Zum anderen erfolgt hier eine Bestimmung und Prüfung, wie eine Skalierungsplattform dynamische Ressourcen dazuschalten kann. Hiermit wird nachgewiesen, wann mehr Systemleistung, z. B. durch CPU und Speicher, hinzugefügt werden sollte oder wann die Architektur auf Bedarf weitere Services hinzuschaltet, damit rechtzeitig höhere Lasten abgedeckt werden. So können diese Services, wie z. B. Kubernetes, geprüft werden. Hier sehe ich auch keinen Ansatzpunkt, wie in der CI/CD eine gute Lösung implementieren lässt.

8. Nebenläufigkeitstest (Concurrency-Testing)

In diesem Test sollen die Auswirkungen von untereinander beeinflussenden gleichzeitigen Aktionen geprüft werden. Dies ist einfacher in einem Beispiel zu erklären. Unser System besteht aus einer Datenbank, in die ein Service viele Daten schreibt und wiederum ein Webclient, der davon viele Daten abfragt. Jetzt können schreibende und lesende Prozesse auf der Datenbank das System signifikant beeinflussen und die Gesamtperformance unter das Definierte sinken lassen. Für mich stellt diese Testart eine erweiterte und besondere Form eines Lasttests dar. Kein einzelner, sondern unterschiedliche Endpunkte werden geprüft, die gemeinsame Komponenten verwenden. Somit kann je nach Szenario auch diese Testart sinnvoll in eine Pipeline integriert werden.

Der Ansatz

Ich benötigte eine Lösung, die zwar die verschiedenen Qualitäts-Aspekte und Performanz-Ziele abdecken würde, jedoch in ganz kurzer Durchführungszeit ein Ergebnis lieferte, das einfach auswertbar ist. Bestenfalls durch die Meldung "erfolgreich" oder "fehlgeschlagen". Im Gegenzug werden eine ganze Menge Daten gesammelt, die bei Nicht-Erfolg zur Auswertung herangezogen werden können.

Technischer Rahmen

Als Werkzeug für die Szenarien nutzte ich JMeter, ein Open-Source-Tool, das speziell für Performance-Tests ausgelegt ist [5]. Die Ausführungspipeline läuft in GitHub Actions, einem der bekannteren Git-Clones in der Cloud, meines Erachtens selbst für Gelegenheits-Entwickler wie mich noch ganz gut anwendbar [6]. Nun brauche ich noch eine Ausführungsumgebung bzw. Testmittel. Hier entschied ich mich, ein vorgefertigtes Runner-Image von GitHub zu verwenden, in dem ich nur noch meine benötigten Tools installiere und dann JMeter zur Ausführung bringe [7]. Um die Lösung rund zu bekommen, entwickelte ich zusätzlich ein kleines Python-Script, das mir aus den JMeter-Logs den Gesamtstatus der Durchführung ermittelt [8].

JMeter-Projekt

Beim JMeter-Projekt handelt es sich um ein umgesetztes Szenario mit wenigen Schritten. Man ruft drei HTTP-Requests auf, die ein Login auf der zu testenden Webseite (SUT) durchführen, dann eine Seite mit einem Formular zur Kontaktanlage öffnen und zuletzt viele Kontakte auf dieser Seite anlegen. Die Kontakte werden aus einer sehr umfangreichen CSV-Kontaktdatenliste ausgelesen, damit hier etwas Varianz und keine Fehler wegen Doppelanlage erfolgen. Die Kontaktanlage wird so lange wiederholt, wie das Profil an Ausführungszeit vorgibt. Das JMeter-Projekt wird einfach in das Repository in GitHub gespeichert, in dem auch die nachfolgende GitHub Actions erstellt wird.

Eine kleine Besonderheit in unserem JMeter-Projekt existiert mit einem einfachen JSR223-Sampler mit eingebautem Groovy-Skript. Dieses fragt ab, ob in einem vorhergehenden Sampler ein Fehler auftrat und schreibt dies in ein error.log. Danach wird der Exit-Code von JMeter auf 0 zurückgesetzt, damit die gesamte Pipeline nicht gleich nach JMeter-Ausführung abbricht, sondern noch die Reports einsammelt und auswerten kann.

Code 1: JMeter-JSR223-Sampler zum Schreiben des error.log:

if (!prev.isSuccessful()) {
    OUT.println("Script execution error")
    File file = new File(vars.get('base_Dir') + File.separator + 'error.log')
        file.write "Script execution error"
    System.exit(0)

GitHub Actions

Die GitHub Action besteht aus einem sequenziell ablaufenden Job, der im Wesentlichen vier Hauptaktivitäten beinhaltet:

  1. Installieren von Runner-Image mit Ressourcen und benötigten Tools
  2. Ausführen des JMeter-Tests
  3. Erstellen der JMeter-Reports und Ablegen als GitHub Actions Artifact
  4. Ausführen des Python-Assertions-Skripts zum Ermitteln des Ausführungsstatus'

Auch in der folgenden GitHub Actions YAML-Datei existiert eine Besonderheit. Einige JMeter-Parameter habe ich durch GitHub Actions Secrets ersetzt und kann diese somit von außen steuern. Insbesondere die secrets.NUM_THREADS, damit sind die simulierten und parallelen JMeter-Benutzer gemeint, und die secrects.DURATION, welche die Laufzeit des JMeter-Projekts angibt. Dies ermöglicht eine gute Steuerung, um flexibler auf die Projekteigenheiten innerhalb der Pipeline einzugehen.

Code 2: GitHub Actions YAML-Datei

name: Run JMeter CI/CD test

on:
  push:
   branches:
     - main
  workflow_dispatch:

jobs:
  run_test:
    runs-on: ubuntu-20.04
    env:
      MAX_90_PCT: ${{ secrets.MAX_90_PCT }} # A value for the python assertion at the end of this action, e.g. 5000
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-java@v3
        with:
          distribution: 'zulu' # See 'Supported distributions' for available options
          java-version: '19'
      - name: Download JMeter
        run: wget dlcdn.apache.org//jmeter/binaries/apache-jmeter-5.5.zip
      - name: Extract JMeter ZIP archive
        run: unzip apache-jmeter-5.5.zip
      - name: Run JMeter Tests
        run: apache-jmeter-5.5/bin/jmeter -n -t local-test-plan.jmx -l log.jtl -Jnum_Threads=${{ secrets.NUM_THREADS }} -JURL=${{ secrets.URL }} -JProtocol=${{ secrets.PROTOCOL }} -JPort=${{ secrets.PORT }} -JDuration=${{ secrets.DURATION }} # In the different secrets are externally controlled parameters
      - name: Archive JMeter JTL raw log file as an artifact
        uses: actions/upload-artifact@v3
        with:
          name: JTL
          path: |
            log.jtl
      - name: Create JMeter HTML report
        run: apache-jmeter-5.5/bin/jmeter -g log.jtl -o reports/html
      - name: Archive JMeter HTML report as an artifact
        uses: actions/upload-artifact@v3
        with:
          name: reports
          path: |
            reports/html
      - name: Run assertions
        run: python assertions.py

Python Assertions

Die Prüfung innerhalb des Python-Scripts umfasst mehrere Bereiche:

  1. Prüft innerhalb der JMeter statistics.json, ob der 90 Percentiels nicht den vorgegebenen Wert aus der GitHub Actions überschreitet.
  2. Prüft, ob von JMeter eine error.log geschrieben wurde, die auf einen Fehler zeigt, den wir aus JMeter erhalten haben (z. B. bei Erhalt von HTTP Status-Codes 500).

Code 3: Python assertions.py

import os
import json

MAX_90_PCT = int(os.getenv("MAX_90_PCT")) if os.getenv("MAX_90_PCT") else 1000

found_errors = 0

def assertion(condition, on_success_message, on_error_message):
    if not condition:
        print("Assertion failed: " + on_error_message)
        global found_errors
        found_errors += 1
    else:
        print("Assertion passed: " + on_success_message)

with open("./reports/html/statistics.json") as f:
    # Load JMeter common statistics JSON file
    jDoc = json.load(f)
    # Extract statistics that summarize all requests
    stat_total = jDoc["Total"]
    # Check assertions of the 90 percentile 
    assertion(
        stat_total["pct1ResTime"] <= MAX_90_PCT,
        f"90% percentile of all requests was {stat_total['pct1ResTime']} ms which is lower than the maximum value of {MAX_90_PCT} ms",
        f"90% percentile of all requests should be at maximum {MAX_90_PCT} ms, but is {stat_total['pct1ResTime']} ms"
    )

# Check for existance of JMeter error.log
if os.path.exists("error.log"):
    found_errors += 1
    with open("error.log") as f:
        content = f.read()
        print("Errors in script execution:")
        print(content)

# Exit with code 1 if there were errors or missed assertions
if found_errors > 0:
    exit(1)

Gerade das Prüfen von weiteren Werten aus der statistics.json kann hier in der Python-Assertions ergänzt werden, damit eine noch vollständigere Bewertung des Tests vorgenommen werden kann.

Nun können alle Bestandteile zusammenspielen und so regelmäßig innerhalb der Pipeline eine Qualitätsaussage zu Performanz und Lastverhalten geben.

Zusammenfassung & Fazit

Durch diese Lösung wurde erreicht, einen Ansatz eines Continuous Performance-Testings zu implementieren, der es ermöglicht, in der kurzen vorgegebenen Zeit diesen speziellen nicht-funktionalen Test in einer CI/CD-Pipeline auszuführen. Wenn günstige Rahmenbedingungen vorhanden sind, darunter verstehe ich den Willen, diese Performance-Tests auf ein Minimum an Requests zu reduzieren und die Möglichkeit, ein paar individuelle Skripte zu erstellen, dann kann damit auch ein weiterer wertvoller Beitrag in der Qualitätssicherung erfolgen. Die aufgezeigte Lösung ist auf die meisten Performance-Test-Tools übertragbar, beispielsweise auf Locust und andere CI/CD-Umgebungen, z. B. Jenkins. Auch das Assertions-Script ist keine Besonderheit, die sich nur in Python umsetzen lässt.

In der Zukunft möchte ich noch weitere und speziellere Prüfungen einbauen, die nicht nur Daten auf die JMeter-Zusammenfassung auswerten, sondern auch auf der Ebene von Einzelrequests bzw. Clustern von Requests erfolgen. Diese Daten werden jetzt schon in die JMeter-JTL-Datei geschrieben, nur die Abfragen werden auf dieser Basis komplexer und die Performanz-Vorgaben müssen differenzierter ermittelt werden, um zu einem eindeutigen Ergebnis zu kommen.

Autor
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben