Startseite

Performance Test Suite

Meine Test Suite dient dazu, die Performance und Funktion von HTTP basierten Diensten zu überprüfen. Sie erstellt einen Bericht über die Antwortzeiten und die Erfolgsquote der einzelnen Testfälle.

Download: Test Suite Quelltext

Motivation

Eine wichtiger Teil bei der Entwicklung von Enterprise Web Anwendungen ist das automatisierte Testen. Das gelingt mit Software wie Katalon Studio recht gut, allerdings kann damit keine aussagekräftigen Last-Tests erzeugen. Derart komplexe Testautomaten sind üblicherweise nicht imstande, starke Server an ihre Leistungsgrenzen zu treiben.

Einfachere Testautomaten, die ganz dumm eine Folge von vordefinierten HTTP Requests senden, können zwar gut Last generieren, aber sie eignen sich kaum für dynamische interaktive Anwendungen wie z.B. Online Shops. Dort hängt der nächste Schritt häufig vom Inhalt der aktuellen Seite ab. Besonders komplex sind single-page Anwendungen mit Frameworks, bei denen sich die Seitenstrukturen und ID Nummern der HTML/Formular Elemente ständig ändern.

Deswegen hatte ich vor einigen Jahren ein eigenes Konzept entwickelt, das ich nun veröffentliche, weil es sich in vielen sehr unterschiedlichen Projekten bewährt hat.

Anforderungen

Bei der Entwicklung spielten folgende Anforderungen eine wichtige Rolle:

Mit diesem Funktionsumfang ist die Test Suite zwar keine eierlegende Wollmilchsau, aber ein guter Anfang.

Wie man das Projekt benutzt

Das Projekt ist so aufgebaut, daß man es wahlweise mit Eclipse oder Netbeans bearbeiten kann. Man kann die Tests sowohl innerhalb der IDE als auch auf der Kommandozeile ausführen.

Ausführen

In der Entwicklungsumgebung ausführen

In NetBeans klickt man zum Ausführen mit der rechten Maustaste in den Quelltext oder auf den Dateinamen und dann auf "Run File". In Eclipse klickt man mit der rechten Maustaste auf den Dateinamen und dann auf "Run As Java Application".

Auf der Kommandozeile ausführen

Zuerst muss man das Projekt in der Entwicklungsumgebung als jar Datei exportieren. In Netbeans macht das die Build-Funktion, in Eclipse geht man dazu ins Menü File/Export.../Java/JAR-File. Diese Datei kopiert man dann auf einen Rechner mit guter Netzwerk-Anbindung. Dann gibt man dort folgendes ein:
java -cp TestSuite.jar my.test.suite.MeineTestSuite
Um einen anderen Server zu testen, kann man die konfigurierte SERVER_URL so übergehen:
java -DSERVER_URL="http://woanders.de" -cp TestSuite.jar my.test.suite.PerformanceTest
Oder alternativ kann man das JAR File auspacken (es ist in Wirklichkeit eine ZIP Datei), die Konfigurationsdatei editieren und dann das Programm so starten:
java my.test.suite.PerformanceTest

Berichte

Das Programm gibt seine Berichte immer auf die Konsole aus. Man kann sie bei Bedarf einfach in eine Datei umleiten. Wer die "PerformanceTest" Suite unverändert ausführt, wird folgenden Bericht erhalten:
-----------------------------------------------------------------------------
Report of PerformanceTest with 10 threads, 100ms delay
-----------------------------------------------------------------------------
Login
  open                        2254-3084ms (~2941ms)    30/30 failed   (NoRouteToHostException Keine Route zum Zielrechner (Host unreachable))
  login                       3053-3068ms (~3060ms)    30/30 failed   (NoRouteToHostException Keine Route zum Zielrechner (Host unreachable))
Total time: 18527 ms
Die "PerformanceTest" Suite hat zwei Testfälle ausgeführt, die unter dam Klassen-Namen "Login" gruppiert sind. Ein richtiges Projekt hat natürlich wesentlich mehr Testfälle, vielleicht sogar mehrere Suiten.

Alle Testfälle sind fehlgeschlagen, weil der Verbindungsaufbau zum Server fehlschlug. Das ist logisch, denn der konfigurierte Server "http://192.168.0.123" existiert nicht. Die beiden Testfälle wurden jeweils 30 mal ausgeführt, weil in der Konfigurationsdatei 3 Wiederholungen für jeden Thread eingestellt sind.

In der Mitte wird die Antwortzeit des Servers ausgegeben, und zwar minimal, maximal und Durchschnitts-Zeit. Daneben wird ausgegeben, wie viele von den 30 Ausführungen fehlschlugen. Dahinter steht in Klammern die letzte Fehlermeldung.

Die gemessenen Zeiten beziehen sich nur auf die HTTP Kommunikation. Die tatsächliche Gesamt-Zeit ist immer etwas mehr, weil die Testsuite selbst Zeit für die Verarbeitung benötigt.

Konfigurationsdatei

Die Konfigurationsdatei configuration.properties kann vom Programmierer bei Bedarf erweitert werden. Folgende Einstellung sind vorgegeben:

SERVER_URL = http://192.168.0.123
Definiert den Anfang der URL's vom Server. Der HTTP Client schreibt dies vor alle URL's, wenn sie nicht bereits mit "http" beginnen.

SESSIONCOOKIE = PHPSESSID
Dies ist der Name des Session Cookies. Wenn der zu testende Server keine Cookies verwendet, spielt diese Einstellung keine Rolle. Das Cookie wird automatisch als "sessionID" gespeichert und in jedem folgenden Request als HTTP Header an den Server gesendet.

CONNECT_TIMEOUT = 5000
Legt fest, nach wie vielen Millisekunden der Server die HTTP Verbindung annehmen muss. Wenn er zu langsam ist, bricht der Test mit einer entsprechenden Fehlermeldung ab.

READ_TIMEOUT = 10000
Legt fest, nach wie vielen Millisekunden der Server normale Anfragen beantworten muss. Wenn er zu langsam ist, bricht der Test mit einer entsprechenden Fehlermeldung ab.

SLOW_READ_TIMEOUT = 60000
Wie READ_TIMEOUT, jedoch für besondere Fälle wo eine langsamere Antwortzeit ausdrücklich erlaubt sein soll.

ENCODING = UTF-8
Zeichensatz in dem die Antworten des Servers erwartet werden.

LANGUAGE = de
Die Sprache wird als HTTP Request Header an den Server gesendet. Damit kann man bei vielen Web-Anwendungen die Anzeigesprache beeinflussen.

REPORT_FORMAT = text
Dieser Parameter bestimmt, in welchem Format der Bericht erstellt werden soll. Man kann auch csv oder html erzeugen.

Falls man unbedingt HTTPS verwenden muss und der Server ein selbst signiertes Zertifikat verwendet, benötigt man zusätzlich die beiden folgenden Einstellungen:

javax.net.ssl.trustStore = certificate/keystore.jks
Gibt eine Datei an, in der zusätzliche Zertifikate abgelegt sind, denen man vertraut. Im Unterverzeichnis certificate findest Du eine Beispiel-Datei sowie eine README Datei, die erklärt, wie man eigene Zertifikate hinzufügt.

javax.net.ssl.trustStorePassword = test1234
Mit diesem Passwort wird der Zertifikats-Speicher geöffnet. Derjenige, der die Datei erzeugt hat, kennt das Passwort.

Das Programm kann viele hundert Threads parallel ausführen. Dazu dienen die folgenden Einstellungen:

THREADS = 10
Anzahl der (fast) gleichzeitigen Ausführungen von einer Testsuite.

THREAD_STARTUP_DELAY = 10
Die Threads werden mit so vielen Millisekunden Versatz gestartet, was realen Szenarien näher kommt als alle genau gleichzeitig zu starten.

REPETITIONS = 3
Jeder Thread wiederholt seine programmierte Sequenz von Testfällen so oft.

Darüber hinaus gibt es noch die Konfigurtionsdatei logging.properties, wo man einstellen kann, wie detailliert die Meldungen der einzelnen Java Pakete sein sollen. Die interessantesten Einträge darin sind diese:

.level = SEVERE
de.stefanfrings.test.framework.level = WARNING
my.test.level = FINEST
Diese Einstellungen bedeuten, dass die Klassen aus dem Paket "de.stefanfrings.test.framework" nur Warnungen und Fehler protokollieren sollen. Aber die Klassen aus dem Paket "my.test" sollen alle möglichen Details ausgeben. Für alle ungenannten Pakete gilt die erste Zeile, welche festlegt, dass nur Fehler (SEVERE) protokolliert werden. Mehr Doku zur Konfiguration von java.util.logging gibt es hier.

Programmierung

Zunächst möchte ich klarstellen, dass der Download lediglich einen HTTP Client, einen Scheduler für die Threads und eine Reporting-Engine enthält. Für zusätzliche Schnittstellen (zum Beispiel Datenbank) muss man die entsprechenden Libraries hinzufügen.

Test Klasse

Die Programmierung eines Testautomaten beginnt damit, dass man mindestens eine Test-Klasse schreibt. Für einen schnellen Start habe ich die beiden Klassen my.test.cases.Login und my.test.cases.Logout als Kopiervorlage bereit gestellt.

Jede Test-Klasse hat eine run() Methode, welche die einzelnen Testfälle in der richtigen Reihenfolge ausführt, sowie eine main() Methode die das Ausführen innerhalb der IDE erleichtert.

public class Login extends TestClass
{

    public void run() 
    {
        call("open");
        call("login");
    }
    
    public String open() throws Exception
    {
        loadPage("/login.html");
        mustContain("Geben Sie Name und Passwort ein");
        return null;
    }

    public String login() throws Exception 
    {
        Map<String, String> postParams = new HashMap<>();
        postParams.put("name", "dagobert");
        postParams.put("password", "duck");
        
        submitForm("/login.php", postParams);
        
        String code=getJsonValue("code");
        if ("0".equals(code))
        {
            return null;
        }
        else
        {
            return "login failed, code="+code;
        }
    }
    
    public static void main(String args[]) 
    {
        new Login().run();
        report.print("Report of " + Login.class.getSimpleName());
        System.exit(report.isAllSuccessful() ? 0 : 1);
    }
}
Jeder Testfall ist eine Methode in der Test Klasse. Die Klasse dient dazu, zusammenhörige Testfälle zu gruppieren.

Die "open" Methode öffnet die Login-Webseite und prüft, ob diese Seite den Text "Geben Sie Name und Passwort ein" enthält. Wenn das nicht der Fall ist, dann hat der Server nicht die erwartete Seite geliefert, so dass es als Fehler gewertet wird. Der Rückgabewert return null signalisiert, dass der Test erfolgreich war. Im Fehlerfall soll man stattdessen eine Fehlermeldung zurückliefern oder eine Exception werfen, wie es die mustContain() Methode tut.

Die "login" Methode simuliert das Senden eines ausgefüllten HTML Formulars. Die Variable postParams enthält dabei die Feldnamen und Werte des Formulars. Mit expectJsonValue() wird kontrolliert, ob die Antwort des Servers in Ordnung ist. Ein gültige Antwort würde in diesem Fall so aussehen:

{
    "response": {
        "code": 0,
        "message": "success"
    }
}

Test Suiten

Wenn man den Testautomaten später benutzt, dann führt man immer eine der Test-Suiten aus. Diese dienen dazu, mehrere Testklassen zu einem vollständigen Testlauf zusammen zu fassen und in parallelen Threads auszuführen.

Das Projekt stellt als Kopiervorlage die Klasse my.test.suite.PerformanceTest zur Verfügung.

Jede TestSuite enthält nur zwei Methoden, und zwar run() und main():

public class PerformanceTest extends TestSuite
{

    public void run()
    {
        for (int i=0; i<REPETITIONS; i++)
        {
            new Login().run();
            if (isLoggedIn())
            {            
                // Insert other tests here
                new Logout().run();
            }
        }
    }

    public static void main(String args[])
    {
        new PerformanceTest().executeMultiThreaded(1);
        System.exit(report.isAllSuccessful() ? 0 : 1);
    }
}

Um einen Dauer-Last-Test zu bauen, fügt man eine Zählschleife ein:

public static void main(String args[])
{
    for (int i=1;;i++) // repeat forever
    {
        new PerformanceTest().executeMultiThreaded(i);
    }
}
Nach jedem Durchlauf gibt das Programm einen Zwischenbericht aus.

Was das Framework bereit stellt

Mit config.get(name) kommt man an die Konfigurations-Parameter heran. Zusätzlich gibt es zur Bequemlichkeit die folgenden vordefinierte Variablen als Abkürzung:

Nach einem HTTP Request findet man die Antwort in den folgenden Variablen:

Methoden:

sessionData()
Liefert eine HashMap zum Speichern von thread-lokalen session Daten. Sie ist dazu gedacht, dass ein Testfall irgendwelche Daten an die nachfolgenden Testfälle übergeben kann. Das Framework ruft z.B. sessionData().put("sessionID") und sessionData.get("sessionID") auf, um das Session Cookie zu speichern.

isLoggedIn()
Liefert true zurück, wenn die Session Daten eine "sessionID" enthalten.

doHTTP(String urlPart, int readTimeout, Map<String, String> httpRequestHeader, Map<String, String> httpPostData, String documentBody)

Sendet einen HTTP Request an die angegebene URL. Wenn die URL nicht mit "http" beginnt, dann wird die konfigurierte SERVER_URL davor gepackt. readTimeout bestimmt, wie lange maximal auf die Antwort gewartet werden soll, hier gibt man normalerweise entweder READ_TIMEOUT oder SLOW_READ_TIMEOUT an. httpRequestHeader kann optional HTTP Header festlegen, die gesendet werden sollen. Außerdem kann man entweder httpPostData oder documentBody verwenden, um Daten an den Server zu senden. httpPostData simuliert das Absenden eines Formulars im Web-Browser, während documentBody einen String 1:1 sendet.

Ich benutze doHTTP normalerweise nicht direkt, sondern rufe stattdessen die folgenden Wrapper auf:

Die obigen Methoden benutzen alle READ_TIMEOUT. Weiterhin gibt es auch "Slow" Varianten (z.B. loadSlowPage()) die stattdessen den SLOW_READ_TIMEOUT benutzen. Diese sind für besondere Fälle gedacht, die ausnahmsweise länger dauern dürfen.

Mit den folgenden Methoden kann anschließend die Antwort (also der Inhalt von der Variable document) überprüft werden:

contains(String... keywords)
Findet heraus, ob empfangene Dokument die angegebenen Stichwörter in genau dieser Reihenfolge enthält.

mustContain(String... keywords)
Bricht mit einer Exception ab, wenn das empfangene Dokument nicht alle angegebenen Stichwörter in genau dieser Reihenfolge enthält.

getFormField(String... keywords)
Findet in dem zuvor empfangenen HTML Dokument ein Formular und extrahiert den Wert eines Formular-Feldes. Als Parameter gibt man beliebig viele Stichwörter an, die in genau dieser Reihenfolge in dem HTML Dokument vorkommen müssen. Das letzte Stichwort entspricht dem Namen des Formular-Feldes. Zum Beispiel findet getFormField("Paket","input","nummer") den Wert von Paket-Nummer:<input type="text" name="nummer" value="wert">.

getElementValue(String... keywords)
Findet ein HTML oder XML Element und liefert dessen Wert zurück. Als Parameter gibt man beliebig viele Stichwörter an, die in genau dieser Reihenfolge in dem HTML/XML Dokument vorkommen müssen. Das letzte Stichwort ist der Name des gesuchten Elementes. Zum Beispiel findet getElementValue("user","name") den Wert von <user><name>wert</name></user>.

getAttribute(String... keywords)
Findet ein HTML oder XML Element und liefert davon ein bestimmtes Attribut zurück. Als Parameter gibt man beliebig viele Stichwörter an, die in genau dieser Reihenfolge in dem HTML/XML Dokument vorkommen müssen. Das vorletzte Stichwort ist der Name des gesuchten Elementes, und das letzte Stichwort ist der Name des gewünschten Attributes. Zum Beispiel findet getAttribute("user","name") den Wert von <user name="wert"></user>.

getJsonValue(String... keywords)
Findet in einem JSON Dokument einen Wert anhand seines Namens. Als Parameter gibt man beliebig viele Stichwörter an, die in genau dieser Reihenfolge in dem JSON Dokument vorkommen müssen. Das letzte Stichwort ist der Name des gesuchten Wertes. Zum Beispiel findet getJsonValue("response","code") die "0" in {"response":{"code":0,"message":"success"}}.

getUrlParameter(String... keywords)
Findet in einem HTML Dokument eine URL und liefert den Wert eines bestimmten URL-Parameters zurück. Als Parameter gibt man beliebig viele Stichwörter an, die in genau dieser Reihenfolge in dem HTML Dokument vorkommen müssen. Das letzte Stichwort benennt den URL Parameter. Zum Beispiel findet getUrlParameter("irgendwo.de","betrag") den Wert von http://irgendwo.de/blabla?betrag=wert.

getUpdateValue(String... keywords)
Diese Methode ist speziell für Frontends mit RichFaces gedacht. Wenn der Server nach einem AJAX Request in der Webseite ein Formularfeld aktualisiert, dann findet diese Methode den neuen Wert heraus. Als Parameter gibt man beliebig viele Stichwörter an, die in genau dieser Reihe. Zum Beispiel findet getUpdateValue("name") den Wert von <update id="name"><![CDATA[wert]]>.

Mit den folgenden Methoden gann man ganz grob prüfen, ob der Server eine Datei im erwarteten Format geliefert hat:

Multi-Threading und Variablen

Beim Programmieren muss man stets daran denken, dass der Testautomat Multi-Threaded arbeitet. Die TestSuite erzeugt für jeden Thread separate Instanzen der Testklassen, bevor deren Testfälle ausgeführt werden. Normale variablen innerhalb der Testklassen sind daher nur für den jeweils laufenden Thread sichtbar.

Manchmal möchte man jedoch, dass alle Threads sich eine Variable teilen. Diese muss dann als static deklariert werden und die Zugriffe darauf müssen synchronisiert werden, damit es nicht zu Konflikten wegen gleichzeitigen Zugriffen kommt.

Ein typischer Anwendungsfall wäre die Erzeugung einer fortlaufenden Nummer. Der entsprechende Java Code könnte so aussehen:

public class KaufeEinenArtikel extends TestClass 
{
   static int number=100;
   
   public static synchronized int nextNumber()
   {
       return number++;
   }
   
   
   public String zeigeArtikel()
   {    
       loadPage("/katalog/artikelnummer="+nextNumber());
       return null;
   }
}