Das Wissensportal für IT-Professionals. Entdecke die Tiefe und Breite unseres IT-Contents in exklusiven Themenchannels und Magazinmarken.

SIGS DATACOM GmbH

Lindlaustraße 2c, 53842 Troisdorf

Tel: +49 (0)2241/2341-100

kundenservice@sigs-datacom.de

Qualität am laufenden Band

Bei der Entwicklung von CI/CD-Pipelines gehen Softwareengineers oft nach der „Trial and Error“-Methode vor: Pipelinecode schreiben, Pipeline anstoßen, warten, Fehler analysieren, Behebungsversuch und erneut probieren. Dieses Vorgehen ist nicht nur langwierig und mühsam, sondern aufgrund der Fehleranfälligkeit auch gefährlich, da fehlerhafte Deployments drohen. Ein testgetriebenes Vorgehen bei Pipelines ist demgegenüber vorzuziehen. Die Pipelines werden dadurch so robust wie die Anwendung.
Author Image
Lukas Pradel

Author


  • 25.05.2023
  • Lesezeit: 11 Minuten
  • 75 Views

Als Kent Beck gegen Ende des vergangenen Jahrtausends mit seinem Buch über Extreme Programming [Beck99] die Technik der testgetriebenen Softwareentwicklung vorgestellt hat, war ihm vermutlich nicht klar, dass er die professionelle Softwareentwicklung nachhaltig verändern würde. Seine Methode ist heute nicht mehr wegzudenken, obwohl ihr eine ganze Zeit lang viele Skeptiker mit Kritik und Argwohn begegnet sind. Am häufigsten wurde das Argument ins Feld geführt, dass bei der testgetriebenen Entwicklung deutlich mehr Code produziert wird, der mitgewartet werden muss, und dass sie einen höheren initialen Aufwand bei der Umsetzung von Anforderungen mit sich bringt.

Der Zyklus der testgetriebenen Softwareentwicklung

Heute dagegen besteht die herrschende Meinung darin, dass diese Aufwände sich mittel- oder spätestens langfristig mehr als auszahlen, qualitativ höherwertige Software hervorbringen, die weniger Bugs enthält und darüber hinaus modularer, flexibler und erweiterbarer ist.

Im Kern der testgetriebenen Entwicklung steht ein einfacher Zyklus aus drei Schritten, den Softwareengineers bei der Umsetzung einer Anforderung durchlaufen (siehe Abbildung 1).

Abb. 1: Der Entwicklungszyklus der testgetriebenen Softwareentwicklung

Am Anfang der Umsetzung einer Anforderung steht nicht etwa eine Klasse im Produktivcode, sondern der Unit-Test für eben diese Klasse, der zunächst zwangsläufig fehlschlagen muss (roter Kreis). Im nächsten Schritt (grüner Kreis) erhält die Klasse gerade so viel Produktivcode, wie erforderlich ist, sodass der Test bestanden wird. Im dritten Schritt werden der Produktiv- und gegebenenfalls auch der Testcode einem Refactoring unterzogen, sodass diese den gewünschten Qualitätsansprüchen genügen. Denn oftmals ist die einfachste Implementierung für einen Unit-Test nicht auch die verständlichste.

Dann beginnt der Zyklus erneut, indem ein weiterer Unit-Test für die Anforderung ergänzt wird. Der Zyklus wird so lange durchlaufen, bis alle Tests und Klassen für die Anforderung umgesetzt sind.

CI/CD-Pipelines

Fast genauso selbstverständlich wie das testgetriebene Vorgehen sind in der modernen Softwareentwicklung zumindest die kontinuierliche Integration (continuous integration, CI) und immer öfter auch das kontinuierliche Deployment (continuous deployment, CD). Die Erfahrung zeigt, dass es für hohe Produktivität bei der Softwareentwicklung zuträglich ist, wenn die Softwareengineers in einem Team, das gemeinsam an einem Produkt arbeitet, ihren Code regelmäßig miteinander integrieren. Jede solche Integration kann beispielsweise in Form eines Merge-Requests automatisiert über einen Buildserver verifiziert werden – vorausgesetzt natürlich, es liegen aufgrund eines testgetriebenen Vorgehens bei der Entwicklung hinreichend abdeckende Unit- und Integrationstests vor. Jede erfolgreiche Integration ist dann ein potenzieller Lieferkandidat der Software.

Beim kontinuierlichen Deployment wird das Artefakt (bei einem vorhergegangenen Codereview und Merge) auch tatsächlich in diverse Test- und Staging-Umgebungen ausgerollt. Da diese Abfolge in größeren Teams oder Organisationen mehrere Dutzend Mal an einem einzelnen Tag durchlaufen wird, erfolgt sie automatisiert. Jeder einzelne Schritt („stage“) stellt ein „quality gate“, also eine Qualitätsprüfung, dar. Wenn das Softwareartefakt eine der Überprüfungen nicht besteht, beispielsweise den Kompiliervorgang oder das Ausführen aller Tests, dann bricht die Pipeline ab. Anderenfalls kann das Artefakt bei vollständigem Durchlaufen der CI/ CD-Pipeline bis in die Produktionsumgebung vollkommen automatisiert ausgeliefert werden (siehe Abbildung 2).

Abb. 2: Eine einfache CI/CD-Pipeline

Für die Definition von CI/CD-Pipelines kommen grafisch-konfigurative Verfahren oder Programmiersprachen wie Ruby bei Gitlab-CI oder Groovy bei Jenkins-Pipelines zum Einsatz.

Testgetriebene Entwicklung von CI/CD-Pipelines

Wer schon einmal mit einer CI/CD-Pipeline Software vollautomatisch bis in die Produktion geliefert hat, der weiß, dass der Qualitätsanspruch an die Pipeline gar nicht groß genug sein kann, denn sie ist das Einzige, was die Software von der Produktion trennt. Wenn die Pipeline fehlerhaft implementiert ist, kann das unter Umständen drastische Folgen haben. Zum Beispiel wenn fehlerhafte, veraltete oder falsch konfigurierte Artefakte in die Produktion deployt werden. Genau genommen ist es also nur konsequent, der Pipeline mit denselben Ansprüchen zu begegnen, wie dem Softwarecode selbst. Und bei diesem kommt heutzutage selbstverständlich die testgetriebene Softwareentwicklung zum Einsatz. Warum also nicht auch bei CI/ CD-Pipelines?

Zum einen fällt immer wieder auf, dass Softwareengineers dazu neigen, alles, was nicht unmittelbarer Teil des Anwendungscodes ist, mehr oder weniger stiefmütterlich zu behandeln. Gerade Engineers, die primär im Bereich Java-Backend tätig sind, empfinden Infrastrukturprobleme eher als lästig. Zum anderen fehlt vielleicht auch Bewusstsein oder Kenntnis, dass die geeigneten Tools inzwischen durchaus zur Verfügung stehen.

Im Ökosystem von Java erfreut sich nach wie vor der Automatisierungsserver Jenkins großer Beliebtheit, bei dem sich mit den Jenkins-Pipelines mühelos beliebig mächtige und komplexe CI/CD-Pipelines für jede noch so spezielle Anforderung realisieren lassen. Um eine Jenkins-Pipeline nun testen zu können, stellt sich natürlich die Frage, wie sich die Umgebung, also in diesem Fall Jenkins, mocken lässt. Dazu eignet sich am besten das Testframework „Jenkins-Pipeline-Unit“ [JPU], welches in Form einer Groovy-Bibliothek eingebunden wird. Insbesondere bietet die Bibliothek seit einiger Zeit eine offiziell noch experimentelle Unterstützung der deklarativen Jenkins-Pipelines, welche seit einiger Zeit der De-facto-Standard für CI/ CD-Pipelines in Jenkins sind.

Die eigentlichen Unit-Tests werden in der JVM-Sprache Groovy in Kombination mit dem Unit-Test-Framework JUnit geschrieben. Wer mit Java vertraut ist, kann sich schnell einarbeiten und orientieren. Am schwierigsten gestaltet sich wie so oft das korrekte Setup eines entsprechenden Projekts, sodass die verschiedenen Frameworks sauber miteinander funktionieren.

Einrichtung der Testumgebung

Als Basis dient das bewährte Buildsystem Maven, das in der Welt der Java-Entwicklung weite Verbreitung und Bekanntheit genießt. Eine geeignete Konfiguration der Frameworks über eine POM ist im Codebeispiel in Listing 1 gegeben.

Listing 1: Das Projekt-Setup mit Maven

Zu beachten ist hier insbesondere die Verwendung einer hinreichend aktuellen Version von Jenkins-Pipeline-Unit. Als Laufzeitumgebung kommt Groovy in der Version 3 zum Einsatz. Diese wird über das GMavenPlus-Plug-in in Maven eingebunden. Da lediglich ein Jenkinsfile getestet wird, genügt es, die Unit-Tests in Groovy zu kompilieren.

Für Groovy-Neulinge stellt sich schließlich noch die Frage nach der geeigneten Dateiund Ordnerstruktur im Projekt (siehe Abbildung 3). Die Bibliothek Jenkins-Pipeline-Unit erwartet die Pipelineskripte von Jenkins per Konvention im Verzeichnis src/main/jenkins.

Die Unit-Tests liegen in einem frei wählbaren Package im Verzeichnis test/groovy.

Abb. 3: Die Projektstruktur

Der erste Unit-Test

Zum jetzigen Zeitpunkt ist die Datei mit den Unit-Tests leer und das Jenkinsfile existiert noch gar nicht, denn beim Entwicklungszyklus der testgetriebenen Entwicklung steht nun mit der ersten Anforderung („die Pipeline soll erfolgreich durchlaufen“) zunächst der erste Unit-Test an, der verifiziert, ob die Pipeline erfolgreich ist.

Wie bei Java gilt die Konvention, dass Klassen mit Unit-Tests auf Test enden.

Daher wird eine Datei mit dem Namen PipelineTest.groovy bei der Testausführung vom Maven-Surefire-Plug-in entsprechend berücksichtigt. In dieser Datei muss nun eine Klasse definiert werden, die von der Klasse DeclarativePipelineTest aus der Jenkins-Pipeline-Unit-Bibliothek erbt. Vor der Ausführung eines JUnit-Tests muss dann zunächst die setUp-Methode der Basisklasse aufgerufen werden, was am einfachsten über eine mit @Before annotierte setUp-Methode zu realisieren ist (Listing 2).

Listing 2: Erster Unit-Test für erfolgreiche Pipeline

Der erste eigentliche Unit-Test wird dann, wie aus Java bekannt, in JUnit über eine entsprechend annotierte Methode eingeleitet. Mit der Anweisung runScript ("Jenkinsfile") wird das Jenkinsfile in einer gemockten Jenkins-Pipeline-Umgebung ausgeführt. Das Ergebnis der Ausführung wird mit der selbstredenden Anweisung assertJobStatusSuccess() überprüft. Schließlich wird mittels printCallStack der Aufrufbaum der Pipeline in der Konsole geloggt.

Bei Ausführung des Tests über mvn clean test wird dieser Test nun erwartungsgemäß fehlschlagen, da es noch gar kein Jenkinsfile mit einer entsprechenden Pipeline gibt.

Die erste Pipeline

Nun muss daher im zweiten Schritt des testgetriebenen Entwicklungszyklus das Jenkinsfile mit einer minimalen Jenkins-Pipeline gefüllt werden, die den Test erfolgreich durchlaufen lässt (Listing 3). Ein syntaktisch valides, minimales Jenkinsfile enthält mindestens eine Stage mit mindestens einem Befehl. In diesem Fall geben wir einfach auf der Jenkins-Konsole „Hello world!“ aus. Bei Ausführung des Tests ist dieser nun tatsächlich erfolgreich und der erwartungsgemäße Jenkins-Stacktrace wird in der Konsole geloggt (Listing 4).

Listing 3: Eine minimale erfolgreiche Pipeline

Listing 4: Konsolenausgabe des Tests

Im letzten Schritt, dem Refactoring, gibt es derzeit noch nichts zu tun, da die Anforderung einer erfolgreichen Pipeline umgesetzt und der dafür geschriebene Code erst einmal ausreichend ist.

Vom Minimalbeispiel zur Pipeline

Die bisherige Pipeline ist für ein echtes Projekt offensichtlich nicht brauchbar. Anstatt zu fordern, dass die Pipeline erfolgreich durchlaufen muss, erscheint es sinnvoller, zu fordern, dass diese genau dann erfolgreich durchlaufen muss, wenn auch der Buildvorgang des Projekts erfolgreich war. Um dies zu verifizieren, sind zwei Unit-Tests für den Positiv- und den Negativfall nötig (Listing 5).

Listing 5: Unit-Tests für erfolgreichen und fehlerhaften Build

Für den Erfolgsfall muss ein Mock sicherstellen, dass ein entsprechender Buildbefehl mit Maven erfolgreich ist. Dazu stellt das Testframework die Klasse PipelineTest Helper bereit, die aufgrund der Vererbung über das Feld helper zugreifbar ist. Über diese Klasse können diverse Jenkins-Direktiven gemockt werden, so wie beispielsweise der Aufruf von Shellskripten mithilfe der Methode addShMock. Mit dem ersten Argument wird der Shellbefehl festgelegt, der gemockt werden soll. Im zweiten Argument kann bei Bedarf eine Konsolenausgabe konfiguriert werden. Das letzte Argument legt den gemockten Rückgabewert des Aufrufs fest. Für den Unit-Test des Erfolgsfalls wird dementsprechend der Rückgabewert auf null gesetzt, während für den Fehlerfall ein beliebiger anderer Rückgabewert genügt. Für den Fehlerfall ist darüber hinaus noch eine JUnit-Rule erforderlich, welche die erwartete Exception fängt, die Jenkins unweigerlich wirft, wenn ein Aufruf eines Shellbefehls mit einem anderen Rückgabewert als dem Erfolgswert null beantwortet wird.

Der Negativtest wird nun erneut erwartungsgemäß scheitern, was jedoch wiederum ganz im Sinne des testgetriebenen Entwicklungszyklus ist: Bei einer neuen oder angepassten Anforderung ändert sich zunächst der zugehörige Test und erst dann der Produktivcode (Listing 6).

Listing 6: Pipeline mit Build-Stage

Mit der Anpassung des Produktivcodes laufen die Unit-Tests nun erfolgreich durch. Im dritten Schritt, dem Refactoring, wäre es nun denkbar, das Ergebnis der Pipeline explizit zu machen, indem wie im ersten Beispiel über assertJob StatusSuccess() beziehungsweise über assertJobStatusFailure() das Ergebnis geprüft wird (Listing 7). Die Pipeline setzt das Ergebnis dann explizit auf „Failure“, wenn der Shellbefehl mit dem Aufruf von Maven nicht erfolgreich ist (Listing 8).

Listing 7: Build-Status anstatt Exception im Fehlerfal

Listing 8: Die vollständig testgetrieben entwickelte Pipeline

Mit diesen Tests und der zugehörigen Pipeline ist der Grundstein für eine brauchbare CI/CD-Pipeline gelegt. Ausgehend vom Buildschritt lässt sich diese Pipeline beliebig mit den von Jenkins bereitgestellten Direktiven erweitern, wobei jeder Funktionsaufruf über die Methode registerAllowed Method der Klasse PipelineTest Helper nach Bedarf gemockt werden kann. Das vollständige Codebeispiel mit den wesentlichen Methoden der Bibliothek steht bei Github zum Abruf bereit [Prad]. Es handelt sich um ein fehlerfrei kompilierbares Muster, das als Grundlage für eigene Pipelines dienen kann.

Fazit

Die Übertragung des testgetriebenen Ansatzes auf CI/CD-Pipelines bringt somit mehrere Vorteile: Zunächst gewinnt der durchaus kritische Pipelinecode dieselbe Qualität und Robustheit wie auch der Anwendungscode. Zudem werden die Pipeline-Schritte flexibler und erweiterbar, das Risiko von Refactorings und Anpassungen wird deutlich reduziert. Und schließlich entfällt das mühselige und langsame „Trial and Error“-Vorgehen, das bei Pipelines leider oft Anwendung fand.

Die Vernachlässigung von Infrastruktur- oder Pipelinecode ist gefährlich. Denn bei gelebtem CI/CD und DevOps kann der Qualitätsanspruch an ebendiese konsequenterweise nur mindestens so hoch sein wie der Anspruch an den Anwendungscode selbst.

Obgleich Jenkins bisweilen argwöhnisch als altbacken und unnötig ausführlich abgetan wird, können Softwareengineers an dieser Stelle wieder einmal das langjährige und infolgedessen reichhaltige Ökosystem von Jenkins ausschöpfen. Es dürfte nur eine Frage der Zeit sein, bis die Konkurrenten wie Gitlab-CI oder Bamboo-CI nachziehen. Bis dahin ist eine über Unit-Tests getriebene CI/ CD-Pipeline in Jenkins zweifelsfrei eine gute Option.

Referenzen

[Beck00] K. Beck, XP Explained, Addison-Wesley Professional, 1999

[JPU] Jenkins-Pipeline-Unit, https://github.com/jenkinsci/JenkinsPipelineUnit

[Prad] Vollständiges Codebeispiel, https://github.com/lpradel/test-driven-jenkins-pipeline

. . .

Author Image

Lukas Pradel

Author
Zu Inhalten
arbeitet als Softwareengineer bei der DB Vertrieb GmbH in Frankfurt. Die Zeit, die er mit der Automatisierung von Tests und Deploymentprozessen einspart, nutzt er am liebsten zum Motorradfahren.

Artikel teilen

Nächster Artikel
Welt des DDD-schen Denkens