Christoph Haag

Christoph Haag

Christoph ist Softwareentwickler mit einer Passion für Musik

30.03.2023 | 11 min Lesezeit

Softwaretests - Ein kritischer Blick

expected [Test === Test] to be true but found false

Softwaretests - Ein kritischer Blick blog image

In der Softwareentwicklung geht es viel um Vertrauen.

Ein einfacher Netzwerkrequest durchläuft unglaublich viele Stationen. Von der Softwareebene des Browsers und dem Betriebssystem, hin zur physikalischen Ebene der Netzwerkkarte und den Elektronen im Netzwerkkabel. Jede Ebene ist wichtig und jede Ebene hat ein Versprechen: "Ich kümmere mich um XYZ, damit du es nicht tun musst." Wir nennen das Abstraktion.

Warum dreht sich also alles um Vertrauen? Weil wir den Versprechen dieser Abstraktionen glauben schenken (müssen). Wir können nicht alles selbst entwickeln, weil wir weder die Zeit noch das Geld und auch nicht das Know-how dazu haben.

Mit der Zeit vergessen wir, dass es sich um Abstraktionen handelt. Sie werden zu Gewissheiten. Der Computer fährt hoch, wenn der Knopf gedrückt wird. Die Nachricht im Messenger der Wahl wird auch dann zugestellt, wenn das Gegenüber gerade nicht am Handy ist. Die Bestellung im Onlineshop kommt bereits morgen an, wenn die Bestellung in den nächsten fünf Minuten raus geht.

Alles Versprechen, vieles davon Gewissheiten, weil die Erfahrung gemacht wurde, dass die Versprechen korrekt sind.

...

Meistens.

Das funktioniert nur solange gut, bis das Versprechen einmal gebrochen wird. Und das ist der Knackpunkt. Softwareentwickler müssen verstehen und wissen, dass Abstraktionen ihr Verpsrechen manchmal nicht einhalten. Dass Dinge schief gehen. Irgendwo im Abstraktionsdschungel.

abstractions do not really simplify our lives as much as they were meant to

Dazu kommt, dass selbstgeschriebener Code auf bestehenden Abstraktionen aufbaut und selbst auch neue einführt.

Die Lösung für dieses Problem: Software Tests - Ganz nach dem Motto: "Vertrauen ist gut, Kontrolle ist besser."

Tests validieren, dass Versprechen auch gehalten werden. Dies gilt insbesonders auch dann, wenn sich die Abstraktion (z.B. durch Paket-Updates), nicht aber der eigene Code geändert hat.

Das Dilemma

Das Ziel und der einzige Grund für Tests ist es Vertrauen zu schaffen. Dieses Ziel hat einen Preis: Zeit und damit Geld. Und beides ist ein knappes Gut. Deadlines müssen gehalten werden und das Budget darf nicht gesprengt werden. Mit unendlich viel Zeit und unendlich viel Geld liese sich alles testen. Da dies aber nicht der Fall ist, gilt es klug zu überlegen, worin die Zeit investiert wird.

Es drängt sich die Frage auf: Wie werden Tests effizient geschrieben? Oder anders formuliert: Wie wird möglichst viel Vertrauen in möglichst wenig Zeit generiert?

Klassifizierung

Dazu zuerst eine Klassifizierung von (funktionalen) Tests, wobei eine genaue Definition und Abtrennung insb. zwischen Unit- und Integrationstests nicht existiert:

  • Statische Tests: Typisierte Sprachen erlauben es, Fehler schon vor der Laufzeit des Tests/ der Anwendung zu erkennen. Zum Beispiel TypeScript statt JavaScript, C# (ohne Reflection), uvm..
    → Vertrauen in eine überhaupt lauffähige Software.

  • Unit-Tests: Kleine und minimale funktionale Einheiten. Isoliert und unabhängig.
    → Vertrauen, dass grundlegende, kleine Code-Abschnitte so funktionieren wie erwartet.

  • Integrations-Tests: Ich persönlich bevorzuge eine sehr strikte Definition. Werden mehrere Einheiten im Verbund getestet, oder Abhängigkeiten gemockt, handelt es sich um einen Integrationstest.
    → Vertrauen, dass mehrere kleine Einheiten korrekt im Zusammenspiel verwendet werden.

  • End-to-End Tests: Auch hier bevorzuge ich eine strikte Definition und Abgrenzung: Ein End-To-End Test überprüft Use-Cases aus Sicht eines Benutzers. Ohne Wenn und Aber. Netzwerkabfragen, Authentifizierung, mehrere Endgeräte, verschiedene Auflösungen, usw. Es handelt sich also um einen Test-Bot, der Von-Hand-Tests automatisiert ausführt. Im Gegensatz zu Unit- und Integrationstests liegt der Fokus ausschließlich auf dem Verhalten/ Endergebnis. Der zugrundeliegende Code spielt keine Rolle.
    → Maximal mögliches Vertrauen, da alle Abstraktionen mit einbezogen werden.

  • Von-Hand-Tests: Ein realer User, der die reale Applikation testet.
    → Maximal mögliches Vertrauen. Schließt auch weitere Punkte wie Usability, etc. mit ein.

Mocking

Mocking (verallgemeinert) bedeutet, Vertrauen in die Software gegen Zeitersparnis bei der Test-Ausführung einzutauschen. Dies geschieht indem Abstraktionen und Abhängigkeiten ausgeblendet (gemockt) werden (z.B. Netzwerk-Requests, Fremd-Pakete, ...).

Mocking hat sich umgangsprachlich eingebürgert; ein besserer Überbegriff wäre vermutlich Test-Double. Dazu zählen Fake (leichtgewichtige Ersatz-Implementierung), Stub (vordefinierter Rückgabewert), Spy (Aufrufe tracken) und Mock (Stub + Spy).

Zeitaufwand

Tests kosten unterschiedlich viel Zeit, wobei dies mehr als nur die Implementierung des Tests betrifft. Hier ein paar Punkte, die alle berücksichtigt werden müssen:

  • Setup: Der Test muss überhaupt einmal laufen. Das bedeutet, das Framework aufzusetzen und den Prozess zu etablieren, sodass die Tests bei den Entwicklern lokal als auch in der Build- und Release-Pipeline laufen.

  • Implementierung: Die eigentliche Implementierung des Tests.

  • Korrektheit: Ist die Aussage des Tests überhaupt korrekt? Schlägt er nur dann fehl, wenn auch ein Fehlverhalten vorliegt?

  • Wartung: Anforderungen können sich über die Zeit ändern. Tests müssen ggf. angepasst werden.

  • Updates: Pakete müssen aktuell gehalten werden, um Sicherheitspatches einzuspielen, neue Features nutzen zu können und von Performanceoptimierungen zu profitieren.

  • Ausführung: Wie lange benötigt ein Testlauf bzw. die ganze Sammlung an Tests? Können Tests parallel ausgeführt werden?

  • Fehlerbehebung: Wie lange wird benötigt, um einen gefundenen Fehler zu beheben?

  • Wissen: Team-Mitglieder müssen Tests verstehen können und in der Lage sein, selbst Tests zu schreiben. Wie einfach ist das Testing-Framework, wie gut die Dokumentation und der (Community)-Support? Wie steil ist die Lernkurve für neue Team-Mitglieder?

Annäherung an eine Kosten-Nutzen Rechnung

Ich möchte hier den Versuch wagen, die verschiedenen Arten von Tests in ihrem Kosten-Nutzen Verhältnis aus meinen persönlichen Erfahrungen und Recherchen zu beurteilen.

Statische Tests

Der Aufwand ist in nahezu allen Punkten gering, wenn von Anfang an auf typisierte Sprachen gesetzt wird. Der Code muss sowieso geschrieben werden und wenn die Typisierung dazu führt, Fehler vor der Ausführung zu entdecken, ist der evtl. Mehraufwand gedeckt. Negativ ins Gewicht fällt die etwas höhere Einstiegshürde (z.B. JavaScript vs TypeScript). Allerdings ist die Aussage als Test, also die Validierung der Korrektheit, sehr begrenzt. Positiv ist die deutlich verbesserte Developer Experience was sich sowohl in Zeiterersparnis als auch Entwickler Zufriedenheit niederschlägt.

Unit-Tests

Wenn richtig implementiert, sind Unit-Tests sehr schnell in allen Bereichen. Die Ausführungszeit sollte im Millisekundenbereich liegen. Sie haben eine sehr kleine und abgegrenzte Aufgabenstellung und sind damit sehr einfach zu verstehen, zu schreiben und auch zu erweitern. Wenn die Funktionalität einmal geändert wird, ist der Schmerz gering, sie zu löschen. Randbedingungen können sehr einfach getestet werden und sie stellen auch eine Form der Dokumentation dar. Sie sind perfekt für testgetriebene Entwicklung (TTD) geeignet, da die Eingabeparameter und das erwartete Ergebnis im Voraus bekannt sind. Allerdings ist die Aussagekraft im Gesamtkontext der Applikation, also in Bezug auf einen konkreten Use-Case, eher gering.

Integrations-Tests

Die Ausführungszeit ist erheblich länger als die von Unit-Tests. Randbedingungen sind schwierig zu testen, oder erfordern einiges an Schreibarbeit. Durch Mocking wird sowohl Ausführungszeit als auch das generiertes Vertrauen gleichermaßen gekürzt. Der größte Schwachpunkt ist vermutlich die Wartung: Geänderte Anforderungen führen fast immer zu Anpassungen. Durch die Abhängigkeiten führen u.U. deren Änderungen ebenfalls zu Anpassungen. Integrationstests haben die größte Chance, fehlzuschlagen, obowhl aus Use-Case-Perspektive alles in Ordnung ist.

End-to-End Tests

Diese Tests verkörpern Vertrauen. Schlägt einer dieser Tests fehl, liegt (fast) ohne Zweifel ein Fehlverhalten vor, dem nachgegangen werden muss. Sind alle Tests erfolgreich, dann kann bei entsprechender Testabdeckung davon ausgegangen werden, dass alles (das wichtigste) passt. Die Ausführungszeit ist die größte Schwäche dieser Tests. Verschiedene Endgeräte, Benutzerrollen, überhaupt die Bereitstellung einer testfähigen realen Applikation ist in der Regel sehr aufwendig. Dafür sollte eine Änderung am Test nur dann nötig sein, wenn sich an der Anforderung etwas geändert hat.

Zusammenfassung

Test Typen im Vergleich: generiertes Vertrauen vs Zeitaufwand
Meine persönliche Einordnung - generiertes Vertrauen vs Zeitaufwand

Meine persönliche Einschätzung ist, dass statische-, Unit- und End-To-End Tests den Integrationstests überlegen sind. Warum? Weil sie einen klaren Fokus haben.

  • Statisch: Lauffähigkeit
  • Unit: Funktion in Isolation
  • End-to-End: User-Perspektive

Das heißt nicht, dass Integrationstests nicht notwendig oder sinnvoll sind. Aber, sie haben die größte Chance mit viel Zeit wenig Vertrauen zu generieren. Und das bringt mich zu meinem letzten Abschnitt...

Qualität über Quantität

People love debating what percentage of which type of tests to write, but it's a distraction. Nearly zero teams write expressive tests that establish clear boundaries, run quickly & reliably, and only fail for useful reasons. Focus on that instead. Justin Searls - twitter

Schlechte Tests können dazu führen, dass sehr viel Zeit aufgebracht wird, ohne dass echter Mehrwert entsteht. In der Regel führen schlechte Tests genau so wie gar keine Tests dazu, dass das gesamte Projekt erheblich verzögert wird, die Qualität leidet und der Frust im Team steigt.

Ein paar Grundregeln

Besseren Code schreiben

Ein wichtiger Punkt beim Testen ist der zu testende Code selbst. Es gibt eine Vielzahl von Ratgebern, wie besserer Code geschrieben wird z.B. hier, hier und hier.

better code tends to also be testable code [stackexchange.com]

Kurz und knapp ein paar Punkte, was guten Code ausmacht:

  • Einfachheit: lesbar, verständlich
  • keine Angst vor Refactoring
  • deterministisch: selber Input = selber Output
  • klare Grenzen, klare Verantwortlichkeiten: SOLID Leitsätze

Implementierung und Korrektheit

Ein Test darf nur dann erfolgreich sein, wenn die Bedingung, die er prüft korrekt ist und andersherum darf er nur dann fehlschlagen, wenn die Bedingung, die er prüft nicht zutrifft. Ist dem nicht so, geht das Vertrauen in den Test verloren und damit das, wofür der Test eigentlich sorgen sollte.

Im folgenden Beispiel sind beide Tests erfolgreich, obwohl der zweite keine besonderen Zeichen enthält.

const chars = /[§$%&\/()+*#-_.,;:]+/;
export const isStrong = (value: string): boolean => {
  if (value == null) return false;
  if (value.length < 8) return false;
  return chars.test(value);
}

Open browser consoleTests

Der Test ist erfolgreich, obwohl die Bedingung ("mindestens ein Sonderzeichen"), nicht korrekt ist. Die getestete Zeichenkette enthält nur sieben Zeichen und somit wird die falsche Bedingung geprüft.

Tipp - Testabdeckung als Tool verwenden

Dies ist ein perfektes Beispiel, wie ein Test-Abdeckungs-Tool helfen kann. Der Report nur für den zweiten Test sieht wie folgt aus:

Testabdeckung mit jest html reporter
Testabdeckung mit jest html reporter

So ist grafisch schnell ersichtlich, welche Zeilen Code tatsächlich ausgeführt wurden.

Eine andere Möglichkeit zu überprüfen, ob Tests korrekt implementiert sind ist, die Implementierung zu ändern und zu überprüfen, dass die entsprechenden Tests fehlschlagen.

Korrektheit, Ausführung

Der Test muss zuverlässig und möglichst schnell laufen. Wenn Tests immer wieder lokal oder in der Pipeline fehl schlagen, dann lohnt es sich herauszufinden warum. Auf lange Sicht rechnen sich zuverlässige Tests. Wenn der Fehler nicht zu finden ist, sollte der Mehrwert/ das Vertrauen das der Test schafft, gegen die verlorene Zeit abgewogen werden. Im Zweifel kann der Test auch gelöscht werden oder es wird eine andere Möglichkeit gefunden, das Szenario zu testen.

Wartung

Ein Test sollte ohne Änderung durchlaufen, wenn sich die Implementierung, jedoch nicht die Anforderung geändert hat. Wenn häufig nach kleinen Änderungen am Code größere Umbaumaßnahmen am Test notwendig sind, dann lohnt es sich, den Test noch einmal genauer anzuschauen, warum das so ist. Dies ist meines Erachtens ein häufiges Phänomen bei Integrations-Tests.

Fehlerbehebung

Die Fehlermeldung muss aussagekräftig sein. Klingt logisch. Ist aber oft nicht der Fall. Häufig sind generische Tests die Ursache, oder wenn eigene Test-Abstraktionen eingeführt werden. In dem Zug, in dem sichergestellt wird, dass ein Test in den richtigen Momenten fehlschlägt, kann auch sichergestellt werden, dass die Fehlermeldung verständlich ist. Wenn generische Tests, oder Test-Helfer geschrieben werden, muss validiert werden, dass die Ursache im Falle eines Fehlers zurückverfolgt werden kann.

Fazit

Tests schaffen Vertrauen in ein lauffähiges System. Sie sind die Basis, um den Stresslevel beim Drücken des 'Deploy to Production'-Buttons in Grenzen zu halten.

Sie sollten jedoch immer eine Abwägung zwischen Zeit und Nutzen sein. Wenn einige der Grundregeln früh im Projekt nicht beachtet werden, werden sie zum Zeitfresser (schlecht fürs Budget), sorgen für Frust (schlecht für die Entwickler) und haben damit ihre eigentliche Daseinsberechtigung verspielt.

Aus meiner Erfahrung machen sich insb. Unit-Tests, weil sie wenig Zeit kosten, und End-To-End Tests, weil sie sehr viel Vertrauen schaffen, schnell bezahlt. Für Integrationstests empfehle ich ein gesundes Misstrauen. So wenig wie möglich, so viel wie nötig und Qualität über Quantität.

In diesem Sinne: Test smart, not hard.