The Art of Test

Das Testen der Software ist fast genauso wichtig, wie deren Implementierung. Bei komplexen Systemen müssen Entwickler und Kunden prüfen können, ob das erstellte Werk auch allen Anforderungen entspricht und korrekt arbeitet. Aus diesem Grund werden beide Tätigkeiten beim Test-Driven-Development auch miteinander verbunden. Schwierigkeiten bereitet es häufig, welche Art von Test wann und wie verwendet werden sollte.

Testarten

Die pure Anzahl der Buzzwords zum Thema macht es auch nicht einfacher. Es existieren Unit Tests, Integration Tests, manuelle Tests, automatisierte Tests, Regression Test, Smoke Tests, Pre Flight Checks, System Test, Black Box, White Box und Gray Box Tests, Last Tests, Stress Tests, explorative Tests, Acceptance Tests und vermutlich noch einige weitere.

Manuelle und automatisierte Test

Diese beiden unterscheiden sich konzeptionell kaum, hier liegt der Fokus auf Geschwindigkeit und Reproduzierbarkeit. Automatisierte Test werden in der Regel bevorzugt, weil sie sehr viel schneller und dadurch häufiger durchgeführt werden können und sie immer in identischer Weise ablaufen. Schlagen automatisierte Test fehl, dann hat sich die Software-Umgebung zwischenzeitlich geändert. Entweder durch Code oder Datenänderungen. Manuelle Tests sind langwieriger und mit dem Faktor Mensch ist die Reproduzierbarkeit nicht immer gewährleistet. Dafür sind sie häufig ohne großen Aufwand durchzuführen. Das Erstellen von automatisierten Test kann unverhältnismäßig sein, wenn die Kosten dafür sehr hoch sind oder die Tests nur sehr selten durchgeführt werden. Bei explorativen Tests prüft der Tester, in der Regel mit manuellen Test, bislang nicht systematisch geprüfte Aspekte eines Systems.

Black-Box, Gray-Box und White-Box Tests

Diese drei unterscheiden die Art des Wissens um das zu testende System. Bei Black-Box Tests ist das Innenleben des System dem Tester unbekannt und er testet die spezifizierte Funktionalität. Bei White-Box Tests ist das Innenleben bekannt und der Tester prüft die Möglichkeiten des Systems. Gray-Box Tests sind ein Mittelweg, der die Nachteile der anderen beiden Varianten minimiert. Werden nur die spezifizierten Funktionalitäten geprüft, bleiben unerwünschte Reaktionen bei Extremas vielleicht unentdeckt. Wird nur das aktuelle Innenleben des Systems getestet, bleiben fehlende Funktionalitäten unentdeckt.

Software Entwickler schreiben gewöhnlich Gray-Box Tests da sie das Innenleben des Systems und die Anforderungen der Kunden kennen. Dennoch werden Kundenanforderungen nicht beachtet oder Extremsituationen im Code nicht genau beleuchtet. Beim iterativen Arbeiten werden solche Lücken aber nach und nach gefüllt.

Unit Tests und Integration Tests

Entwickler erstellen üblicherweise Unit- und Integration Tests. Während sich Unit-Tests auf die Methoden- und Klassenebene beschränken, prüfen die Integration Tests das Zusammenspiel von Klassen und größeren Systemkomponenten. Beides sind White-Box Test, da die Entwickler mit dem Innenleben ihrer Software gut vertraut sind.

System Tests

Eine Nummer größer sind die System Tests, die Funktionalität auf der Ebene des Gesamtsystem testen. Diese Test werden als Black-Box Tests häufig von QS Mitarbeitern oder Kunden ausgeführt.

Acceptance Tests und Regression Tests

Werden Kundenanforderungen, bzw. Akzeptanzkriterien, geprüft spricht man von Acceptance Tests. Diese werden üblicherweise ausgeführt, bevor das System in Produktion genommen wird. Später werden dann vielleicht Fehler in der Anwendung entdeckt und behoben. Zusätzlich werden automatisierte Test geschrieben, um das erneute Auftreten des Fehlers zu verhindern. Solche Tests werden Regression Tests genannt.

Pre-Flight Tests und Smoke Tests

Diese Tests dienen der Prüfung des produktiven System. Pre-Flight Tests in Anlehnung an die Checkliste der Piloten vor dem Start. Wichtige Funktionalitäten des Anwendungen werden durch automatisierte System Tests überprüft. Ähnlich sind Smoke Tests, die in Anlehnung an die Rauchgasprüfung von Rohren, Probleme in der Anwendung schnell anzeigen.

Last Tests und Stress Tests

Am Ende noch Last Test und Stress Tests. Bei den Last Tests wird das Produktivsystem oder ein vergleichbares Testsystem mit der geplanten Last gefahren. Dabei sollte das System weiterhin flüssig arbeiten. Bei Stress Tests wird die Last noch weiter erhöht. Dabei sollte das System nicht extrem verlangsamen oder vollständig ausfallen, sondern auf die zusätzliche Last intelligent reagieren.

Soweit zu den Spielarten von Software Tests. Für Entwickler stellt sich die Frage, welche Test er schreiben soll und was es dabei zu beachten gilt.

Die einfache Antwort ist, jeder Test ist zu schreiben. Die funktionalen Unit, Integration und System Tests und auch die nicht funktionalen Last und Stress Tests.

Nicht-funktionale Tests können keine funktionalen Tests ersetzten und umgekehrt können auch funktionale Tests keine nicht-funktionale Tests belegen. Unit Tests sind auch nicht ausreichend um das Zusammenspiel von Systemkomponenten zu testen. Andererseits reicht ein Systemtest aus, um die Funktionsfähigkeit aller beteiligten Codezeilen zu belegen. Dennoch haben Integration Tests und Unit Tests einen erheblichen ökonomischen Wert. Sie benötigen sehr viel weniger Ressourcen und Zeit um die Korrektheit eines Teilsystem zu beweisen. Auch bei der Veränderung von Software Systemen zeigen sie schnell an, ob Teile der Anwendung noch planmäßig funktionieren. Da Unit Tests noch viel preiswerter sind als Integration Tests, sollte der Großteil der Funktionalität über Unit Tests abgesichert werden und nur die Interaktion zwischen Teilsystemen über Integration Tests abgesichert werden.

Testtheorie

Grundsätzlich handelt es sich bei jedem Test um den Versuch eine Hypothese zu verifizieren. Bevor ein Test geschrieben wird, muss also klar sein, was überprüft werden soll. Dann muss der Versuchsaufbau konstruiert und der initiale Zustand für den Versuch erstellt werden. Danach wird der Versuch durchgeführt und geprüft, ob das Resultat zur Hypothese passt.

Hypothese

Jeder Test versucht eine Hypothese zu verifizieren. Wie schon im Beitrag Tolle Tests formuliert, sollen Test einfach gehalten sein. Ein Test kann einfach gehalten werden, wenn eine einzelne Hypothese formuliert wurde. Oft versuchen Entwickler aber eine Vielzahl von Hypothesen gleichzeitig zu testen. Das hat mehrere Nachteile. Die Tests werden kompliziert und der Test endet bei der ersten nichtzutreffenden Hypothese. Für die Fehlersuche ist es aber hilfreicher von allen Hypothesen zu wissen, ob sie zutreffen oder nicht. Fünf einfache Tests statt ein komplizierter Test ist auch nicht teurer in der Erstellung aber sehr viel günstiger während der Weiterentwicklung und Wartung.

Häufig werden Hypothese geprüft, die völlig unnötig sind. Es sind Axiome, die für die Anwendung grundsätzlich gelten. Beispielsweise muss ein Spring Boot Test nicht prüfen, ob ein Service initialisiert wurde. Ein Unit Test sollte die implementierte Funktionalität prüfen und ein Integration Test die Interaktion mit anderen Services. Die Initialisierung durch Spring Dependency Injection ist ein fundamentaler Mechanismus für die Anwendung, wie etwa das spezifizierte Verhalten der JVM. Solche Dinge müssen nicht getestet werden.

Versuchsaufbau

Der Versuchsaufbau besteht aus einer Initialisierung, dem Aufruf des Anwendungscode und der Validierung der Ergebnisse.

Wird die Ausgangssituationen für einen Test entworfen, dann sollte nur das absolut Nötigste bereitgestellt werden.

@SpringBootTest
class RechnungsTest {
  @Autowire KundenService ks;
  @Autowire RechnungService rs;

  @Test
  void negativeSumme() {
    assertNotNull(ks);
    assertNotNull(rs);
    Kunde k = ks.get(12345);
    Rechnung r = new Rechnung(-12, k);
    assertThrows(IllegalArgumentException.class, () -> rs.validate(r));
  }
}

Die hier dargestellte Testmethode zeigt, wie ein Unit Test nicht aussehen sollte. Der Test benötigt die Spring Boot Umgebung, um über den KundenService an eine spezielle Instanz vom Typ Kunde zu gelangen. Dann wird eine Rechnung Instanz erzeugt und damit die validate Methode von RechnungService geprüft.

Um keinen Umweg über nutzlose Mocks für dieses Beispiel zu nehmen, kommt sofort der Hinweis, dass innerhalb der validate Methods nur geprüft wird, ob die Summe größer als Null ist. Der Test benötigt keine Spring Boot Umgebung, keine Services und sogar keine Kunde Instanz.

class RechnungsTest {
  @Test
  void negativeSumme() {   
    assertThrows(IllegalArgumentException.class,
      () -> new RechnungService ().validate(new Rechnung(-12, null)));
  }
}

Die Einfachheit für Test hat immer einen ökonomischen Grund. Ein Spring Boot Test dauert viel länger als ein einfacher Unit Test. Die Nutzung der Kunde und KundService Klassen produziert eine Abhängigkeit zu ungenutzten Code und verursacht in der Regel unnötigen Änderungsaufwände, wenn sich diese Klassen ändern. Selbstverständlich kosten auch komplizierte Tests Zeit, wenn ein Entwickler versucht einen Test zu verstehen, der lange erfolgreich durchlaufen wurde und nach Monaten oder Jahren Testfehler produziert.

Mit Ausnahme der System Tests, ist jeder Test nur eine Annäherung an die Realität. Obwohl Entwickler gerne Mocks in ihren Test verwenden, sollte die Gefahr nicht unterschätzt werden, das Systemverhalten dadurch zu verändern. Im Beitrag Morden mit Mockito sind einige bekannte Anti-Pattern dazu aufgeführt.

Verifizierung

Am Ende eines Tests werden die Ergebnisse geprüft und damit die Hypothese bestätigt. Daher reicht es vollständig aus, nur die Dinge zu prüfen, die eine Verifizierung oder Falsifizieren der Hypothese unterstützen. Alles anderes ist Muda/Verschwendung. Manche Tests erzeugen den Verdacht, der Entwickler ist so unsicher über den Verlauf, dass er am liebsten alles testet. Bei solchen Tests werden dann auch gerne noch einmal alle Vorbedingungen und Invarianten geprüft. Häufig werden aber keinerlei Prüfungen durchgeführt, als ob das Durchlaufen des Codes schon ein Sieg wäre.

Am Ende noch eine Erkenntnis aus dem Grundstudium. Wir können nur beweisen, dass ein Programm einen Fehler enthält aber nicht, dass es fehlerfrei ist. Aus diesem Grund sind wir immer auf der Jagd nach dem nächsten Fehler.