Effizientes Testen auf allen Stufen
Kleine Ressourcen, große Anforderungen
Intuitiv, innovativ, modern, benutzerfreundlich und barrierefrei soll sie sein, die Applikation – zumindest der Teil, den der User sehen kann. Alles, was "dahinter" liegt, soll möglichst schnell, effizient und fehlerfrei arbeiten. Von einer ausgelieferten Software wird viel erwartet. Natürlich sind dies valide Bedürfnisse, die ein starkes IT-Team seinem Auftraggeber erfüllen kann. Was sich aber in den letzten Jahren im Zuge der Digitalisierung verschärft hat, sind die Rahmenbedingungen, unter denen heute die meisten Entwickler-Teams arbeiten: sehr eng bemessene Zeit (aus Kostengründen oder externem Auslieferungsdruck), knappe Ressourcen (Stichwort Fachkräftemangel), wenig Verständnis seitens des Auftraggebers.
Das Entwicklungsteam muss jetzt also schlau und effizient handeln. Prozesse müssen optimiert werden, die Aufgaben sinnvoll verteilt. Natürlich helfen auch die richtigen Tools und eine breit gefächerte Expertise. Dennoch kommen viele Teams mit zunehmendem Zeitdruck zu der Frage: Können wir einen so zeitraubenden Task wie das Testen nicht einfach bleiben lassen?
Kein Test ist auch keine Lösung
Irren ist menschlich und so wird keine Software per se fehlerfrei entwickelt. Der tiefe Nutzen von Software-Tests liegt darin, einerseits Fehler frühzeitig zu entdecken und andererseits zu überprüfen, ob alle Anforderungen korrekt umgesetzt wurden. Dies verhindert wirtschaftlichen Schaden durch hohe Kosten und führt zu einem besseren Code, der leicht gewartet und erweitert werden kann. Es gibt zahlreiche Argumente für Software-Tests, auf die an dieser Stelle gar nicht eingegangen werden soll. Was wir uns merken können: Software-Tests sind die Grundlage einer qualitativ hochwertigen Software.
Doch die Bandbreite für eine sinnvolle Teststrategie ist groß. Wie also können wir unsere Effizienz steigern und gleichzeitig unseren Aufwand reduzieren?
Wer testet wann?
Widmen wir uns zunächst der Brandbreite bei den Faktoren Rollenverteilung und Zeitpunkt. Beides kann innerhalb eines Teams stark variieren. Das eine Extrem der Rollenverteilung favorisiert klar getrennte Funktionalitäten zwischen Entwicklerin und dezidiertem Test Automation Engineer. Der Vorteil liegt dabei in der Expertise: Die Entwicklung kann den Core-Code zur Perfektion treiben, ohne viel nach rechts und links schauen zu müssen, der Test wiederum prüft mit eigenem Verständnis und Test-Expertise das fertige Stück Software auf Herz und Nieren – codenah mit WhiteBox-Tests und/oder "von außen" mit BlackBox-Tests.
Am anderen Ende der Skala stehen Teams, in denen es – ganz nach agilem Ansatz – überhaupt keine Rollen mehr gibt. Jede macht alles, jede entwickelt und jede testet auf allen Stufen. Somit benötigt man hauptsächlich Allrounder, Generalisten, die sich schnell auf jede Aufgabe einstellen müssen und damit einen breiten Kenntnisstand entwickeln.
Auch hinsichtlich des Zeitpunktes, wann der Test ansetzt, gibt es eine breite Varianz. In früheren Zeiten wurden codenahe Unit- und Integrationstests zwar während der Entwicklung durchgeführt, alle Blackbox-Tests starteten aber erst, nachdem die Software komplett fertig gestellt wurde – waren also nachgelagert. Es erübrigt sich fast zu sagen, dass der Umbau falsch interpretierter Funktionalitäten im Anschluss hohe Kosten verursacht hat. Diese Vorgehensweise hat sich als nicht besonders effizient herausgestellt – auch wenn die Entwicklungszeit an sich vielleicht kürzer erschien.
Das andere zeitliche Extrem stellt den Test voran. Dies kann so weit gehen, dass alle Tests, Unit- (TDD-Ansatz) und auch Acceptance-Tests (BDD-Ansatz) unmittelbar vor dem Coden einer Komponente oder eines fachlichen Features aufgestellt werden. Das kann – und da kommen wir noch einmal auf die Rollenverteilung zurück – sowohl von allen Teammitgliedern als auch ausschließlich vom Test Automation Engineer durchgeführt werden. Der Vorteil dieses Ansatzes liegt darin, dass schon vor der ersten Zeile Code klar ist, was die Komponente (ausschließlich) leisten soll und/ oder der Kunde seine Zustimmung oder sein Veto zur fachlichen Umsetzung gegeben hat. Der Nachteil liegt darin, dass diese Arbeitsweise anfänglich sperrig erscheinen mag und einer sehr gut durchdachten Planung bedarf. Zwischen den angeführten Extrempunkten bezüglich Rollenverteilung und zeitlicher Anordnung liegen unzählige Abstufungen.
Rollenverteilung und Zeitpunkt – Best Practices
Wie ein Team testet, hängt grundlegend an Faktoren wie dem unterschiedlichen Erfahrungshorizont der Mitarbeiter, Größe, Selbstverständnis und Prinzipien des Teams und auch der Beschaffenheit des Projektes. So lässt sich kein allgemeingültiger Ansatz propagieren, der jedes Team gleichermaßen zum Erfolg führt. Dennoch kann jedes Team seine Effizienz steigern, indem es folgende Grundsätze berücksichtigt.
- Menschen arbeiten in den Bereichen schneller, in denen sie sich gut auskennen und für die sie sich interessieren. Dies kann zum Beispiel eine tiefe Test-Erfahrung sein, wie bei einer dezidierten Testerin, aber auch wie bei einer sehr testerfahrenen und -affinen Entwicklerin. Das Team sollte also genau hinschauen, bei wem die Test-Stärken und Interessen liegen.
- Das Vier-Augen-Prinzip steigert die Effizienz. Wird eine Funktionalität vom gleichen Menschen entwickelt und in Gänze getestet, verlieren wir den Mehrwert für die Funktionalität. Der Test hätte zum Beispiel keine Chance, falsch verstandene Anforderungen zu erkennen. Um redundantes und damit ineffizientes Handeln zu vermeiden, bedarf es also einer guten Absprache hinsichtlich der Rollenverteilung für die Aufgaben.
- Je früher Fehler und Missverständnisse erkannt werden, desto effizienter lassen sie sich beheben. Dabei ist es unerheblich, wer sie mit welchem Ansatz entdeckt. Das Team sollte den Ansatz nur so wählen, dass zumindest eine Person die Chance hat, zum frühestmöglichen Zeitpunkt Alarm zu schlagen.
Hands on – Best-Practice-Beispiel
Betrachten wir ein mögliches Szenario, bei dem die bisher genannten Grundsätze berücksichtigt wurden, einmal im Detail. Da es sich in der Praxis in vielen Projekten als besonders effizient herauskristallisiert hat, wollen wir es an dieser Stelle als "Best-Practice-Beispiel" vorstellen.
Grundsätzlich sehen wir, dass der Testaufwand für einen bestimmten Task auf unterschiedliche Personen aufgeteilt wurde. Dies sind oft eine Entwicklerin und eine dezidierte Testerin, die Testrolle kann aber ebenso gut von einer testerfahrenen Entwicklerin ausgefüllt werden. Die Person mit der Testaufgabe bringt auf jeden Fall Testerfahrung mit, um die Stärken der Teammitglieder voll auszuschöpfen. Die klare Rollenverteilung ermöglicht Tests unterschiedlicher Stufen, die von verschiedenen Personen durchgeführt werden. So ist das Vier-Augen-Prinzip garantiert.
Die Entwicklerin startet die Implementierung mit dem Test-driven Development. "Test first" erleichtert viele Design-Entscheidungen und kann unserer Meinung nach zu einem geeigneten Level an Kapselung führen.
Nach Fertigstellung greift auch hier wieder das Vier-Augen-Prinzip mit einer statischen Prüfung des Codes. Mindestens eine andere erfahrene Person führt – getriggert durch einen Merge Request – ein Review des Programmcodes sowie der Unit- und Integrationstests durch, sodass sogar ein Teil des Tests zusätzlich auf Qualität überprüft wird.
Parallel dazu beginnt die Entwicklung des Akzeptanztests. Wann genau der geeignete Startpunkt der Automatisierung ist, hängt von den Anforderungen der Aufgabe bzw. den Rahmenbedingungen der Umgebung ab. Auch die Akzeptanztests werden von einer weiteren erfahrenen Person reviewt und damit einer Qualitätsüberprüfung unterzogen.
Alle fertigen Tests durchlaufen eine (oder mehrere) Pipeline(s). Der genaue Aufbau ist hierbei nebensächlich, der Fokus liegt darauf, dass nur erfolgreich durchgelaufene Tests den Code in die erste Umgebung auf dem Weg zur Produktion spielen. Auch in den verschiedenen Umgebungen vor PROD – hier DEV und Test – werden die Tests alle regelmäßig als Regression ausgeführt, sodass möglichst viele Fehler vor der Produktion gefunden und behoben werden können.
Darf's ein bisschen mehr sein? Wie und wo testen wir?
Neben den eher strukturellen Faktoren gibt es auch eine große Bandbreite bei technischen Faktoren wie Testabdeckung und Teststufen.
Bei der Testabdeckung gibt es logischerweise zwei Extreme: ganz oder gar nicht. Fragwürdig ist, ob eine hundertprozentige Testabdeckung sinnvoll ist und mit "fehlerfrei" gleichzusetzen wäre. Das andere Extrem – also kein Test – ist unserer Meinung nach ein gefährlicher Ansatz, den sich heute keiner mehr leisten kann. Zwischen diesen beiden Extremen gibt es mindestens 99 mögliche Abstufungen.
Schauen wir auf die Verteilung der Teststufen, Unittest, Integrationstest und Akzeptanztest (auch e2e-Test oder GUI-Test genannt), so sehen wir einerseits die klassische Pyramide mit vielen Unit-, weniger Integrations- und einigen handverlesenen Akzeptanztests. Die umgedrehte Testpyramide wäre das andere Extrem, in der goldenen Mitte hat sich der sogenannte Test-Diamant (oder auch Test-Trophäe) angesiedelt, der den Schwerpunkt auf den Integrationstest legt.
Testabdeckung und Teststufen – Best Practices
20, 50, 80 Prozent – was ist also eine sinnvolle Zielvorgabe? Alle Teststufen und wenn ja, in welchem Verhältnis? Im Sinne der Effizienz können wir auch hier Grundsätze aufstellen:
- So viel wie nötig, so wenig wie möglich. Grundsätzlich gilt: je mehr getestet, desto verlässlicher die Software. Daher ist eine höhere Testabdeckung anzustreben, die nach unserer Erfahrung jenseits der 70-Prozent-Marke liegen sollte. 100 Prozent getestete Software – und zwar bzgl. Codezeilen und fachlicher Features – sind hingegen nicht effizient. Der Aufwand ist im Gegensatz zum Outcome kaum zu rechtfertigen, der Mehrwert marginal, die Zeit schlichtweg nicht vorhanden.
- Analyse-Tools sind eine wertvolle Hilfe. Glücklicherweise gibt es eine Reihe von Programmen, die sich mit der statischen Analyse der Code-Qualität beschäftigen – sogenannte Static Application Security Testing (SAST) Tools, wie zum Beispiel das Open-Source-Werkzeug SonarQube. Diese effiziente Methode deckt Fehler und Schwachstellen automatisch auf, wobei die Regeln zur Qualitätssicherung individuell eingestellt werden können.
- Teste nur Deinen eigenen Code. Die gängigen genutzten Frameworks sind selbst gut getestet. Ob ein Apache Commons Lang: StringUtils.deleteWhitespace(myString) wirklich alle Leerzeichen löscht, bedarf keiner weiteren Abklärung. Die Druckfunktion des Betriebssystems, die durch einen Print-Befehl aufgerufen wird, muss ebenso wenig gecheckt werden.
- Klärt die Zuständigkeit! Eine klare Teststrategie hilft, Redundanzen zwischen den Teststufen zu vermeiden. Ein Integrationstest muss nicht über die Oberfläche das Zusammenspiel mehrerer Komponenten abtesten, wenn dies der Akzeptanztest ebenfalls tut. Ein Code Review durch die jeweils andere Rolle kann hier beispielsweise helfen. Klare, verbindliche Regeln der Zuständigkeiten erhöhen die Effizienz an dieser Stelle jedoch am besten.
- Eine sinnvolle Priorisierung ist der Schlüssel zum Erfolg. Wrapper-Methoden ohne eigene Funktionalität müssen nicht getestet werden, fachlich unrealistische Edge Cases ebenso wenig. Effizient ist es aber auf jeden Fall, den Schwerpunkt der Tests auf Komponenten mit großer Komplexität und auf Features mit einer Kern-Funktionalität zu legen. Niedrigere Priorisierung bekommen Tests für nebensächliche, aber häufig vorkommende Aspekte.
- Gemeinsame Prinzipien sparen Zeit. In einem Testkonzept legt das Team fest, welche Tests konkret geschrieben werden sollen, wie mit Good und Edge Cases umgegangen wird, welche Namenskonventionen eingehalten werden. Das gemeinsame Verständnis aller Entwickler und Tester erleichtert das Arbeiten. Einmal aufgestellt, muss es nur noch von jeder umgesetzt werden, was die Effizienz des Einzelnen signifikant steigert.
Der Praxis-Check
Mittlerweile haben wir einige generelle Grundsätze in unser Testkonzept aufgenommen, um auf unterschiedlichen Ebenen effizienter zu werden. Worauf einigen wir uns aber im Hinblick auf die konkrete Umsetzung im Code? Und was davon ist nicht nur sinnvoll, sondern auch hilfreich hinsichtlich knapper Ressourcen und wenig Zeit? Um dies zu beantworten, werfen wir einen tiefen Blick in die einzelnen Teststufen.
Unittest
Unittests decken nur algorithmische Komplexität ab. Als Unittests sollte das Testkonzept nur solche Tests zulassen, die einen Algorithmus bzw. eine Berechnung abbilden. Klassen, die fester Bestandteil einer Operation sind, haben häufig einen zu geringen Kapselungsgrad für sinnvolle Tests auf dieser Stufe. Gute Unittests sorgen außerdem für eine Dokumentation komplexer Berechnungen.
Ein Negativbeispiel für einen nicht effizienten Unittest wäre das folgende. Getestet werden soll die Methode findbyId:
class CarService {
public Optional<Car> findById(long id) {
return carRepository.findById(id);
}
}
Diese ist eine Wrapper-Methode der findbyId-Methode des JpaRepositorys carRepository (Hintergrund ist eine gewünschte Kapselung: Der zugehörige Controller soll nur auf die CarService-Klasse zugreifen dürfen, nicht aber auf das dazugehörige Repository selbst).
Ein möglicher Unittest in Java dazu sähe wie folgt aus:
@ExtendWith(MockitoExtension.class)
class BadUnitTest {
@Mock
CarRepository carRepository;
@InjectMocks
CarService carService;
@Test
void testFindById() {
Car car = new Car();
car.setId(123L);
doReturn(Optional.of(car)).when(carRepository).findById(123L);
Car foundCar = carService.findById(123L).orElseThrow();
assertThat(foundCar).isEqualTo(car);
}
}
Hier arbeiten wir mit sogenannten Mocks. Diese funktionieren wie leere Platzhalter für die Klassen CarService und CarRepository. Wir können das Verhalten dieser Klasse nun für unsere Zwecke definieren, da die Methode findById ohne diese Klassen nicht sinnvoll funktionieren würde.
Es gibt gleich zwei Gründe, warum dieser Unittest vermieden werden sollte:
- Es sind eine Menge Zeilen nötig, um diese sehr simple Methode überhaupt testen zu können. Der Test selbst ist dann im Verhältnis sehr simpel und rechtfertigt den komplexen Aufbau nicht.
- Die Methode findById ist eine Wrapper-Methode für eine JpaRepository-Methode. Es gibt in ihr weder Logik noch Algorithmus. Sie sollte also problemlos funktionieren.
Sinnvoll hingegen ist ein Unittest für folgende Methode:
public static <T, K> Predicate<T> distinctByKey(Function<? super T, K> keyExtractor, Consumer<T> duplicates){
Set<K> seen = ConcurrentHashMap.newKeySet();
return value -> {
K key = keyExtractor.apply(value);
boolean added = seen.add(key);
if (!added){
duplicates.accept(value);
}
return added;
};
}
Diese kurze Methode hat es in sich. Auf den ersten Blick erkennen wir nicht, was eigentlich ihr Ziel ist. Schreiben wir aber die folgenden drei Unittests, erschließt sich uns die wahre Bedeutung.
@Test
void testDistinctByKey_firstChar() throws Exception {
assertThat(Stream.of("one", "two", "three", "four")
.filter(StreamUtil.distinctByKey(value -> value.charAt(0))))
.containsExactly("one", "two", "four");
}
@Test
void testDistinctByKey_length() throws Exception {
assertThat(Stream.of("one", "two", "three", "four")
.filter(StreamUtil.distinctByKey(String::length)))
.containsExactly("one", "three", "four");
}
@Test
void testDistinctByKey_modulo() throws Exception {
assertThat(IntStream.range(1, 100)
.boxed()
.filter(StreamUtil.distinctByKey(value -> value % 10)))
.containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
}
Die zu testende Methode erwartet als Parameter eine Funktion und kann auf einen Stream angewandt werden. Im ersten Test wird die Methode auf eine Abfolge von Strings aufgerufen, mit dem Unterscheidungsschlüssel "Anfangsbuchstabe". Alle Strings werden miteinander verglichen und nur solche mit unterschiedlichem Anfangsbuchstaben als Ergebnis wieder zurückgegeben. Das Ergebnis zeigt, dass distinctByKey offenbar verschiedene Objekte anhand der eingereichten Funktion unterscheidet.
Die anderen Tests funktionieren nach dem gleichen Prinzip, nur dass wir hier Objekte anhand der Länge des Strings oder des Ergebnisses bei einer modulo-Rechnung vergleichen. Was diese Tests gemeinsam haben, ist ihr einfacher Aufbau und ihre gute Lesbarkeit. Durch sie wird die sehr komplexe Methode zugänglicher und verständlicher. Zugleich ersparen wir uns mögliche Erklärungen als Kommentar im Code. Die Methode distinctByKey können wir dank der in ihr gekapselten Berechnung testen, ohne Abhängigkeiten umständlich mit Platzhaltern ersetzen zu müssen. Damit ist dies ein sinnvoller Einsatz von Unittests.
Integrationstest
Setzen wir also Unittests gezielter ein, reduziert sich logischerweise die Anzahl an getesteten Codezeilen. Für eine weiterhin hohe Testabdeckung legen wir einen größeren Fokus auf Integrationstests, die gleich mehrere Codezeilen und -blöcke auf einmal betrachten.
In Integrationstests wird die Interaktion mehrerer (teilweise externer) Komponenten getestet. Durch die gegebenen Abhängigkeiten sind diese Tests von Natur aus etwas langsamer als Unittests. Für eventuell auftretende Performance-Probleme gibt es mittlerweile zahlreiche Lösungen, auf die an dieser Stelle aber nicht eingegangen werden soll. Vielmehr widmen wir uns wieder unserem Ziel, Tests effizienter zu gestalten. Neben dem oben bereits erwähnten Testkonzept als sinnvolle Basis gibt es verschiedene Strategien für eine gesteigerte Effizienz auf dieser Teststufe.
- Reduzierter Aufwand für den Kontext! Integrationstests erfordern häufig einen Kontext in Form von z. B. verschiedenen Objekten in der Datenbank. Hier sollte man sich als Team eine Strategie überlegen, wie dieser Kontext möglichst sinnvoll und ressourcenschonend aufgebaut wird. Grundsätzlich gibt es verschiedene Varianten:
1. Objekte aufbauen und direkt in die Datenbank speichern
Schon beim Lesen der Möglichkeiten wird klar, dass der erste Punkt jedes Mal sehr viel manuelle Arbeit von der Entwicklerin erfordert. Man hat keinen Anfangsaufwand, dafür ist aber jeder einzelne Test recht aufwändig zu schreiben.
2. Hilfsmethoden für das Setup bauen
Mithilfe der zweiten Methode kann man den permanenten Aufwand verringern. Man baut Hilfskonstrukte auf, die die Arbeit mit der Datenbank erleichtern. Dies kann zum Beispiel eine Hilfsmethode für das Abspeichern von Objekten in einer Datenbank sein, hier DatabaseInitializers genannt.databaseInit.company(COMPANY_ID).persist();
Diese einzelne Codezeile baut ein Unternehmensobjekt auf, wodurch die Initialisierung des Kontexts vereinfacht und gut lesbar wird. Entwickler können sehr simpel schreiben, was sie als Voraussetzung in der Datenbank benötigen. Die dadurch entstehende Flexibilität ist durchaus ein Vorteil. Ein großer Nachteil ist der zwar einmalige, aber relativ große Aufwand zur Erstellung sowie später kontinuierlicher Wartung und Pflege, wenn sich Objekte verändern oder weitere hinzukommen.
Ein weiterer Nachteil beider Ansätze besteht darin, dass man schnell Gefahr läuft, ein Szenario in der Datenbank zu bauen, was man durch die klassischen Operationen gar nicht abbilden kann. Dadurch testet man unter Umständen in einem fehlerhaften Kontext oder einen nicht relevanten bzw. sogar unrealistischen Fall.
3. Die eigenen Operationen nutzen
Genau hier setzt die dritte Methode an. Der Kontext wird ausschließlich mithilfe der sowieso implementierten Operationen des Systems erstellt. Der Vorteil ist, dass man wenig Anfangsaufwand hat und keine Wartung durchführen muss. Außerdem sorgt man dafür, dass der Ausgangszustand fehlerlos ist und nur mögliche Ausgangszustände darstellt, die auch durch den User später erzeugt werden können. Unrealistische Startzustände fallen also automatisch weg. Neben den vielen Vorteilen wollen wir mögliche Nachteile nicht verschweigen. Gegebenenfalls muss man gewisse Einbußen in der Flexibilität in Kauf nehmen und ist eventuell in manchen Situationen auch auf eine Manipulation einzelner Datenbankobjekte angewiesen. Eine Kommunikation mit Drittsystemen kann zudem den Kontext künstlich verkomplizieren.
Effizient für den Kontextaufbau ist eine Methode, die am besten zum Projekt passt. Welche Methode nun gewählt wird, hängt stark von der Projektumgebung ab. Ein Microservice mit kleingeschnittenen Operationen wäre gut beraten, nur die eigenen Operationen zu verwenden, während ein System mit vielen Drittsystemen eher Hilfsmethoden implementieren könnte. Haben wir die effizienteste Methode gefunden, sollte sie ihren Platz im Testkonzept erhalten. - Automatisierte Unterstützung für Assertions. Nicht nur der Kontextaufbau bietet die Chance für Verbesserungen, sondern auch der letzte Teil des Tests, in dem Erwartungen verifiziert werden. Je komplexer das zu prüfende Objekt, desto zeitaufwändiger ist es, die richtigen Assertions zu schreiben. Gerade Überprüfungen von umfangreichen Objekten können viele Ressourcen verbrauchen, da unzählige Assertions geschrieben werden müssen. Eine zeitsparende Lösung können hier automatisierte Checks dieser Objekte sein. Dies bietet zum Beispiel das Konzept des Open-Source-Projekts "Validation File Assertions" für Java. Ob und in welcher Art und Weise es genutzt wird, sollte natürlich wieder im Testkonzept festgehalten werden.
Exkurs: "Validation File Assertions"
Das "Validation File Assertions"-Open-Source-Projekt konvertiert Assertions aller Art in Textform, um diese dateibasiert schneller und übersichtlicher überprüfen zu können. Da mehrere Assertions in einem Schritt ausgeführt werden können, minimiert sich der Aufwand bei der Erstellung von Tests signifikant. Darüber hinaus führt die Validation File Assertion nicht nur den Check der Assertion an sich aus, sondern zeigt diese direkt auch im richtigen Kontext. Im Detail wird automatisch eine Textdatei des zu vergleichenden Objekts erstellt, die – einmal von der Entwicklerin freigegeben – als Referenzdatei dient. Bei jedem weiteren Testdurchlauf wird wiederum eine aktuelle Datei erstellt, die mit der Referenzdatei verglichen wird. Das funktioniert nach dem Schema in Abb. 3. Dadurch können auf schnelle Art und Weise sehr umfangreiche Objekte überprüft werden. Code und weitere Erläuterung sind hier zu finden: [1].
Akzeptanztest
Realistische Anwendungsfälle der Software aus Sicht der User sind der Kern eines jeden Akzeptanztests. So ist es nicht verwunderlich, dass diese Teststufe andere Kriterien bei der Erstellung und Verbesserung ihrer Testfälle zu Grunde legt als ausschließlich technische. Effizienz ist hier eben nicht nur im Testcode zu erreichen. Vielmehr spielen semantische Kriterien, auch Business-Kriterien genannt, eine ebenso signifikante Rolle.
Soft Facts – Best Practices
- Klare Absprachen von Anfang an! Eindeutige Akzeptanzkriterien bzw. eine mit dem Kunden abgesprochene Spezifikation verhindern das Schreiben redundanter Testfälle. Logischerweise erspart uns die Konzentration auf wirklich relevante Testfälle eine Menge obsoleter Arbeit. Absprachen zu Beginn des Projekts, bevor die erste Zeile Code geschrieben wird, sind daher ein wünschenswerter Start in das Projekt. Verbindlich sollen sie sein, aber nicht starr um jeden Preis. Im gesamten Prozess bedarf es stetiger Reflexion der Kriterien und im Zweifelsfall auch Anpassung selbiger. Entscheidend dabei ist immer, dass die Einigung am besten schriftlich festgehalten wird, für alle zugänglich ist und die Einhaltung von mindestens einer Person kontrolliert wird.
- Manche Testfälle sind wichtiger als andere. Zahlreiche Use Cases, Zustandsübergänge und Eingabemöglichkeiten und die unendliche Menge ihrer Kombinationen lassen die Zahl möglicher Testfälle schnell auf eine unüberschaubare Größe steigen. Im Sinne der Effizienz ist daher eine Priorisierung der Testfälle angeraten. Konkret können wir uns dazu die folgenden Fragen stellen:
- Häufig genutzte Use Cases: Sie erhöhen die Wahrscheinlichkeit, dass ein im Code versteckter Fehler vom User entdeckt wird. Welche sind also die vielgenutzten Key-Funktionen der Software aus Sicht des Nutzers?
- Risiko-Bereiche: Welche Teile der Software können im Fehlerfall zu einem relevanten Schaden in wirtschaftlicher oder sicherheitstechnischer Hinsicht führen?
- Anforderungen des Kunden: Welcher Use Case ist für den Auftraggeber der relevanteste?
- Komplexität: Komplexe Funktionalitäten einer Software zeigen sich gemeinhin auch anfälliger für Fehler als solche, die einem simplen Prinzip folgen. In welchem Anwendungsfall steckt also eine komplizierte Logik?
Hard Facts – Best Practices
Haben wir alle sinnvollen Testfälle identifiziert, widmen wir uns nun der technischen Umsetzung dieser Szenarien. Ein bekannter Grundsatz besagt, dass Tests so isoliert wie möglich sein sollten. Starten wir also unsere Tests in einer sauberen Umgebung mit einem sauberen SUT ("System under Test"), können wir schon einmal mögliche Wechselwirkungen ausschließen. Isoliert sind auch die Szenarien an sich, um diese in beliebiger Reihenfolge ausführen zu können, Stichwort Parallelisierung. Schnell regt sich hierbei der Vorwurf, dass Isolierung die Performance erheblich negativ beeinflussen kann – zu Recht, denn jeder neu aufgebaute Test braucht seine Zeit. Wie aber testen wir möglichst effizient in diesem Spannungsfeld? Wie beim Integrationstest können hier zwei Ansätze, einer für den Kontext und einer für die abschließenden Assertions, helfen.
- Test-Schnittstellen sparen Aufwand. Im automatisierten Akzeptanztest erfolgen die Aktionen über die GUI, um die Handlungen des Users möglichst realistisch nachzuahmen. Im Normalfall sieht das Setting für den Akzeptanztest wie in Abb. 4 aus.
- Der WebDriver des TestFrameworks greift auf die GUI des SUT-Frontends zu. Nun stehen wir vor einem Dilemma: Für jeden Test muss ein entsprechender Kontext aufgebaut werden, um das System in einen gewünschten Startzustand zu bringen. Vielleicht müssen zum Beispiel mehrere Datensätze angelegt werden. Geschieht dies über die GUI, z. B. über auszufüllende Formulare, braucht es nicht nur zahlreiche zu implementierende Schritte, sondern hängt zur Runtime auch von äußeren Faktoren ab – das kostet uns wertvolle Zeit, die wir im Zweifelsfall nicht haben. Wir behalten den Anspruch, alle für einen Test relevanten Aktionen, also solche, die zu einem erwarteten Zustand führen sollen, immer über die GUI auszuführen. Für den Aufbau des Kontextes können wir von dieser Maxime aber abweichen, da es unerheblich ist, wie der User (oder das Automation Framework) überhaupt zum Startzustand gelangt ist.
- An dieser Stelle hat es sich als sehr effizient erwiesen, eine Testschnittstelle am Backend des SUT zu implementieren. Über diese API kann das Test-Framework direkt aus dem Code heraus einen oder mehrere Wunsch-Requests an das Backend schicken, um beispielsweise die Datenbank mit den erforderlichen Datensätzen zu füllen. Dies sähe dann wie in Abb. 5 aus.
Ein gut strukturierter und gekapselter Testcode ermöglicht einen sinnvollen Aufbau verschiedener Requests und lässt uns neue Tests effizienter und schneller erstellen. Ein zusätzlicher Vorteil ist die schnellere Ausführung unserer Tests – ein nicht unerheblicher Aspekt auch für Deployments. Es macht eben einen signifikanten Unterschied, ob wir 20 Datensätze über eine GUI oder über eine Schnittstelle anlegen. Nicht jeder Check braucht einen eigenen Test. Die Validierung zweier unterschiedlicher Funktionalitäten sollte grundsätzlich nicht in einem Test vermischt sein – das können wir so festhalten. Nehmen wir aber als Beispiel das Login auf unserer Oberfläche der zu testenden Applikation. Das Akzeptanzkriterium könnte lauten: User A sieht nach dem Login die für ihn relevanten Informationen. Nach dem erfolgreichen Login erwarten wir also Folgendes: Zunächst wird der User auf die entsprechende Startseite weitergeleitet, danach als angemeldeter User angezeigt und die Startseite enthält die für ihn relevanten Informationen. Natürlich möchten wir alle drei Punkte separat abtesten, ohne jedoch jedes Mal einen neuen eigenen Test dafür aufzubauen. Wie können wir also die Assertions sinnvoll kombinieren?
Die Überprüfung, ob wir auf die entsprechende Seite weitergeleitet wurden, ist maßgeblich für den Erfolg der weiteren Prüfungen und damit separiert zu validieren. Die beiden anderen Zustände sind aber gleichberechtigt und nicht voneinander abhängig – eine gute Möglichkeit, an dieser Stelle die in vielen Frameworks gängigen SoftAssertions einzusetzen.
Beispiel mit AssertJ und Java:
SoftAssertions softly = new SoftAssertions();
softly.assertThat(“Max Mustermann”).isEqualTo(actualDisplayedUsername);
softly.assertThat(expectedPageValues).isEqualTo(actualPageValues);
softly.assertAll()
Auf diese Weise können mehrere Assertions in einem Test nacheinander ausgeführt werden, ohne dass dieser aufgrund einer nicht erfüllten Erwartung zwischendurch abbricht. Die nicht bestandenen Checks werden uns als "Failures" angezeigt.
Die Ausgabe würde im Fehlerfall der ersten SoftAssertion wie folgt aussehen:
Multiple Failures (1 failure)
-- failure 1 --
expected: "Max Mustermann"
but was: "Maya Musterfrau"
at VerifyUser.specificUserVisualization(VerifyUser.java:21)
Die zweite Assertion zum Inhalt der Seite ist also trotz des ersten Fehlschlags durchgeführt worden. Somit bekommen wir für beide Checks eine gültige Aussage, haben uns aber gleichzeitig einen Test gespart.
Fazit
Effizienz in der Software-Entwicklung ist gerade bei schwierigen Rahmenbedingungen wichtig – dies gilt nicht nur für die Programmierung an sich, sondern auch für die gesamten Testaktivitäten. Sowohl bei Teamstruktur und Rollenverteilung als auch bei der technischen Umsetzung auf allen Teststufen können wir unsere Arbeit geschickt optimieren. Wir haben eine ganze Reihe von Effizienz-Grundsätzen und Best Practices aufgezählt. Wie diese in der Realität eingesetzt werden, hängt immer vom jeweiligen Projekt ab. Auch müssen nicht alle Grundsätze gleichzeitig befolgt werden. Jeder einzelne kann die Effizienz aber schon sichtbar steigern.