End-to-End-Tests Reise & Integration in eine GitLab-Pipeline
Einführung in End-to-End Tests & GitLab-Integration
Während unserer Microservices Reise in den letzten Jahren sind wir bei der Entwicklung, Wartung und Integration der End-to-End Tests in unsere CI/CD-Pipeline auf mehrere Probleme gestoßen. Schauen wir uns die wichtigsten Themen an, auf die wir gestoßen sind, und – wo ist der Fall – wie wir sie gelöst haben:
- Was sind End-to-End Tests?
- Warum müssen wir End-to-End-Tests schreiben?
- Wann sollte man End-to-End-Tests schreiben?
- Wie viel End-to-End Test zu schreiben?
- GitLab Grundlagen
- So geht’s: einrichten der Runner und Caching
- So geht’s: einrichten der Pipeline
- So geht’s: isolieren die Tests
- So geht’s: vorbereiten der Umgebung für die Durchführung der Tests
- So geht’s: bereiten die Daten für die Tests
- So geht’s: ausführen der Tests mit Docker Compose
- So geht’s: aufräumen nach den Tests
- So geht’s: die Tests so schnell wie möglich zu machen
- So geht’s: Wartung der Testsuite
- Fazit
Voraussetzungen
Um diesen Artikel optimal nutzen zu können, ist zumindest ein Grundverständnis von Linux, Docker und Docker Compose erforderlich.
In diesem Artikel wird davon ausgegangen, dass alle Microservices als Docker-Images gepackt und in einem privaten oder öffentlichen Docker-Repository veröffentlicht werden.
Das End-to-End-Testframework muss Junit-ähnliche Runner unterstützen, die von Gradle, Maven oder der Befehlszeile aus gestartet werden können.
Kontext
Die Beispielsoftware ist eine Unternehmensanwendung, die auf dem Java Tech Stack mit Spring Boot im Backend und Angular im Frontend entwickelt wurde. Das System besteht aus mehreren Microservices, die in der Open Telekom Cloud als Docker-Container bereitgestellt werden. Wir verwenden Hazelcast als Integrations-Backbone und MySql als persistenten Storage.
Da es mehrere Umgebungen gibt, haben wir eine GitLab-Pipeline für die kontinuierliche Integration und Bereitstellung eingerichtet. Wir haben eine konsistente Testsuite entwickelt, die aus Unit-Tests, Integrationstests, Sicherheitstests, Leistungstests und End-to-End-Tests besteht. Die meisten von ihnen werden bei jedem Zusammenführen auf unseren Umgebungszweigen ausgeführt. Wir arbeiten mit Feature-Zweigen, die in die Environment-Zweigen zusammengeführt werden. Es gibt im Grunde fünf Umgebungen, zwei Entwicklungssysteme, ein Feature-Preview-System, ein Staging-System und die Produktionsumgebung.
Als End-to-End-Testframework setzen wir Serenity ein, weil es starke Unterstützung für automatisierte Tests mit Selenium 2 bietet. Der Zweck von Serenity ist es, eine lebendige Dokumentation zu erstellen. Die Tests sind im “ give – when – then“-Stil geschrieben und liefern illustrierte, narrative Berichte.
Die aggregierten Berichte können anhand von Features, Stories, Steps, Szenarien und Tests organisiert werden.
Was sind End-to-End Tests?
End-to-End-Tests sind im Grunde User-Interface Tests. Sie befinden sich an der Spitze der Testpyramide und sind die teuersten zu entwickeln und zu warten, aber die langsamsten auszuführen. Daher besteht nur ein kleiner Prozentsatz der gesamten Testsuite aus End-to-End-Tests.
Warum müssen wir End-to-End-Tests schreiben?
Die End-to-End-Tests sind ein Sicherheitsnetz für Refactoring und kontinuierliche Erweiterungen. Sie erhöhen die Softwarequalität, indem sie früher Feedback geben, bevor sie in Die Produktion gehen.
Nicht zuletzt sind End-to-End-Tests eine Notwendigkeit, weil sie das Vertrauen des Teams in jede Lieferung erhöhen.
Wann sollte man End-to-End-Tests schreiben?
Manchmal sind Unit-Tests schwer zu implementieren oder anzufangen, insbesondere bei Legacy-Anwendungen, bei denen ein Refactoring erforderlich ist, um den Code testbarer zu machen. In diesem Fall ist es sicherer, mit End-to-End-Tests zu beginnen.
Darüber hinaus können die End-to-End-Tests auch als Abnahmetests verwendet werden. Wenn die UI-Mockups übersichtlich sind, können sie parallel zur Story-Implementierung geschrieben werden.
Wie viel End-to-End Test zu schreiben?
Die End-to-End-Tests sollten nur bei geschäftskritischen User Journeys durchgeführt werden. Es müssen nur glückliche Pfade eingeschlossen werden, d. h., wir müssen die Fehlerfälle oder die Validierungen nicht testen.
GitLab Grundlagen
GitLab ist die Wahl von Berg Software für das Hosting des Source Codes und den Betrieb der kontinuierlichen Integrations- und Delivery-Pipelines. Die Pläne reichen von kostenlos bis Enterprise. Sie können private oder öffentliche Repositorys erstellen.
Einige der schönsten Out-of-Box-Funktionen von GitLab sind:
- REST APIs
- CI/CD-Unterstützung
- protected branches
- Code-Review-Unterstützung
- vordefinierte Variablen
- custom runners-Unterstützung
- Integration mit verschiedenen identity providers
- feinkörniges Berechtigungsmodell
- geplante Jobs
Sie können mehrere Organisationen definieren, jede davon mit eigenen Projekten und Repositorys.
Jobs können manuell oder automatisch durch andere Jobs oder durch User-Commits ausgelöst werden. Jeder Job führt eine Pipeline mit mehreren Phasen aus. Jede Phase kann ihr eigenes Docker-Image haben, das auf dem neuesten Code, der aus dem angegebenen Zweig extrahiert wurde, ausgeführt werden kann.
Es gibt vordefinierte Environment-Variablen, auf die der Benutzer innerhalb der Jobs zugreifen kann, wie z.B. Branch-Name und Commit-Message, die während des Builds sehr hilfreich sind. Es gibt auch benutzerdefinierte Variablen, die sensible Werte speichern können und die geschützt werden können. Nur geschützte Zweige haben Zugriff auf geschützte Variablen.
So geht’s: einrichten der Runner und Caching
Jeder Job wird von einem bestimmten GitLab-Runner ausgeführt. Die Runner sind Container-Images, die auf den vom Benutzer zur Verfügung gestellten Maschinen ausgeführt werden. Sie nehmen Jobs an und führen sie aus. Sie können so viele Runner definieren, wie Sie möchten. Es gibt auch gemeinsam genutzte Runner, die kostenlos zur Verfügung gestellt werden, aber Sie müssen auf die Builds anderer Benutzer warten.
Wir verwenden keine gemeinsamen Runner. Sie sind jedoch in GitLab im Bereich der CI/CD-Einstellungen des Projekts zu finden.
Wir verwenden eine Docker Compose, um die Runner einmalig zu registrieren und zu initialisieren:
version: '2' services: register-runner-main-01: restart: 'no' image: gitlab/gitlab-runner:alpine volumes: - /opt/docker_conf/runner-main-01:/etc/gitlab-runner command: - register - --non-interactive - --locked=false - --name= Group - prj - MAIN - 01 - --executor=docker - --output-limit=409600 - --docker-image=alpine:latest - --docker-volumes=/var/run/docker.sock:/var/run/docker.sock - --docker-volumes=/opt/docker_data/runner-main-01-cache:/opt/docker_data/runner-main-01-cache - --tag-list=otcmain - --docker-privileged - --docker-cache-dir=/opt/docker_data/runner-main-01-cache - --cache-type=s3 - --cache-s3-server-address=10.6.yyy.xx:9000 - --cache-s3-access-key=minioxxxxx - --cache-s3-secret-key=yyyy - --cache-s3-bucket-name=runner-main - --cache-shared - --cache-s3-insecure environment: - CI_SERVER_URL=https://gitlab.com/ - REGISTRATION_TOKEN=…
- --docker-image=alpine:latest
Wir haben die Volumes und Mappings für den Cache und für die Konfiguration definiert.
volumes: - /opt/docker_conf/runner-main-01:/etc/gitlab-runner - --docker-volumes=/opt/docker_data/runner-main-01-cache:/opt/docker_data/runner-main-01-cache - --docker-cache-dir=/opt/docker_data/runner-main-01-cache
- --docker-cache-dir=/opt/docker_data/runner-main-01-cache - --cache-type=s3 - --cache-s3-server-address=10.6.yyy.xx:9000 - --cache-s3-access-key=minioxxxxx - --cache-s3-secret-key=yyyy - --cache-s3-bucket-name=runner-main - --cache-shared - --cache-s3-insecure
version: '2' services: minio: image: minio/minio container_name: minio1 restart: unless-stopped volumes: - /opt/docker_conf/minio-etc:/root/.minio - /opt/docker_data/minio-data:/data ports: - "9000:9000" environment: - MINIO_ACCESS_KEY=minioak - MINIO_SECRET_KEY=… command: server /data networks: default: external: name: prj-bridge-network
- --tag-list=otcmain
version: '2' services: runner-main-01: restart: unless-stopped image: gitlab/gitlab-runner:alpine container_name: runner-main-01 volumes: - /opt/docker_conf/runner-main-01:/etc/gitlab-runner - /opt/docker_data/runner-main-01-cache:/opt/docker_data/runner-main-01-cache - /var/run/docker.sock:/var/run/docker.sock
Jeder Runner hat seinen eigenen Cache- und Konfigurationsordner, der zuvor bei der Registrierung konfiguriert wurde.
Um die Hauptläufer bei der langen Ausführung der End-to-End-Tests nicht zu blockieren:
- müssen wir uns auf die gleiche Weise wie oben registrieren und starten;
- die Trigger-Runner müssen die nicht-blockierende Ausführung der Pipeline aus einer anderen Projekt-Pipeline starten.
Das Startup-Skript sieht genauso aus wie oben:
services: runner-guitrigger-01: restart: unless-stopped image: gitlab/gitlab-runner:alpine container_name: runner-guitrigger-01 volumes: - /opt/docker_conf/runner-guitrigger-01:/etc/gitlab-runner - /opt/docker_data/runner-guitrigger-01-cache:/opt/docker_data/runner-guitrigger-01-cache - /var/run/docker.sock:/var/run/docker.sock
So geht’s: einrichten der Pipeline
Jedes Projekt lebt in seinem eigenen Git-Repository. Im Projektstammordner befindet sich eine Datei mit dem Namen .gitlab-ci.yml, die die Pipeline für jedes Projekt konfiguriert. Die Datei definiert Variablen, Cache-Einstellungen, auszuführende Vorher- und Nachher-Skripte und die Phasen der Pipeline. Die Phasen sind im Grunde die Schritte, die während der Pipeline ausgeführt werden sollen, und die Bedingungen für die Ausführung, die ausgelöst werden sollen. Der letzte Schritt in unserer Pipeline ist die End-to-End-Testphase.
Sie können Variablen definieren, die in derselben Datei oder in allen Pipeline-Skripten verwendet werden können:
variables: GRADLE_OPTS: "-Dorg.gradle.daemon=false" DOCKER_DRIVER: overlay2
stages: - opssetupchmodfix - test
Hier haben wir zwei Phasen. Nicht alle Phasen werden in einem einzigen Durchlauf ausgeführt. Wir werden später jede Phasendefinition detailliert beschreiben.
Um die Ausführungszeit der Jobs zu beschleunigen, müssen wir die Abhängigkeiten der einzelnen Builds zwischenspeichern, damit sie nicht jedes Mal heruntergeladen werden müssen. Der Cache hat einen Schlüssel, der aus der vordefinierten Git-Umgebungsvariablen CI_COMMIT_REF_SLUG ausgewählt wird, die den Git-Tag oder den Zweignamen enthält, für den das Projekt erstellt wird:
<pre class="text" style="font-family:monospace;">cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- .gradle/wrapper
- .gradle/caches</pre>
Wir können ein Skript definieren, das vor dem Start der Ausführung der Phasen ausgeführt wird:
before_script: - export GRADLE_USER_HOME=`pwd`/.gradle
guitest: image: registry1.projekt.de/prj-dind-chrome stage: test script: - ./ci/bin/guitest.sh "$EXECUTE_TEST_FOR_ENVIRONMENT" "$SRC_TRGR_BRANCH" tags: - guirunner
Das Bild registry1.projekt.de/prj-dind-chrome ist ein benutzerdefiniertes Docker-Image, das auf selenium/standalone-chrome:latest basiert und mit bash, curl, openssl, git, x11 server, jdk und anderen Tools erweitert wurde, um den End-to-End-Test in einer Browser-Umgebung durchführen zu können.
Auszug aus der Dockerfile, die zum Erstellen des Images verwendet wird:
RUN sudo apt-get -y install bash curl git openssl openssh-client openjdk-8-jdk libxpm4 libxrender1 libgtk2.0-0 libnss3 libgconf-2-4 xvfb gtk2-engines-pixbuf xfonts-cyrillic xfonts-100dpi xfonts-75dpi xfonts-base xfonts-scalable x11-xserver-utils x11-xkb-utils
script: ./ci/bin/guitest.sh "$EXECUTE_TEST_FOR_ENVIRONMENT" "$SRC_TRGR_BRANCH"
Das Skript wird aus dem ausgecheckten Sourcecode bezogen. Es gibt zwei Umgebungsvariablen, die dem Skript übergeben werden und die dem Container vom Aufrufer zur Verfügung gestellt werden. Sie werden auf der Grundlage der aktuellen Zweig- oder Tag-Variable berechnet, die von GitLab bereitgestellt wird CI_COMMIT_REF_NAME. Ich werde das Skript später erklären.
Der Prozess, der die Ausführung auslöst, kann eine andere Pipeline sein, die den Befehl in einer Phase aufruft:
trigger -a ${GITLABTRIGGER_APITOKEN} -p ${ GITLABTRIGGER_GUITESTTOKEN} -t ${branch} ${GITLABTRIGGER_GUITESTID} -e SRC_TRGR_BRANCH=${CI_COMMIT_REF_NAME} -e EXECUTE_TEST_FOR_ENVIRONMENT=${GUITEST_ENVIRONMENT^^} -e EXECUTE_TEST_FOR_PIPELINE=${CI_PIPELINE_URL}
Der Abschnitt „artifacts:“ definiert, welche Dateien nach Beendigung des Auftrags zum Download zur Verfügung gestellt werden sollen, was die Quelle dieser Dateien ist und wie lange sie aufbewahrt werden sollen.
artifacts: when: always paths: - target/* - /opt/selenium/config.json expire_in: 1 week
only: - branch_dev1 - branch_dev2 - branch_preview - master - /^story-/ except: variables: - $CI_COMMIT_MESSAGE =~ /^opssetup-*/
Der erste Ansatz verwendet die Befehle:
Xvfb -ac :99 -screen 0 1280x1024x16 & export DISPLAY=:99 ./gradlew -i clean runAParallelSuite aggregate -P webdriver.base.url=$TARGET_BASE_URL
Dieser Ansatz lässt sich nicht sehr gut skalieren. Wir bekamen häufige Speicherprobleme, so dass wir beschlossen, die Tests auf einem dedizierten Computer mit den Befehlen zu starten:
openssl enc -aes-XXalgo -d -pass env:GRP_SSL_ENCSECRET -in ci/secrets-enc/id_rsa-usr-dockerexec.dat >| ci/secrets/id_rsa-usr-dockerexec ssh -i ci/secrets/id_rsa-usr-dockerex usr-dockerex@10.6.xx.xx -o StrictHostKeyChecking=no "/opt/docker_exec/launch-tests.sh ${CI_JOB_ID} ${TARGET_LABEL} ${TARGET_BRANCH} ${TARGET_ENV}"
Mit Hilfe von Ansible haben wir den Rechner mit den richtigen Benutzerkonten und Berechtigungen eingerichtet, um die GUI-Tests von einem Remote-Rechner aus starten zu können. Der geheime Benutzerschlüssel wird bereitgestellt, nachdem er mithilfe einer geschützten Umgebungsvariablen entschlüsselt GRP_SSL_ENCSECRET, die in der GitLab-Benutzeroberfläche definiert ist. Die Skriptvariablen wurden auf der Grundlage des Zweignamens und der Standardvariablen von GitLab berechnet.
Jedem Job wird eine eindeutige ID zugewiesen, die als CI_JOB_ID angezeigt wird. Das TARGET_LABEL wird verwendet, um die Docker-Image-Labels anzugeben, die bei der Ausführung des Tests verwendet werden sollen. Die beiden anderen Variablen geben den Zweig an, von dem aus der Testcode und die Umgebung beim Herunterladen der Bilder zu berücksichtigen sind. In einem weiteren Abschnitt erklären wir das Startskript.
Eine weitere Phase namens „opssetupchmodfix“ wird nur ausgeführt, wenn die bereitgestellte Commit-Nachricht den Text „opssetup-chmod-fix“ enthält, und nur auf dem mit „otcprj“ gekennzeichneten Runner. Die Phase ändert die Berechtigungen der ausführbaren Bash-Dateien, die von git ausgecheckt wurden, in den aktuellen Projektarbeitsbereichsordner und überträgt die Änderungen zurück an git. Die Standardberechtigungen verweigern die Ausführung.
opssetup-chmod-fix: stage: opssetupchmodfix image: registry1.projekt.de/prj-dind-base script: - chmod oga+x ./ci/bin/*.sh - ./ci/bin/fixexecuteflag.sh tags: - otcprj only: variables: - $CI_COMMIT_MESSAGE =~ /^opssetup-chmod-fix*/
So geht’s: isolieren die Tests
Man könnte meinen, dass die Isolierung der Tests eine schlechte Sache ist, weil man kein Feedback vom echten System, den Daten und den Benutzern bekommt. In den meisten Fällen besteht der Zweck der End-to-End-Tests jedoch darin, zu überprüfen, ob die vorhandene geschäftskritische Funktionalität nach der Durchführung von Änderungen noch wie erwartet funktioniert. Die Überprüfung der Systemkonsistenz oder -leistung darf nicht mithilfe der End-to-End-Tests durchgeführt werden.
So geht’s: vorbereiten der Umgebung für die Durchführung der Tests
Wir haben eine Cloud-Maschine für die Ausführung der End-to-End-Tests mithilfe von Terraform-Skripten erstellt. Wenn mehrere Maschinen benötigt werden, können diese dynamisch erstellt werden. Die Maschine wird mit Ansible konfiguriert. Der Benutzer „usr-dockerex“ wird angelegt und darf (A) eine Verbindung über seinen ssh-Schlüssel herstellen und (B) das Startskript für den End-to-End-Test ausführen. Das Skript launch-tests.sh wird während der Maschineneinrichtung kopiert.
Wir müssen in der Lage sein, mehrere End-to-End-Tests für verschiedene Zweige und Umgebungen parallel laufen zu lassen. Daher verwenden wir die Job-ID, um alle von einem Lauf gestarteten Dienste voranzustellen. Wir verwenden Docker Compose, um die Container zu starten.
An jeden Containernamen fügen wir die Auftrags-ID wie folgt an:
container_name: ui-client${JOB_ID}
image: "registry1.projekt.de/ui-client:${ CLIENT_IMAGE_TAG}"
Die Microservices, die nicht geändert werden, müssen für den End-to-End-Test ebenfalls gestartet werden. Dazu müssen wir eine Zielumgebung angeben, von der wir die neuesten Images für diese Umgebung erhalten. Jede Umgebung ist an einen geschützten Zweig gebunden.
Nehmen wir an, wir haben 3 Microservices und nur 2 werden geändert. Jeder befindet sich in seinem eigenen Git-Repository. Die Änderungen für dieselbe Story/dasselbe Feature werden in einem Zweig mit demselben Namen durchgeführt, sagen wir feature1. Die erste Umgebung, in der das Feature bereitgestellt wird, sollte die Entwicklungsumgebung sein. Daher muss der unveränderte microservice3 das neueste Image verwenden, das für die Entwicklungsumgebung erstellt wurde. Die verwendeten Bilder sind in diesem Fall die folgenden:
• image: "registry1.projekt.de/microservice1:feature1" • image: "registry1.projekt.de/ microservice2:feature1" • image: "registry1.projekt.de/ microservice3:dev1_latest"
function check_branch_existence(){ local result=$1 local project=$2 local branch=$3 local exists=$(curl -I -so /dev/null -w "%{http_code}" "https://gitlab.com/api/v4/projects/${project}/repository/branches/${branch}?private_token=${GRP_GITUSER_ACCESSTOKEN}") eval $result="'$exists'" }
Wir müssen die Existenz eines Docker-Image-Tags überprüfen, um den richtigen Container zu starten oder auf ein Standard-Image zurückzugreifen, das eigentlich das Dev-Image ist. Wir verwenden die folgende Funktion:
function check_image_tag_existence(){ local result=$1 local project=$2 local image_tag=$3 local repo=$4 echo "docker inspect --type=image ${repo}/${project}:${image_tag}" local exists=$(docker inspect --type=image "${repo}/${project}:${image_tag}" --format "{{.Id}}" > /dev/null 2>&1 && echo $? || echo $?) echo "Exists image? ${exists}" if [ $exists -eq 1 ]; then echo "try to pull image" local pullresult=$(docker pull "${repo}/${project}:${image_tag}";echo $?) echo "pull result ${pullresult}" echo "tried to pull image ${repo}/${project}:${image_tag}" fi exists=$(docker inspect --type=image "${repo}/${project}:${image_tag}" --format "{{.Id}}" > /dev/null 2>&1 && echo $? || echo $?) echo "after second inspect $exists" eval $result="'$exists'" }
Wir iterieren durch alle unsere GitLab-Microservices-Projekte, um die richtigen Umgebungsvariablen mit den Container-Images und Tags zu berechnen.
projects=("1233xxx" "1224xxxx"… ) project_names=(..) project_vars=(..) for i in ${!projects[@]}; do checkImageTag ${projects[$i]} ${project_names[$i]} ${project_vars[$i]} $imageTag "dockerregistry1.server" "dev1-latest" done
function checkImageTag(){ local curProjId=$1 local curProjName=$2 local curProjVar=$3 local curProjTag=$4 local curRegistry=$5 local envBranch=$6 check_image_tag_existence imageexistresult $curProjName $curProjTag $curRegistry if [ $imageexistresult -eq 0 ]; then eval "export $(echo ${curProjVar}_IMAGE_TAG | tr [a-z] [A-Z])=$imageTag" else #fallback to the default tag check_image_tag_existence imageexistresult $curProjName $envBranch $curRegistry if [ $imageexistresult -eq 0 ]; then eval "export $(echo ${curProjVar}_IMAGE_TAG | tr [a-z] [A-Z])=dev1-latest" else echo "image ${curProjTag} for project ${curProjName} does not exist!" exit 1 fi fi }
Bisher haben wir (A) die Containernamen so berechnet, dass sie für verschiedene Jobs eindeutig sind, und (B) die richtigen Docker-Image-Tags basierend auf dem Zweignamen und der Zielumgebung. Wir vermissen noch die richtige Bereinigung, bevor wir die neuen Container für die Tests starten.
Die Ausführung der End-to-End-Tests dauert weniger als eine Stunde, so dass wir alle Container, die vor mehr als einer Stunde gestartet wurden und noch laufen, stoppen können. Die Container haben das Präfix ad. Wir filtern diese Container und zählen sie. Wenn es solche Container gibt, verwenden wir Docker Stop und geben die Liste der gefilterten Container-IDs aus.
if [ `docker ps --filter "status=running" --filter "name=ad" | grep 'hour.* ago' | awk '{print $1}' | wc -l ` -gt 0 ] ; then docker stop $(docker ps --filter "status=running" --filter "name=ad" | grep 'hour.* ago' | awk '{print $1}') fi
if [ `docker ps --filter "status=exited" | grep 'hour.* ago' | awk '{print $1}' | wc -l ` -gt 0 ] ; then docker rm $(docker ps --filter "status=exited" | grep 'hour.* ago' | awk '{print $1}') fi
if [ `docker inspect --format='{{.Name}} ' $(docker ps -aq --filter "status=running" --filter "name=admicroservice1*" ) | cut -d r -f 3 | wc -l` -lt 5 ] ; then echo 'allowing not more than 5 running gui tests' else echo 'there are running in parallel already 5 gui test, please retry later ' exit 1 fi
export NETWORK_BRIDGE=nwkbguitest${JOB_ID} [ ! "$(docker network ls | grep $NETWORK_BRIDGE)" ] && docker network create -d bridge $NETWORK_BRIDGE || echo "Network present!"
vardockers=`docker ps -a --filter "name=*${JOB_ID}"| wc -l` if [ $vardockers -eq 1 ] then echo "no containers" else echo "stop and remove all containers" docker stop $(docker ps -a -q --filter "name=*${JOB_ID}") docker rm $(docker ps -a -q --filter "name=*${JOB_ID}") fi
docker-compose -f /opt/docker_compose/docker-compose-apps.yml up -d
docker inspect --format='{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -q) docker logs -f endtoend-tests${JOB_ID}
result=`docker inspect $(docker ps -aq --filter "name=endtoend-tests${JOB_ID}") --format='{{.State.ExitCode}}'` docker-compose -f /opt/docker_compose/docker-compose-apps.yml down docker network rm $NETWORK_BRIDGE docker network prune -f docker image prune -f docker volume prune -f exit $result
So geht’s: bereiten die Daten für die Tests
Wir verwenden das Spock-Framework für Systemtests unserer Rest-API. Spock ist eine Test- und Spezifikationsplattform für Java- und Groovy-Anwendungen, die über den Junit-Runner einfach in IDEs und CI-Pipelines integriert werden kann. Mit Spock können wir Spezifikationen schreiben, die die erwarteten Funktionen eines zu testenden Systems beschreiben. Die Tests können eine Dokumentation für ein breiteres Publikum als die Entwickler erzeugen, indem sie die beschrifteten Blöcke „given“, „when“ und „then“ verwenden (wie im Folgenden):
given: "an admin user" // ...code when: "his company settings are changed" // ...code then: "the user is notified" //...code
Für Tests werden bestimmte Benutzer verwendet, um (so weit wie möglich) die Interferenzen mit den vorhandenen Benutzern zu isolieren, die auf dem System aktiv sind.
Zu Beginn der Ausführung werden die Testdaten vorbereitet. Am Ende wird die Bereinigung durchgeführt.
Die Docker Compose-Datei von oben enthält den MySQL-Dienst, der beim Starten ein SQL-Skript ausführt, um die leeren Datenbanken zu erstellen.
db-mysql: container_name: prj-mysql${JOB_ID} image: mysql:5.8 volumes: - "./setmode.cnf:/etc/mysql/conf.d/setmode.cnf" ports: - "3306" entrypoint: sh -c " echo 'CREATE DATABASE IF NOT EXISTS at_projects /*!40100 DEFAULT CHARACTER SET utf8 */; CREATE DATABASE IF NOT EXISTS at_server; …' > /docker-entrypoint-initdb.d/init.sql; /usr/local/bin/docker-entrypoint.sh --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci "
So geht’s: ausführen der Tests mit Docker Compose
Alle Microservices und erforderlichen Laufzeitabhängigkeiten werden in der Docker Compose-Datei deklariert. Die Containernamen sind eindeutig, während die Image-Tags aus dem Startskript berechnet und als Umgebungsvariablen bereitgestellt werden.
Alle Dienste für einen Auftrag werden im gleichen Netzwerk gestartet, einschließlich des Datenbankservers.
networks: default: external: name: ${NETWORK_BRIDGE}
Ein typischer Backend-Dienst sieht wie folgt aus:
services: prj-server: image: "registry1/prj-server:${PRJ_SERVER_IMAGE_TAG}" container_name: prj-server${JOB_ID} environment: - "SPRING_PROFILES_ACTIVE=otcdev,swagger" - "SQL_SERVER_IP=db-mysql${ JOB_ID}" … ports: - 8080 restart: always mem_limit: 2500m depends_on: - db-mysql
ui-client: image: "registry1 /ui-client:${UI_CLIENT_IMAGE_TAG}" container_name: ui-client${JOB_ID} environment: - "BACKEND_SERVER_URL=http://prj-server${ JOB_ID}:8080" - "BACKEND_CHECKLIST_URL=http://prj-checklists${JOB_ID}:8084" ports: - 8085 restart: always command: /bin/bash -c "envsubst < /opt/enviroment-endpoints.template > /opt/enviroment-endpoints.json && cp /opt/enviroment-endpoints.json /usr/share/nginx/html/assets/enviroment-endpoints.json && exec nginx -g 'daemon off;'"
Das Ausführen des Tests wird tatsächlich in einem speziellen Container ausgeführt, der von einem Chrombild aus gestartet wurde. Das Bild mit allen eingestellten Abhängigkeiten ist bereit, den End-to-End-Test auszuführen.
services: endtoend-tests: image: registry1.projekt-adis.de/adis-dind-chrome container_name: endtoend-tests${JOB_ID}
volumes: - /opt/docker_data/guitest:/repo/tests
entrypoint: > /bin/sh -c "
git config --global user.name \"${GRP_GITUSER_NAME}\"; git config --global user.email \"${GRP_GITUSER_EMAIL}\";
sudo mkdir repo; sudo chmod a+rwx repo; cd repo; sudo mkdir tests/${JOB_ID}; sudo chmod a+rwx tests/${JOB_ID}; sudo rm -rf adis-systemtest;
git -C . clone --branch ${PRJ_SYSTEMTEST_BRANCH} https://${GRP_GITUSER_LOGON}:${GRP_GITUSER_ACCESSTOKEN}@gitlab.com/company/projects/cust/prj/prj-systemtest.git; chmod oga+x ./prj-systemtest/ci/bin/systemtest.sh; cd prj-systemtest;
./ci/bin/systemtest.sh ISOLATED http://prj-server${JOB_ID}:8080 http://prj-projects${ JOB_ID}:8081 http://prj-checklists${ JOB_ID }:8084;
cp -R build/test-results/test /repo/tests/${ JOB_ID}/test-results-system; cat /repo/tests/${ JOB_ID}/test-results-system/TEST-specs.TestSuite.xml; cat /repo/tests/${JOB_ID}/test-results-system/*.xml >> /repo/tests/${ JOB_ID}/test-results-system/systestresult.log; [ `cat /repo/tests/${JOB_ID}/test-results-system/systestresult.log | grep '\failures=.[^0].' | wc -l` -eq 0 ] && echo 'system test success' || exit 1;
cd ..; sudo rm -rf prj-guitest; git -C . clone --branch ${PRJ_GUITEST_BRANCH} https://${GRP_GITUSER_LOGON}:${GRP_GITUSER_ACCESSTOKEN}@gitlab.com/company/projects/client/prj/prj-guitest.git; chmod oga+x ./prj-guitest/ci/bin/*.sh; cd prj-guitest;
./ci/bin/guitest.sh ISOLATED http://prj-client${ JOB_ID} http://prj-admin-client${ JOB_ID};
cp -R target/site /repo/tests/${ JOB_ID}/test-site; cp -R build/test-results/runAParallelSuite /repo/tests/${ JOB_ID }/test-results; cp -R build/reports/tests/runAParallelSuite /repo/tests/${ JOB_ID }/test-reports; cat /repo/tests/${ JOB_ID }/test-site/serenity/results.csv; [ `tail -n +2 /repo/tests/${ JOB_ID }/test-site/serenity/results.csv | grep -v SUCCESS | wc -l` -eq 0 ] && echo 'gui test successful' || exit 1; " mem_limit: 7500m
So geht’s: aufräumen nach den Tests
Nach der Ausführung der Testsuiten führen wir den Bereinigungsschritt aus. Die Bereinigung kann aus verschiedenen Gründen fehlschlagen, z. B. abrupter Programmabbruch aufgrund fehlender Ressourcen, beschädigte Daten oder Netzwerkfehler, fehlerhafte Bereinigungsprozedur oder eine laufende Neuverteilung. Eine Möglichkeit, mit dieser Situation umzugehen, besteht darin, die Bereinigung auch zu Beginn des Tests durchzuführen. Wenn die Bereinigung nicht erfolgreich ist, dann schlagen Sie den Test fehl. Manchmal können Sie keine erstellten Ressourcen löschen, wenn die Lösch-API nicht verfügbar ist oder die Bereinigung zu teuer ist.
Unsere Lösung besteht darin, für jede End-to-End-Testausführung immer eine saubere Datenbank zu verwenden und diese mit den Systemtests zu bestücken. Dazu benötigten wir eine isolierte Umgebung.
So geht’s: die Tests so schnell wie möglich zu machen
Die End-to-End-Tests werden parallel ausgeführt, um die Ausführungszeit zu minimieren. Dazu werden die Tests in unabhängige Testsuiten aufgeteilt. Das bedeutet, dass die von einer Suite verbrauchten oder produzierten Daten die Daten anderer parallel laufender Suiten nicht verändern. Achten Sie darauf, wenn sich die Aktionen bestimmter Benutzer auf die laufenden Sitzungen aller anderen Benutzer auswirken. Zum Beispiel: die Benutzersprache wird geändert, die Benutzereinstellung, das Menü nicht anzuzeigen, wird geändert, die Berechtigungen werden geändert.
Wie oben gesehen, gibt es einen Gradle-Task, der den Standard-Test-Task erweitert, dem wir die Anwendungs-URLs übergeben, auf die der Browser zeigen soll:
Für die Standard-Testaufgabe (d. h. diejenige, auf die der Browser basierend auf den URLs der Anwendung zeigen soll) gibt es eine Gradle-Aufgabe, die diese erweitert.
task runAParallelSuite(type: Test) { systemProperty "webdriver.base.url", findProperty("webdriver.base.url") maxParallelForks = 3 forkEvery = 1 include '**/**TestSuite.class' testLogging.showStandardStreams = true }
Weitere Faktoren, die die Leistung beeinflussen, sind die Logging-Funktion und die Erfassung von Screenshots. Wir haben die Logging-Funktion auf ein akzeptables Niveau reduziert und die Screenshots so eingestellt, dass sie nur im Fehlerfall aufgenommen werden. Die Datei serenity.properties, die sich im Stammverzeichnis des End-to-End-Projekts befindet, enthält die folgenden Konfigurationen:
serenity.take.screenshots=FOR_FAILURES serenity.logging=NORMAL
chrome.switches=--headless --disable-extensions --disable-gpu --disable-dev-shm-usage --no-sandbox
Innerhalb der Ende-zu-Ende-Tests müssen für maximale Performance xpaths auf Basis von ids verwendet werden.
So geht’s: Wartung der Testsuite
User Journeys werden für geschäftskritische Szenarien geschrieben. Hier führt der Benutzer verschiedene Aktionen aus, um ein bestimmtes Ziel zu erreichen. Die Aufgaben werden in der Benutzeroberfläche ausgeführt, indem er mit ihr interagiert. Der Test stellt Fragen oder macht Annahmen über das Ergebnis der sichtbaren Aufgabe.
Gruppieren Sie den Code in wiederverwendbare Aufgaben oder Aktionen, die vom Benutzer ausgeführt werden:
public abstract class BaseLoginTask implements Task public class OpenMyProfilePage implements Task public class EnterValueIntoField implements Task
public class ConfirmationMessageAppears implements Question<String> public class ValueInColumnOfTable implements Question<List<String>>
public static Target ADD_TASK = Target.the("AddTask button").located(By.id("btn-add-tasks"))
Target.the("The second selected interaction").locatedBy("//div/span/app-link-or-text/a");
WaitUntil.the(ChecklistTemplates.ADD_CHECKLIST_TEMPLATE_BUTTON, WebElementStateMatchers.isEnabled()) .forNoMoreThan(TimeConstants.SECONDS_10). seconds().performAs(actor);
public static final int SECONDS_1 = 1 * factor; public static final int SECONDS_3 = 3 * factor; public static final int SECONDS_10 = 10 * factor; public static final int LOADER = 5 * factor; public static final int LONG_LIST = 10 * factor;
#How long webdriver waits for elements to appear by default, in milliseconds. webdriver.wait.for.timeout=5000 #How long webdriver waits by default when you use a fluent waiting method, in milliseconds. webdriver.timeouts.implicitlywait=5000 #How long should the driver wait for elements not immediately visible, in milliseconds. serenity.timeout=5000
WaitUntil.the(MainUIUtilities.LOADER, WebElementStateMatchers.isVisible()); WaitUntil.the(MainUIUtilities.LOADER, WebElementStateMatchers.isNotPresent()). forNoMoreThan(TimeConstants.SECONDS_10).seconds().performAs(actor);
Fazit
- Private Git-Runner müssen für die Auftragsausführung registriert warden
- Minio kann als Pipeline-Cache-Provider verwendet warden
- Bei den End-to-End-Tests muss ein eigenes GitLab-Projekt eingerichtet werden.
- Die Ausführung kann von einem beliebigen Projekt aus einer in der Datei gitlab-ci.yml definierten Phase ausgelöst werden, indem das Container-Image pipeline-trigger verwendet wird.
- Die Tests sollten in einer dedizierten/provisionierten Umgebung ausgeführt werden, die zur besseren Stabilität von externen Einflüssen isoliert ist.
- GitLab-API kann für komplexe Situationen genutzt warden
- Die Tests sollten in unabhängige Testsuiten aufgeteilt werden, die parallel laufen müssen, ohne Kopf, nur mit Screenshots bei Fehlern, um die beste Leistung zu erreichen
- User journeys must be defined to test only the business-critical happy paths.
- Die Backend-Integrationstests können verwendet werden, um die Daten für die End-to-End-Tests aufzufüllen.
- Für die Wartbarkeit müssen überall in der UI ids verwendet werden, zusätzlich zu den Praktiken der Codeüberprüfung und der Wiederverwendung von Code.
- Warten auf Bedingung, mit einem Timeout, muss anstelle einer festen Wartezeit verwendet werden.
- Eine Bereinigung der Testdaten sollte bei jedem Run durchgeführt werden
_
Wie nähern Sie sich End-to-End-Tests / Pipeline-Integration? Gibt es Erfahrungen, die Sie teilen möchten? Lassen Sie es uns wissen!