Pyramiden sind uns allen ein Begriff. Die bekanntesten sind vermutlich die Pyramiden, die ägyptische Pharaonen als Begräbnisstätten und Abbild der Hierarchie der Sozialstruktur errichten ließen. Pyramiden wurden jedoch unabhängig und völlig selbstständig von weit voneinander entfernt lebenden Volksstämmen errichtet. Heute kennen wir Pyramiden aus der ganzen Welt: Ägypten, Lateinamerika, China, Griechenland, Rom, … Ihnen gemein ist, dass es sich vorwiegend um Gebäude mit religiösem und/oder zeremoniellem Charakter handelt, beispielsweise als Teil eines Totenkults.
Doch was genau hat das nun mit Software und dem Testen von Software zu tun? Handelt es sich dabei etwa auch um ein Konstrukt mit zeremoniellem oder religiösem Charakter? Die Antwort auf diese Frage hängt wahrscheinlich sehr davon ab, wen man fragt … Handelt es sich gar um einen Totenkult? Hoffentlich nicht – unsere Software soll leben!
Und genau dafür leistet eine ganz andere Pyramide einen wichtigen Beitrag: die Test-Pyramide.
In der mythologischen Deutung von Stufenpyramiden handelte es sich um eine riesige Steintreppe zum Himmel, dem “Haus der Ewigkeit” für einen König, das ihn Unsterblichkeit erlangen ließ. Unsterblichkeit klingt gut, das wünschen wir uns für unsere Software doch vielleicht auch. Allerdings sollten wir das Thema vielleicht nicht so angehen wie Cheops, der böse Pharao, der alle Untertanen zwang, beim Pyramidenbau zu helfen.
Unsere Motivation, beim Bau der (Test-)Pyramide mitzuwirken, finden wir womöglich in dem Nutzen, den die Menschen in Lateinamerika aus ihren Pyramiden zogen: Bei den dortigen Pyramiden handelte es sich um den Unterbau belebter Hochebenen, auf dem Häuser, Paläste und Tempel errichtet wurden. Diese Bauwerke waren dadurch vor Überschwemmungen während oft heftiger Regenfälle geschützt. Dieser Schutz ist potenziell lebensnotwendig.
Gleiches gilt für Tests bei der Softwareentwicklung – unsere Test-Pyramiden. Zugeben, stellenweise gleichen sie – trotz ihrer Wichtigkeit – eher Grabhügeln, deren konsequente architektonisch-bautechnische Weiterentwicklung die ägyptischen Pyramiden waren. Gleichen die Tests in eurem Projekt also noch eher einem Hügel als einer Pyramide, seid unbesorgt – ihr seid nicht die ersten (und wohl auch nicht die letzten), denen es so geht. Und es gibt Hoffnung. Auch die vielen Pyramiden in Lateinamerika wurden manchmal sogar mehrfach überbaut.
Pyramiden-Stufe 1: Unit-Tests
Beginnen wir unsere Betrachtung der Software-Test-Pyramide also bei kleinen Hügeln, von denen wir ohne großen Aufwand sehr viele errichten können, um das weitere Bauwerk auf eine breite Basis zu stellen. Beginnen wir bei den Unit-Tests. Vielleicht sind sie als kleinste Elemente unserer Test-Pyramide sogar deren Bausteine. Unit-Tests prüfen ein einzelnes, individuelles Stück Code. Das kann zum Beispiel eine Komponente oder Funktion sein. Grundlegend gilt für dieses Stück Code, dass es sich um die kleinste testbare Einheit handelt, für die es nur wenige Eingabewerte und üblicherweise ein einzelnes Ergebnis gibt.
Alles, was nicht im Scope dieser einzelnen “Unit” ist, wird gemockt, gestubbt oder durch Fake-Objekte ersetzt. Ziel dabei ist die Isolation des zu testenden Codes, so dass klare und eindeutige Ein- und Ausgabewerte gewährleistet sind. Idealerweise werden solche Tests geschrieben, bevor der zu testende Code überhaupt existiert. Robert C. Martin (“Uncle Bob”) hat drei Gesetze für derart testgetriebene Entwicklung aufgestellt:
- Du darfst produktiven Code erst dann schreiben, wenn du einen fehlschlagenden Unit-Test dafür geschrieben hast.
- Der Unit-Test darf nicht mehr Code enthalten, als für korrektes Kompilieren und Fehlschlagen des Tests erforderlich ist.
- Schreib nur so viel produktiven Code wie notwendig ist, damit der bisher fehlschlagende Test erfolgreich durchlaufen wird.
Das geht schnell. Und schnell hat man auf diese Weise sehr viele Tests, die parallel zur Entwicklung entstehen und uns jederzeit Auskunft darüber geben können, ob eine Änderung etwas kaputt gemacht hat. Daher ist es wichtig, sich in den Unit-Tests auf einen einzelnen Aspekt, ein einzelnes Konzept zu konzentrieren oder sogar nur eine Assertion zu verwenden. Schlägt ein Unit-Test fehl, soll unmittelbar deutlich werden, was genau nicht funktioniert hat und kaputt gegangen ist. Auf diese Weise schnell und einfach entdeckte Fehler können in späteren Test-Phasen nur sehr viel schwieriger aufgedeckt werden.
So gehören diese Tests auch unbedingt zum Build-Prozess und sollten grundsätzlich oft und regelmäßig ausgeführt werden, um schnell und regelmäßig Feedback zu bekommen.
Logischerweise schreiben wir als Entwickler die Unit-Tests selbst, für uns und andere Entwickler. Müssten wir darauf warten, dass erst jemand (ein Tester) Unit-Tests für uns schreibt, wäre der Vorteil der Geschwindigkeit dahin. Insbesondere angesichts der Tatsache, dass unter Umständen für eine Zeile produktiven Code deutlich mehr Test-Code entsteht, wäre das ziemlich absurd. Das macht gleichzeitig deutlich, dass es ratsam ist, sich auf das Testen von Komponenten zu konzentrieren, was auch Auswirkungen auf das Verhalten der Software als Ganzes hat. So ist es möglicherweise wenig sinnvoll, jeden Getter und jeden Setter eines Objekts zu testen. Der Mehrwert von Tests ist sehr viel größer, wenn wir uns darauf konzentrieren bei Schleifen und Entscheidungen alle Pfade abzudecken. Dabei sollten wir realistische Daten als Eingabewerte verwenden, um spätere Überraschungen zu vermeiden.
Berücksichtigen wir all das, haben wir ganz nebenbei noch einen weiteren, nicht zu vernachlässigenden Vorteil: Unsere Tests dokumentieren das von uns erwartete Verhalten. Wir – und alle, die vielleicht später zum Projekt dazukommen und mit unserem Code und den dazugehörigen Tests konfrontiert werden – können damit besser nachvollziehen, was wie funktioniert (und warum).
Das alles bezieht sich jedoch nur auf die in den Unit-Tests einzeln für sich getesteten Komponenten. Jeden Bug und jeden Fehler werden wir nicht finden. Und wir werden wahrscheinlich auch keine Probleme identifizieren können, die erst bei der Integration, also beim Zusammenspiel mit anderen Komponenten entstehen. Dafür gibt es die nächste Stufe unserer Test-Pyramide.
Pyramiden-Stufe 2: Modul-Tests & Integrationstests
Wir begeben uns ein Stück weiter nach oben in unserer Pyramide. Die kleinsten testbaren Einheiten erachten wir als ausreichend getestet und widmen uns nun ihrem Zusammenspiel. Bis hierher haben wir uns mit den Unit-Tests, die idealerweise parallel zur Implementierung entstanden sind, insbesondere mit internen Strukturen und Funktionalitäten auseinandergesetzt. Damit waren wir mit den Unit-Tests im Bereich der White-Box-Tests unterwegs.
Widmen wir uns den Modulen (manchmal auch als Komponenten bezeichnet), kann das Wissen zu derartigen internen Aspekten hilfreich sein, ist bei der konkreten Ausgestaltung der Tests aber gleichzeitig eine Frage von Ermessen und Teststrategie. Wir begeben uns allmählich ins Gebiet der Black-Box-Tests. Mit diesen beiden Ansätzen beim Testen von Software haben wir uns vor einiger Zeit bereits eingehender beschäftigt:
Bei den Modul-Tests können wir auf der einen Seite mehrere kleine Einheiten als zusammengehörig betrachten und gemeinsam als Gruppe testen. Das kann beispielsweise eine komplexere Funktionalität in einem Service einer Anwendung sein. Andere Teile der Anwendung werden dabei durch Mocks oder Stubs ersetzt, die für alle gerade nicht relevanten Aspekte der Anwendung klar definiertes Verhalten festlegen, ohne dabei auch direkt von deren tatsächlicher Implementierung abzuhängen (und sofort von Änderungen betroffen zu sein). Für Spring-Boot-Anwendungen ist es zum Beispiel möglich, einzelne Layer der Anwendung separat zu testen. Dabei wird für die Ausführung der Tests ggf. nur ein Teil der Anwendung tatsächlich initialisiert und ausgeführt, was sich positiv auf die Laufzeit der Tests auswirkt. Das ist wichtig, weil wir nach wie vor schnell und oft Feedback bekommen wollen und die Modul-Tests entsprechend auch automatisiert als Teil des Build-Prozesses ausführen.
Andererseits ist es auch denkbar, die gesamte Anwendung als Modul (eines größeren Systems) zu betrachten und damit als Gesamtes, als Black Box, zu testen. In diesem Fall würden wir Mocks und Stubs nur für externe Abhängigkeiten verwenden, die nicht direkt Teil unserer Anwendung sind. Das kann zum Beispiel die Interaktion mit einem Authentication Server oder Identity Provider sein. Für die Testausführung kann die Anwendung lokal gestartet werden und mit Bibliotheken wie REST-assured “von außen” aufgerufen werden. Dadurch durchlaufen die Tests auch alle Sicherheitsmechanismen, die für die Anwendung vorgesehen sind, während andere Möglichkeiten der Modul-Tests erst später ansetzen und damit die ersten Ebenen des Request-Handlings überspringen oder umgehen.
Egal, ob man sich auf komplexere Funktionalität innerhalb einer Anwendung oder eine Anwendung als Gesamtes konzentriert, steht das Aufdecken von Fehlern im Fokus, die bei der Integration und Interaktion verschiedener kleiner Einheiten unserer Software entstehen. Wir wollen sicherstellen, dass alles richtig verbunden ist und zusammen funktioniert.
Natürlich können wir dabei nicht jedes denkbare und undenkbare Szenario abdecken. Wir gehen stattdessen davon aus, dass die Mehrzahl potenzieller Fehlerfälle bereits in den Unit-Tests Berücksichtigung fand. Unser Augenmerk richten wir daher nun auf den “Happy Path” sowie offensichtliche “Corner Cases”. Das könnten zum Beispiel in der Vergangenheit aufgetretene Bugs sein, die nun explizit mit in die Tests aufgenommen werden.
An den beiden Ansätzen für Modul-Tests (oder Integrationstests) wird deutlich, dass der Begriff der Integrationstests sehr breit ist, was sich auch in verschiedenen Definitionen des International Software Testing Qualifications Board (ISTQB) widerspiegelt:
- integration testing: Testing performed to expose defects in the interfaces and in the
interactions between integrated components or systems. See also component integration
testing, system integration testing.
- component integration testing: Testing performed to expose defects in the interfaces and
interaction between integrated components.
- system integration testing: Testing the integration of systems and packages; testing
interfaces to external organizations (e.g. Electronic Data Interchange, Internet).
Das sorgt oft für Verwirrung und so richtig klar können Modul-Tests, Komponenten-Tests, Integrationstests und System-Integrationstests nicht gegeneinander abgegrenzt werden. Der Übergang ist tatsächlich fließend. So unterscheidet Martin Fowler schlicht und einfach zwischen “Narrow Integration Tests” und “Broad Integration Tests”, die sich wie folgt unterscheiden:
Ohne es so richtig deutlich mitzubekommen, haben wir also schon die nächste Stufe der Test-Pyramide erklommen.
Pyramiden-Stufe 3: API-Smoke-Tests & End-to-End-Tests
Am Übergang von den Integrationstests finden wir auch die System-Integrationstests mit den eben beschriebenen Merkmalen. Bei diesen Tests unterscheiden wir zwischen API-Smoke-Tests und End-to-End-Tests. Beiden gemein ist, dass sie eine tatsächlich laufende und komplette Live-Version der Software als Black Box testen, nur auf verschiedene Weise und mit unterschiedlichem Fokus.
Enthält das System grafische Benutzungsoberflächen, werden diese auch in die Tests einbezogen. Hier sprechend wir dann von End-to-End-Tests. Dabei werden UI Testing Frameworks wie Protractor, Selenium oder Puppeteer eingesetzt, um Webbrowser zu automatisieren. Damit schlüpfen wir in die Rolle eines Nutzers und klicken uns an der Oberfläche durch die Anwendung, machen Eingaben, wechseln die Seiten und prüfen dabei, ob das, was uns da angezeigt wird, unseren Erwartungen entspricht. Das geschieht jedoch nicht auf Basis der eigentlich sichtbaren grafischen Oberfläche sondern anhand des Markups im DOM. Ein UI-End-to-End-Test erwartet zum Beispiel einen Button mit einer bestimmten Beschriftung und bestimmten Eigenschaften zu finden, um dann darauf zu “klicken” und im Folgenden das Ergebnis dieser Interaktion zu verifizieren. Auf welcher Seite lande ich? Sehe ich die Informationen und Daten, die ich erwarte?
Der Fokus der UI-End-to-End-Tests liegt also auf Eingaben, Ausgaben und (Nutzer-)Interaktionen über die UI-Komponenten unserer Software. Da diese Tests auf einer Live-Version der Software ausgeführt werden, sind natürlich auch die von den UI-Komponenten ausgelösten API-Requests mit im Spiel. Sie stehen jedoch an dieser Stelle nicht im Fokus.
Bei den Backend-System-Integrationstests geht es dagegen um die API, die (auch) von den UI-Komponenten genutzt wird und die wir hier noch einmal dediziert testen. Dafür können wir zum Beispiel Postman oder noch einmal REST-assured verwenden. Lassen wir die System-Integrationstests mit Fokus auf die APIs gegen in Entwicklungs- oder Test-Umgebungen laufen, können sie durchaus auch Daten verändernde Requests enthalten, um so die ganze Bandbreite möglicher API-Requests und -Interaktionen abzudecken.
Daten zu verändern ist allerdings ein No-go, wenn wir API-Smoke-Tests gegen produktive Systeme ausführen. Hier geht es nur darum, ein Deployment durch bloße Lesezugriffe zu verifizieren und sicherzustellen, dass unser Software-System in einer bestimmten Umgebung verfügbar ist und auf Anfragen in der erwarteten Weise antwortet. Hier werden dann in jedem Fall auch die echten Systeme externer Abhängigkeiten einbezogen.
End-to-End-Tests, Backend-System-Integrationstests und API-Smoke-Tests decken zusammen vielleicht 10 Prozent des gesamten Systems ab, denn spezifische Geschäftslogik ist bereits an anderer Stelle, auf den unteren Stufen der Test-Pyramide, getestet worden. Wir können davon ausgehen, dass die einzelnen Komponenten für sich erwartungsgemäß funktionieren bzw. wir Fehler und Probleme an anderer Stelle aufdecken. Unsere beiden Arten von System-Integrationstests dienen dazu sicherzustellen, dass die Anforderungen erfüllt werden, das Software-System richtig konfiguriert ist und erwartungsgemäß zusammenarbeitet.
Die Tests können und sollten automatisiert ausgeführt werden, jedoch nicht unbedingt bei jedem einzelnen Build. Schließlich sind die Tests durch ihren deutlich größeren Scope potenziell langsamer und aufwändiger. Sie zu oft auszuführen ist möglicherweise nicht immer effizient. Daher werden sie idealerweise über separate Build-Pipelines oder Jobs gesondert gesteuert. So kann die automatisierte Testausführung bei Bedarf manuell angestoßen werden. In jedem Fall ratsam ist es daneben, sie im Zuge eines Deployments automatisch zu triggern. Bei den Tests auf Stufe 3 unserer Test-Pyramide handelt es sich um die letzte Stufe, auf der wir die Tests selbst und automatisiert durchführen. Für die letzte Stufe, die Akzeptanz-Tests, übergeben wir an unsere Kunden.
Pyramiden-Stufe 4: Akzeptanz-Test
Software zu entwickeln ist kein Selbstzweck. Es gilt die Anforderungen des Kunden zu erfüllen, um für ihn einen Mehrwert zu generieren. Ob unsere Software dieses Ziel erreicht, können wir nicht allein testen. Dafür brauchen wir den Kunden und Nutzer der Software, der die Software akzeptieren muss. Akzeptanztests sind essenziell für die Kundenzufriedenheit. Der Kunde entscheidet, ob sich der steinige Weg auf unserer “Steintreppe zum Himmel” gelohnt hat – er ist eben auch hier König.
Entsprechend ist der Akzeptanztest in Softwareprojekten auch eine der letzten Phasen, bevor die Software in den produktiven Einsatz geht. Der Test beginnt dann, wenn alle bekannten Anforderungen umgesetzt und die schwerwiegendsten Fehler beseitigt wurden. Die Software wird dann unter realitätsgetreuen Bedingungen zum Einsatz gebracht und von Fachexperten geprüft.
Unsere Kunden bzw. die Endbenutzer überprüfen die Features aus Benutzersicht mit Fokus auf die geforderten Geschäftsfunktionen und das ordnungsgemäße Funktionieren des Systems, auch um die Stabilität zu überprüfen. Das zu testen ist für unsere Kunden manchmal sogar gesetzlich vorgeschrieben.
Wir gehen davon aus, dass die unter realitätsnahen Bedingungen getestete Software dann in der Realität ebenso funktioniert.
Es ist durchaus möglich, dass beim Akzeptanztest noch Fehler und Probleme auftreten, die wir in den anderen Teststufen zuvor nicht entdeckt haben. Dazu gehören dann jedoch keine kosmetischen Probleme, Abstürze oder einfache Rechtschreibfehler. Es geht vielmehr um Aspekte der Benutzerfreundlichkeit, insbesondere intuitive Benutzbarkeit und erwartungsgemäßes Verhalten. Ist große Einarbeitung erforderlich? Fehlen vielleicht tatsächlich noch Funktionen, die ein Benutzer eigentlich erwarten würde, die aber bisher nicht gefordert waren? Das können oft Kleinigkeiten sein, an die einfach nicht gedacht wurde, auch weil irgendwann alle am Projekt Beteiligten ziemlich tief in der Materie stecken.
Um die Ergebnisse der Akzeptanztests klar dokumentieren und gegebenenfalls wiederholen zu können, basieren sie auf Szenarien, die schon beim Sammeln der Anforderungen in Form von Use-Cases eine Rolle gespielt haben:
Nachdem die Kundenanforderungen analysiert wurden, werden die Testszenarien zusammengestellt, auf ihrer Grundlage wird ein Testplan festgelegt und Testfälle werden erstellt. Danach kann der Akzeptanztest durchgeführt und das Ergebnis festgehalten werden.
Sind die Kundenanforderungen erfüllt, hat unsere Software die Test-Pyramide erfolgreich durchlaufen und ist der Unsterblichkeit ein Stückchen näher gekommen. Und wenn nicht, sind wir alle um eine Erkenntnis reicher: Wir haben zu lösende Probleme und Herausforderungen identifiziert und können in die nächste Iteration gehen.
Fortsetzung folgt …