Die Verwendung von Testcontainers hat die Arbeit mit Testszenarien radikal verbessert. Dank dieses Tools ist das Erstellen von Umgebungen für Integrationstests einfacher geworden (siehe den Artikel Isolation beim Testen mit Kafka ). Jetzt können wir problemlos Container mit verschiedenen Versionen von Datenbanken, Nachrichtenbrokern und anderen Diensten starten. Für Integrationstests hat sich Testcontainers als unverzichtbar erwiesen.
Obwohl Belastungstests weniger verbreitet sind als Funktionstests, können sie viel mehr Spaß machen. Das Studieren von Diagrammen und Analysieren der Leistung eines bestimmten Dienstes kann wirklich Spaß machen. Solche Aufgaben sind selten, aber für mich sind sie besonders spannend.
Der Zweck dieses Artikels besteht darin, einen Ansatz zum Erstellen eines Setups für Belastungstests zu demonstrieren, und zwar auf dieselbe Weise, wie reguläre Integrationstests geschrieben werden: in Form von Spock-Tests unter Verwendung von Testcontainern in einer Gradle-Projektumgebung. Dabei werden Belastungstest-Dienstprogramme wie Gatling, WRK und Yandex.Tank verwendet.
Toolset: Gradle + Spock Framework + Testcontainer. Die Implementierungsvariante ist ein separates Gradle-Modul. Als Lasttest-Dienstprogramme werden Gatling, WRK und Yandex.Tank verwendet.
Für die Arbeit mit dem Testobjekt gibt es zwei Herangehensweisen:
Im ersten Fall verfügen wir über eine Reihe von Belastungstests, die unabhängig von der Version und den Änderungen des Projekts sind. Dieser Ansatz ist in Zukunft einfacher zu pflegen, beschränkt sich jedoch auf das Testen nur veröffentlichter Images. Wir können diese Images natürlich manuell lokal erstellen, aber dies ist weniger automatisiert und verringert die Reproduzierbarkeit. Wenn CI/CD ohne die erforderlichen Images ausgeführt wird, schlagen die Tests fehl.
Im zweiten Fall werden die Tests auf der neuesten Version des Dienstes ausgeführt. Dadurch können Belastungstests in CI integriert und Leistungsdatenänderungen zwischen Dienstversionen abgerufen werden. Belastungstests dauern jedoch normalerweise länger als Unittests. Die Entscheidung, solche Tests als Teil des Quality Gate in CI aufzunehmen, liegt bei Ihnen.
In diesem Artikel wird der erste Ansatz betrachtet. Dank Spock können wir Tests mit mehreren Versionen des Dienstes durchführen, um eine vergleichende Analyse durchzuführen:
where: image | _ 'avvero/sandbox:1.0.0' | _ 'avvero/sandbox:1.1.0' | _
Es ist wichtig zu beachten, dass das Ziel dieses Artikels darin besteht, die Organisation des Testbereichs zu demonstrieren, und nicht darin, einen umfassenden Belastungstest durchzuführen.
Als Testobjekt nehmen wir einen einfachen HTTP-Dienst namens Sandbox, der einen Endpunkt veröffentlicht und Daten aus einer externen Quelle verwendet, um Anfragen zu verarbeiten. Der Dienst verfügt über eine Datenbank.
Der Quellcode des Dienstes, einschließlich der Docker-Datei, ist im Projekt-Repository spring-sandbox verfügbar.
Da wir später in diesem Artikel tiefer in die Details einsteigen, möchte ich mit einem kurzen Überblick über die Struktur des Gradle-Moduls load-tests
beginnen, um ein Verständnis seiner Zusammensetzung zu vermitteln:
load-tests/ |-- src/ | |-- gatling/ | | |-- scala/ | | | |-- MainSimulation.scala # Main Gatling simulation file | | |-- resources/ | | | |-- gatling.conf # Gatling configuration file | | | |-- logback-test.xml # Logback configuration for testing | |-- test/ | | |-- groovy/ | | | |-- pw.avvero.spring.sandbox/ | | | | |-- GatlingTests.groovy # Gatling load test file | | | | |-- WrkTests.groovy # Wrk load test file | | | | |-- YandexTankTests.groovy # Yandex.Tank load test file | | |-- java/ | | | |-- pw.avvero.spring.sandbox/ | | | | |-- FileHeadLogConsumer.java # Helper class for logging to a file | | |-- resources/ | | | |-- wiremock/ | | | | |-- mappings/ # WireMock setup for mocking external services | | | | | |-- health.json | | | | | |-- forecast.json | | | |-- yandex-tank/ # Yandex.Tank load testing configuration | | | | |-- ammo.txt | | | | |-- load.yaml | | | | |-- make_ammo.py | | | |-- wrk/ # LuaJIT scripts for Wrk | | | | |-- scripts/ | | | | | |-- getForecast.lua |-- build.gradle
Projekt-Repository – https://github.com/avvero/testing-bench .
Aus der obigen Beschreibung geht hervor, dass der Dienst zwei Abhängigkeiten hat: den Dienst https://external-weather-api.com und eine Datenbank. Ihre Beschreibung wird weiter unten bereitgestellt, aber beginnen wir damit, alle Komponenten des Schemas für die Kommunikation in einer Docker-Umgebung zu aktivieren – wir beschreiben das Netzwerk:
def network = Network.newNetwork()
und geben Sie Netzwerkaliase für jede Komponente an. Dies ist äußerst praktisch und ermöglicht es uns, die Integrationsparameter statisch zu beschreiben.
Abhängigkeiten wie WireMock und die Lasttest-Dienstprogramme selbst erfordern eine Konfiguration, damit sie funktionieren. Dies können Parameter sein, die an den Container übergeben werden können, oder ganze Dateien und Verzeichnisse, die in den Containern bereitgestellt werden müssen.
Darüber hinaus müssen wir die Ergebnisse ihrer Arbeit aus den Containern abrufen. Um diese Aufgaben zu lösen, müssen wir zwei Verzeichnissätze bereitstellen:
workingDirectory
– das Ressourcenverzeichnis des Moduls, direkt unter load-tests/
.
reportDirectory
– das Verzeichnis für die Arbeitsergebnisse, einschließlich Metriken und Protokolle. Weitere Informationen hierzu finden Sie im Abschnitt zu Berichten.Der Sandbox-Dienst verwendet Postgres als Datenbank. Beschreiben wir diese Abhängigkeit wie folgt:
def postgres = new PostgreSQLContainer<>("postgres:15-alpine") .withNetwork(network) .withNetworkAliases("postgres") .withUsername("sandbox") .withPassword("sandbox") .withDatabaseName("sandbox")
Die Deklaration gibt den Netzwerkalias postgres
an, den der Sandbox-Dienst zur Verbindung mit der Datenbank verwendet. Um die Integrationsbeschreibung mit der Datenbank zu vervollständigen, muss der Dienst mit den folgenden Parametern versehen werden:
'spring.datasource.url' : 'jdbc:postgresql://postgres:5432/sandbox', 'spring.datasource.username' : 'sandbox', 'spring.datasource.password' : 'sandbox', 'spring.jpa.properties.hibernate.default_schema': 'sandbox'
Die Datenbankstruktur wird mithilfe von Flyway von der Anwendung selbst verwaltet, sodass im Test keine zusätzlichen Datenbankmanipulationen erforderlich sind.
Wenn wir nicht die Möglichkeit, Notwendigkeit oder den Wunsch haben, die eigentliche Komponente in einem Container auszuführen, können wir ein Mock für ihre API bereitstellen. Für den Dienst https://external-weather-api.com wird WireMock verwendet.
Die Deklaration des WireMock-Containers sieht folgendermaßen aus:
def wiremock = new GenericContainer<>("wiremock/wiremock:3.5.4") .withNetwork(network) .withNetworkAliases("wiremock") .withFileSystemBind("${workingDirectory}/src/test/resources/wiremock/mappings", "/home/wiremock/mappings", READ_WRITE) .withCommand("--no-request-journal") .waitingFor(new LogMessageWaitStrategy().withRegEx(".*https://wiremock.io/cloud.*")) wiremock.start()
WireMock erfordert eine Mock-Konfiguration. Die Anweisung withFileSystemBind
beschreibt die Dateisystembindung zwischen dem lokalen Dateipfad und dem Pfad innerhalb des Docker-Containers. In diesem Fall wird das Verzeichnis "${workingDirectory}/src/test/resources/wiremock/mappings"
auf dem lokalen Computer in /home/wiremock/mappings
innerhalb des WireMock-Containers gemountet.
Nachfolgend finden Sie einen zusätzlichen Teil der Projektstruktur, um die Dateizusammensetzung im Verzeichnis zu verstehen:
load-tests/ |-- src/ | |-- test/ | | |-- resources/ | | | |-- wiremock/ | | | | |-- mappings/ | | | | | |-- health.json | | | | | |-- forecast.json
Um sicherzustellen, dass die Mock-Konfigurationsdateien korrekt geladen und von WireMock akzeptiert werden, können Sie einen Hilfscontainer verwenden:
helper.execInContainer("wget", "-O", "-", "http://wiremock:8080/health").getStdout() == "Ok"
Der Hilfscontainer wird wie folgt beschrieben:
def helper = new GenericContainer<>("alpine:3.17") .withNetwork(network) .withCommand("top")
Übrigens wurde mit IntelliJ IDEA Version 2024.1 die Unterstützung für WireMock eingeführt , und die IDE bietet Vorschläge zum Erstellen von Mock-Konfigurationsdateien.
Die Deklaration des Sandbox-Service-Containers sieht wie folgt aus:
def javaOpts = ' -Xloggc:/tmp/gc/gc.log -XX:+PrintGCDetails' + ' -XX:+UnlockDiagnosticVMOptions' + ' -XX:+FlightRecorder' + ' -XX:StartFlightRecording:settings=default,dumponexit=true,disk=true,duration=60s,filename=/tmp/jfr/flight.jfr' def sandbox = new GenericContainer<>(image) .withNetwork(network) .withNetworkAliases("sandbox") .withFileSystemBind("${reportDirectory}/logs", "/tmp/gc", READ_WRITE) .withFileSystemBind("${reportDirectory}/jfr", "/tmp/jfr", READ_WRITE) .withEnv([ 'JAVA_OPTS' : javaOpts, 'app.weather.url' : 'http://wiremock:8080', 'spring.datasource.url' : 'jdbc:postgresql://postgres:5432/sandbox', 'spring.datasource.username' : 'sandbox', 'spring.datasource.password' : 'sandbox', 'spring.jpa.properties.hibernate.default_schema': 'sandbox' ]) .waitingFor(new LogMessageWaitStrategy().withRegEx(".*Started SandboxApplication.*")) .withStartupTimeout(Duration.ofSeconds(10)) sandbox.start()
Wichtige Parameter und JVM-Einstellungen umfassen:
Zusätzlich werden Verzeichnisse zum Speichern der Diagnoseergebnisse des Dienstes konfiguriert.
Wenn Sie die Protokolle eines beliebigen Containers in einer Datei anzeigen müssen (was wahrscheinlich während der Phase des Schreibens und Konfigurierens des Testszenarios erforderlich ist), können Sie beim Beschreiben des Containers die folgende Anweisung verwenden:
.withLogConsumer(new FileHeadLogConsumer("${reportDirectory}/logs/${alias}.log"))
In diesem Fall wird die Klasse FileHeadLogConsumer
verwendet, die das Schreiben einer begrenzten Anzahl von Protokollen in eine Datei ermöglicht. Dies geschieht, weil das gesamte Protokoll in Lasttestszenarien wahrscheinlich nicht benötigt wird und ein Teilprotokoll ausreicht, um zu beurteilen, ob der Dienst ordnungsgemäß funktioniert.
Es gibt viele Tools für Belastungstests. In diesem Artikel schlage ich vor, drei davon zu verwenden: Gatling, Wrk und Yandex.Tank. Alle drei Tools können unabhängig voneinander verwendet werden.
Gatling ist ein in Scala geschriebenes Open-Source-Tool für Lasttests. Es ermöglicht die Erstellung komplexer Testszenarien und liefert detaillierte Berichte. Die Hauptsimulationsdatei von Gatling ist als Scala-Ressource mit dem Modul verbunden, sodass die Arbeit mit der gesamten Unterstützung von IntelliJ IDEA, einschließlich Syntaxhervorhebung und Navigation durch Methoden zur Dokumentationsreferenz, bequem ist.
Die Containerkonfiguration für Gatling ist wie folgt:
def gatling = new GenericContainer<>("denvazh/gatling:3.2.1") .withNetwork(network) .withFileSystemBind("${reportDirectory}/gatling-results", "/opt/gatling/results", READ_WRITE) .withFileSystemBind("${workingDirectory}/src/gatling/scala", "/opt/gatling/user-files/simulations", READ_WRITE) .withFileSystemBind("${workingDirectory}/src/gatling/resources", "/opt/gatling/conf", READ_WRITE) .withEnv("SERVICE_URL", "http://sandbox:8080") .withCommand("-s", "MainSimulation") .waitingFor(new LogMessageWaitStrategy() .withRegEx(".*Please open the following file: /opt/gatling/results.*") .withStartupTimeout(Duration.ofSeconds(60L * 2)) ); gatling.start()
Der Aufbau ist fast identisch mit anderen Containern:
reportDirectory
.workingDirectory
.workingDirectory
.
Zusätzlich werden Parameter an den Container übergeben:
SERVICE_URL
mit dem URL-Wert für den Sandbox-Dienst. Wie bereits erwähnt, ermöglicht die Verwendung von Netzwerkaliasen jedoch die direkte Festcodierung der URL im Szenariocode.
-s MainSimulation
zum Ausführen einer bestimmten Simulation.
Hier eine Erinnerung an die Quelldateistruktur des Projekts, um zu verstehen, was wohin übergeben wird:
load-tests/ |-- src/ | |-- gatling/ | | |-- scala/ | | | |-- MainSimulation.scala # Main Gatling simulation file | | |-- resources/ | | | |-- gatling.conf # Gatling configuration file | | | |-- logback-test.xml # Logback configuration for testing
Da dies der letzte Container ist und wir nach seiner Fertigstellung Ergebnisse erwarten, setzen wir die Erwartung .withRegEx(".*Please open the following file: /opt/gatling/results.*")
. Der Test endet, wenn diese Meldung in den Containerprotokollen erscheint oder nach 60 * 2
Sekunden.
Ich werde nicht näher auf die DSL der Szenarien dieses Tools eingehen. Sie können den Code des verwendeten Szenarios im Projektrepository einsehen.
Wrk ist ein einfaches und schnelles Lasttest-Tool. Es kann mit minimalen Ressourcen eine erhebliche Last erzeugen. Zu den wichtigsten Funktionen gehören:
Die Containerkonfiguration für Wrk ist wie folgt:
def wrk = new GenericContainer<>("ruslanys/wrk") .withNetwork(network) .withFileSystemBind("${workingDirectory}/src/test/resources/wrk/scripts", "/tmp/scripts", READ_WRITE) .withCommand("-t10", "-c10", "-d60s", "--latency", "-s", "/tmp/scripts/getForecast.lua", "http://sandbox:8080/weather/getForecast") .waitingFor(new LogMessageWaitStrategy() .withRegEx(".*Transfer/sec.*") .withStartupTimeout(Duration.ofSeconds(60L * 2)) ) wrk.start()
Damit Wrk mit Anfragen an den Sandbox-Dienst funktioniert, ist die Anforderungsbeschreibung über ein Lua-Skript erforderlich. Daher mounten wir das Skriptverzeichnis von workingDirectory
. Mit dem Befehl führen wir Wrk aus und geben das Skript und die URL der Zieldienstmethode an. Wrk schreibt basierend auf seinen Ergebnissen einen Bericht in das Protokoll, der zum Festlegen von Erwartungen verwendet werden kann.
Yandex.Tank ist ein von Yandex entwickeltes Lasttesttool. Es unterstützt verschiedene Lasttest-Engines wie JMeter und Phantom. Zum Speichern und Anzeigen von Lasttestergebnissen können Sie den kostenlosen Dienst Overload verwenden.
Hier ist die Containerkonfiguration:
copyFiles("${workingDirectory}/src/test/resources/yandex-tank", "${reportDirectory}/yandex-tank") def tank = new GenericContainer<>("yandex/yandex-tank") .withNetwork(network) .withFileSystemBind("${reportDirectory}/yandex-tank", "/var/loadtest", READ_WRITE) .waitingFor(new LogMessageWaitStrategy() .withRegEx(".*Phantom done its work.*") .withStartupTimeout(Duration.ofSeconds(60L * 2)) ) tank.start()
Die Lasttestkonfiguration für Sandbox wird durch zwei Dateien dargestellt: load.yaml
und ammo.txt
. Als Teil der Containerbeschreibung werden Konfigurationsdateien in das reportDirectory
kopiert, das als Arbeitsverzeichnis bereitgestellt wird. Hier ist die Struktur der Projektquelldateien, um zu verstehen, was wohin übergeben wird:
load-tests/ |-- src/ | |-- test/ | | |-- resources/ | | | |-- yandex-tank/ | | | | |-- ammo.txt | | | | |-- load.yaml | | | | |-- make_ammo.py
Testergebnisse, einschließlich JVM-Leistungsaufzeichnungen und -Protokolle, werden im Verzeichnis build/${timestamp}
gespeichert, wobei ${timestamp}
den Zeitstempel jedes Testlaufs darstellt.
Die folgenden Berichte stehen zur Einsichtnahme zur Verfügung:
Wenn Gatling verwendet wurde:
Wenn Wrk verwendet wurde:
Wenn Yandex.Tank verwendet wurde:
Die Verzeichnisstruktur für die Berichte ist wie folgt:
load-tests/ |-- build/ | |-- ${timestamp}/ | | |-- gatling-results/ | | |-- jfr/ | | |-- yandex-tank/ | | |-- logs/ | | | |-- sandbox.log | | | |-- gatling.log | | | |-- gc.log | | | |-- wiremock.log | | | |-- wrk.log | | | |-- yandex-tank.log | |-- ${timestamp}/ | |-- ...
Lasttests sind eine entscheidende Phase im Lebenszyklus der Softwareentwicklung. Sie helfen dabei, die Leistung und Stabilität einer Anwendung unter verschiedenen Lastbedingungen zu beurteilen. In diesem Artikel wurde ein Ansatz zum Erstellen einer Lasttestumgebung mit Testcontainern vorgestellt, der eine einfache und effiziente Einrichtung der Testumgebung ermöglicht.
Testcontainer vereinfachen die Erstellung von Umgebungen für Integrationstests erheblich und sorgen für Flexibilität und Isolation. Für Belastungstests ermöglicht dieses Tool die Bereitstellung der erforderlichen Container mit unterschiedlichen Versionen von Diensten und Datenbanken, was die Durchführung von Tests erleichtert und die Reproduzierbarkeit der Ergebnisse verbessert.
Die bereitgestellten Konfigurationsbeispiele für Gatling, Wrk und Yandex.Tank sowie die Containereinrichtung zeigen, wie verschiedene Tools effektiv integriert und Testparameter verwaltet werden.
Darüber hinaus wurde der Prozess der Protokollierung und Speicherung von Testergebnissen beschrieben, der für die Analyse und Verbesserung der Anwendungsleistung von entscheidender Bedeutung ist. Dieser Ansatz kann in Zukunft erweitert werden, um komplexere Szenarien und die Integration mit anderen Überwachungs- und Analysetools zu unterstützen.
Vielen Dank für Ihre Aufmerksamkeit bei diesem Artikel und viel Glück bei Ihrem Vorhaben, nützliche Tests zu schreiben!