End-to-End-Tests Reise & Integration in eine GitLab-Pipeline

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.

Berg Software - End-to-end tests an GitLab integration - 01 Interaction journey
Berg Software - End-to-end tests an GitLab integration - 02 Test count

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.

Berg Software - End-to-end tests an GitLab integration - 03 End-to-end tests pyramid

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.

Berg Software - End-to-end tests an GitLab integration - 04 How to set up the runners and caching
Jeder Runner muss registriert und mit bestimmten Etiketten oder Tags konfiguriert werden. Für jedes Projekt können wir festlegen, welche Runner für die Ausführung der Pipelines zugewiesen werden sollen, basierend auf den Tags der Runner. Sie müssen ein Registrierungs-Token von der Seite der GitLab-Gruppe in den CI/CD-Einstellungen erhalten.

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=…
Der Runner ist ein Docker-Container, der auf einer unserer Cloud-Maschinen ausgeführt wird. Das ausgeführte Docker-Image stammt aus dem Docker-Repository gitlab/gitlab-runner:alpine.
- --docker-image=alpine:latest
Der Befehlsabschnitt definiert, welcher Befehl beim Starten des Containers ausgeführt wird. Es gibt eine einmalige Ausführung.

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
Das Caching verwendet einen AWS S3-ähnlichen Objektspeicher, um die Build-Abhängigkeiten zu speichern. Als S3-Provider wird Minio verwendet:
    - --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
Der MinIO-Container, der für die Zwischenspeicherung verwendet wird, muss im selben Docker-Netzwerk wie der Runner ausgeführt werden.
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
Ein Taglistenflag beschriftet den Runner; es kann verwendet werden, um den Runner mit einem bestimmten Projekt zu kartieren.
- --tag-list=otcmain
Nach der Initialisierung können wir die Runner mit Docker Compose auf unseren Code-Qualitätsmaschinen starten:
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.

Berg Software - End-to-end tests an GitLab integration - 05 How to set up the pipeline
Wir haben fünf Umgebungen, jede mit ihrem spezifischen geschützten Zweig. Wenn dort ein bestimmter Commit durchgeführt wird, wird die Anwendung auf dieser speziellen Umgebung gebaut und bereitgestellt. Für die Produktionsumgebung müssen wir ein bestimmtes Tag mit einer spezifischen Namenskonvention für die Bereitstellung erstellen.

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
Die Phasen sind wie folgt definiert:
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 verwenden pro Zweigcache. Alle Abhängigkeiten werden im Projektordner zwischengespeichert, und der Cache kann bei jeder Jobausführung innerhalb des jeweiligen Projekts wiederverwendet werden. Am Ende des Auftrags werden die angegebenen Ordner in ein Archiv cache.zip gezippt und unter dem angegebenen Schlüssel auf dem Rechner gespeichert, auf dem der GitLab-Runner ausgeführt wird.

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
Die Testphase mit der Bezeichnung ‚guitest‘ startet die Ausführung der End-to-End-Tests:
guitest:
  image: registry1.projekt.de/prj-dind-chrome
  stage: test   
  script:
    - ./ci/bin/guitest.sh "$EXECUTE_TEST_FOR_ENVIRONMENT" "$SRC_TRGR_BRANCH"
  tags:
    - guirunner
Die Testphase ist mit bestimmten Runnern verbunden, basierend auf dem Tag-Label. Nur die Runner, die das gleiche Tag ‚guirunner‘ haben, können die Ausführung dieser Phase abholen.

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
Der Runner, der diese Phase ausführt, startet den angegebenen Container aus dem definierten Bild; wird den Code des End-to-End-Projekts überprüfen; und startet (innerhalb des Containers) das im Skriptabschnitt definierte Skript:
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 Befehl ist im Docker-Image registry.gitlab.com/finestructure/pipeline-trigger verfügbar.

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
Die Phase wird nur für bestimmte Zweige ausgeführt, die im Abschnitt ‚only:‘ definiert sind. Darüber hinaus wird die Phase nicht ausgeführt, wenn die vom Entwickler gegebene Commit-Nachricht den Text „opssetup“ enthält, der als regulärer Ausdruck ausgedrückt wird. Es gibt eine logische ‚and‘-Bedingung zwischen den beiden.
only:
    - branch_dev1
    - branch_dev2
    - branch_preview
    - master
    - /^story-/
  except:
    variables:
      - $CI_COMMIT_MESSAGE =~ /^opssetup-*/
Das Skript guitest.sh kann den End-to-End-Test innerhalb des Runner-Containers oder auf einem anderen dedizierten Remote-Rechner starten. Wir begannen mit dem ersten Ansatz, endeten aber damit, die Tests auf einer dedizierten Maschine laufen zu lassen.

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
Der erste Schritt bestand darin, den In-Memory-Display-Server und die Auflösung einzurichten. Im zweiten Schritt starten wir einen benutzerdefinierten Gradle-Task. Weitere Details folgen.

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*/
Die Phase muss einmal ausgeführt werden, wenn sie aus einer Windows-Umgebung kommt, da es keine einfache Möglichkeit gibt, die Ausführungsberechtigung für die Bash-Dateien zu setzen.

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}
Wir erstellen ein Docker-Image in der Pipeline jedes Microservices und schieben es in unser Docker-Repository. Von dort aus werden die End-to-End-Tests die Bilder ziehen und starten. Verschiedene Zweige erzeugen verschiedene Docker-Images, mit wohldefinierten Bezeichnungen.
image: "registry1.projekt.de/ui-client:${ CLIENT_IMAGE_TAG}"
Das Tag wird mithilfe der GIT-API basierend auf dem Quellzweig, der Zielumgebung und dem Projekt berechnet, das den Build ausgelöst hat. Bei der Arbeit an einem Feature können mehrere Microservices und UI-Clients betroffen sein.

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"
Wenn der Zweig feature1 für den Microservice3 existieren würde, würde er auch für die End-to-End-Tests verwendet werden. Wir prüfen die Existenz eines Zweigs mit dem folgenden Skript:
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'"
}
 
Wenn die Verzweigung existiert, sollte die Ausgabeergebnisvariable den 204 HTTP-Antwortcode enthalten.

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'"
}
Wenn das Image nicht lokal heruntergeladen wird, findet der Befehl „docker inspect“ es nicht. Daher wird vor dem zweiten inspect-Befehl ein Pull durchgeführt. Das Ergebnis der zweiten Inspektion wird zurückgegeben.

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
 
Die Funktion checkImageTag exportiert für jeden Microservice die richtigen Umgebungsvariablen, bzw. gibt sie vor.
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
}
Wir werden für jeden Microservice eine Variable in Form von SERVICE_VAR_NAME_IMAGE_TAG exportiert haben. Der Var-Name ist nach Konvention der Großbuchstabe des Dienstnamens, wobei das Minuszeichen durch einen Unterstrich ersetzt wird.

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
Wir werden die Container danach löschen.
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
Abhängig von den Ressourcen der Testmaschine(n) können wir die Anzahl der Tests begrenzen, die parallel auf einer Maschine laufen. Es spielt keine Rolle, welcher Dienst gezählt wird, es kann jeder sein.
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
Für jede Job-ID wird ein neues Netzwerk erstellt.
export NETWORK_BRIDGE=nwkbguitest${JOB_ID}
[ ! "$(docker network ls | grep $NETWORK_BRIDGE)" ] && docker network create -d bridge $NETWORK_BRIDGE || echo "Network present!"
 
Wenn die Container bereits für dieselbe Auftrags-ID laufen, stoppen und entfernen wir sie über die folgenden Befehle:
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
Nachdem die erste Bereinigung durchgeführt wurde, starten wir alle Container mit Docker Compose im Hintergrund.
docker-compose -f /opt/docker_compose/docker-compose-apps.yml up -d
Anschließend werden die IPs der Container auf der Konsole protokolliert und die Log-Ausgabe des End-to-End-Testcontainers wird auf die Konsole umgeleitet.
docker inspect --format='{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -q)
docker logs -f endtoend-tests${JOB_ID}
Wenn die Tests abgeschlossen sind, wird die endgültige Bereinigung durchgeführt und der Ergebniscode wird ausgewertet und zurückgegeben.
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
Die Docker Compose .yml-Datei wird in einem weiteren Abschnitt erläutert.

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
Die erstellten Berichte sehen wie folgt aus:
Berg Software - End-to-end tests an GitLab integration - 06 Reports
Die Tests sind in Testsuiten und Specs organisiert. Jede Spec befasst sich mit einem bestimmten Bereich unserer APIs.
Berg Software - End-to-end tests an GitLab integration - 07 Reports
Grundsätzlich verwenden wir die Systemtests, um eine leere Datenbank zu füllen, auf der wir die End-to-End-Tests ausführen. Eine kleine Anzahl von Systemtests wird speziell für den End-to-End-Test geschrieben. Dies ist eine Notwendigkeit, bei der einige Daten benötigt werden, oder einige Workflows müssen im Voraus vorbereitet werden, da dies nicht nur von den End-to-End-Tests erstellt werden kann. Die Tests, die zur Erleichterung der Durchführung von End-to-End-Tests erstellt werden, sollten klar unterschieden werden.

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}
Für jeden Container müssen einige Ressourcenlimits festgelegt werden (z. B. Speicher und CPU).

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
Ein typischer Frontend-Dienst sieht wie folgt aus:
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;'"
Eine interessante Sache ist die Ersetzung der Umgebungsvariablen der Backend-URLs in eine Datei, die dem nginx-Webserver zur Verfügung gestellt wird. Diese Datei wird dann in das UI-Image eingebunden, um das UI der End-to-End-Tests auf das richtige Backend zu verweisen. An diesem Punkt benötigen wir nur eine Instanz für jeden Dienst.

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}
 
Der Abschnitt ordnet einen Host-Ordner einem Container-Ordner zu. Der Host-Ordner bleibt auch nach Beendigung der Arbeit des Containers bestehen. Hier werden die End-to-End-Testberichte gespeichert.
volumes:
      - /opt/docker_data/guitest:/repo/tests
Der Einstiegspunkt definiert den Befehl, der beim Starten des Containers ausgeführt werden soll. Es gibt mehrere Linux-Befehle, die durch ;
entrypoint: >					           
        /bin/sh -c "
Zunächst wird der Git-Benutzer so konfiguriert, dass er beim Übertragen erscheint.
git config --global user.name \"${GRP_GITUSER_NAME}\";	
git config --global user.email \"${GRP_GITUSER_EMAIL}\";
 
Es werden eindeutige Ordner für jede Job-ID erstellt und Zugriffsrechte vergeben.
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;
Der Code für den Systemtest wird ausgecheckt und in der frischen Umgebung ausgeführt, um die End-to-End-Testdaten zu füllen.
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;
Die Tests zum Auffüllen der Daten werden gestartet, wobei sie die richtigen Backend-URLs erhalten.
./ci/bin/systemtest.sh ISOLATED http://prj-server${JOB_ID}:8080 http://prj-projects${ JOB_ID}:8081 http://prj-checklists${ JOB_ID }:8084;
Die Testergebnisse werden geparst, und der Auftrag ist fehlgeschlagen, wenn es Fehler gibt.
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;
Die End-to-End-Tests werden aus Git ausgecheckt (d. h. aus dem berechneten Zweig).
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;
 
Der End-to-End-Test wird gestartet, wobei auch die URLs der Clients der Benutzeroberfläche bereitgestellt werden.
./ci/bin/guitest.sh ISOLATED http://prj-client${ JOB_ID} http://prj-admin-client${ JOB_ID};
Die Ergebnisse werden ausgewertet, geparst und an einen Ort kopiert, an dem der nginx-Server sie nach außen liefern kann.
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
}
 
Es gibt 3 Testsuiten, die parallel auf drei Threads ausgeführt werden.

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
Um die Browserfenster nicht zu stören, ist auch das Laufen im Headless-Modus eine Voraussetzung. Unter Linux muss ein virtuelles Terminal mit einer bestimmten Auflösung eingerichtet werden, wie oben gezeigt.
chrome.switches=--headless --disable-extensions --disable-gpu --disable-dev-shm-usage --no-sandbox
Irgendwann bekamen wir eine Menge ungültiger Sitzungsfehler und den Headless-Modus, die durch Hinzufügen der beiden Flags -disable-gpu -disable-dev-shm-usage behoben wurden.

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
 
Schreiben Sie wiederverwendbare Fragen oder Abfragen für die Benutzeroberfläche:
public class ConfirmationMessageAppears implements Question<String>
public class ValueInColumnOfTable implements Question<List<String>>
 
Um eine wartbare Testsuite zu haben, ist eine wichtige Sache, xpaths zu verwenden, die auf ids basieren und nicht auf parent/child-Relationen innerhalb des HTML. Die Änderungen am Layout werden sehr oft durchgeführt und sollten die End-to-End-Tests nicht beeinflussen. Verwenden Sie Konstrukte wie dieses:
public static Target ADD_TASK = Target.the("AddTask button").located(By.id("btn-add-tasks"))
statt:
Target.the("The second selected interaction").locatedBy("//div/span/app-link-or-text/a");
Eine andere Sache ist, keine hard-codierten Wartezeiten einzuführen (wie „warte für 20 Sekunden“). Verwenden Sie stattdessen Konstrukte, die auf das Eintreten bestimmter Bedingungen warten und eine Zeitüberschreitung vorsehen:
WaitUntil.the(ChecklistTemplates.ADD_CHECKLIST_TEMPLATE_BUTTON,             WebElementStateMatchers.isEnabled()) 
.forNoMoreThan(TimeConstants.SECONDS_10). seconds().performAs(actor);
 
Es sollten mehrere Stufen von Timeouts als Konstanten festgelegt werden, die überall verwendet werden können. Außerdem sollte ein Faktor zum Anpassen der Timeouts auf einmal festgelegt und als Systemeigenschaften angegeben werden, wenn sie in einer langsameren Umgebung laufen.
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;
 
In serenity.properties haben wir:
#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
 
Für langsame Seiten empfehlen wir, wenn möglich, die Einführung eines Fortschrittsbalkens oder einer Ladeanzeige in der Benutzeroberfläche, die zu Beginn und am Ende langlaufender Vorgänge überprüft werden kann.
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!

29 Jahre im Geschäft | 2700 Software-Projekte | 760 Kunden | 24 Länder

Wir verwandeln Ideen in Software. Wie lautet Ihre Idee?

Kontakt aufnehmen

6 + 12 =