Eine Version dieses Blogs wurde ursprünglich von Test MagazineTest Magazine im März 2019 veröffentlicht. Wir teilen ihn noch einmal für alle, die ihn beim ersten Mal verpasst haben!
Developers like flame wars. Last Friday, when the afternoon was slowing in the office, an inadvertent GIF on Slack sparked some friendly debate about the right way to test. Because winning flame wars is important, here is my “well, actually”…
Irgendwann in den 2000er-Jahren, als PHP nicht mehr nur als Templating-Sprache galt und Ruby gerade erst mit Rails durchstartete, entschied die Programmierer-Community, dass dynamisch typisierte Sprachen ein großartiger Weg seien, um die kognitive Last für Entwickler zu reduzieren, gnadenlos pragmatisch zu bleiben und die fabrikähnliche Kultur von Java zu vermeiden.
Das Facebook-Mantra „Move fast and break things“ hat sich seither in der gesamten Startup-Szene verbreitet und wird als die Art angesehen, Geschäfte, Produkte, Häuser oder eigentlich alles aufzubauen, was in sich zusammenfallen kann. Und wenn Sie jemals an Software gearbeitet haben, wissen Sie: Was passieren kann, wird auch passieren.
Irgendwann kam Clean Code dazu, und der Aufstieg von TDD versammelte eine Anhängerschaft rund um Writing Tests First, den Glauben an The Coverage und andere Mantras, die sich toll aufsagen lassen, aber deutlich weniger Spaß machen, wenn man sie tatsächlich umsetzt.
Viele Selbsthilfebücher wie Clean Code basieren vage auf persönlichen Erfahrungen und bringen bestimmte Programmiermuster in Bereiche, in denen sie traditionell nicht verwendet wurden: zum Beispiel die Vereinfachung der C++-artigen Objektorientierung durch funktionale Programmierkonzepte wie kleine, einfache, reine Funktionen.
In Wirklichkeit gibt es viel Forschung zur Softwarekrise und dazu, wie man aus dem Schlamassel, in dem wir stecken, herauskommt, und diese widerspricht oft der Weisheit der Masse. Schauen wir uns verschiedene Strategien an, die die Softwarequalität vorantreiben, und wo sie tatsächlich einen Unterschied machen.
Von unten nach oben betrachte ich die Softwareverifizierung generell auf folgenden Ebenen: Typsystem, Unit-Tests, Integrationstests und organisatorische Managementstruktur.
Moment, was? Organisatorische Managementstruktur? Nun, dann fangen wir vielleicht damit an.
Management
Organisationsverhalten ist eine eigenständige Sozialwissenschaft und untersucht die feine Kunst der Personalführungsstrukturen. Da Software normalerweise von Menschen erstellt wird, haben diese Bedürfnisse, interne und externe Motivatoren und müssen gelegentlich zusammenarbeiten, um ein Ergebnis zu liefern.
“quantifiable results from the study show that team and collaboration structure can be a better predictor of quality than tooling, testing strategies, or other code-based metrics.”
Google führte eine Studie zu seinen Teams als arbeitende Einheiten durch, um herauszufinden, was sie effektiver machte, während sich Microsofts Forschung darauf konzentrierte, wie die Organisationsstruktur die Ausfallraten von Software bestimmte. Beide sind auf ihre eigene Weise interessante Ansätze, und die Microsoft-Studie gibt uns einen interessanten Einblick in die Entwicklung von Windows Vista. Die Studie zeigt uns, dass kleinere, fokussiertere Teams zuverlässigere Software produzieren. Eine hohe Fluktuation von Entwicklern senkt die Softwarequalität, während eine engere Zusammenarbeit zwischen Teams, die am selben Projekt arbeiten, zu niedrigeren Ausfallraten führt.
Das mögen Aussagen sein, die von Captain Obvious (oder Melvin Conway) stammen könnten, doch die quantifizierbaren Ergebnisse der Studie zeigen, dass Team- und Zusammenarbeitsstruktur ein besserer Prädiktor für Qualität sein kann als Tooling, Teststrategien oder andere codebasierte Metriken.
Wie ein einzelnes Team die Qualität seiner Ergebnisse kontrolliert, ist der nächste Schritt von hier aus. Insbesondere Code-Reviews sind eine großartige Möglichkeit, gemeinsame Standards zu schaffen und aufrechtzuerhalten. Schriftliche Code-Reviews zwingen Entwickler dazu, ihre Bedenken klar zu kommunizieren, und diese verstärkte technische Kommunikation hilft allen im Team, unterschiedliche Stile und Perspektiven kennenzulernen, während sie gleichzeitig dazu beiträgt, die Fähigkeiten im Team anzugleichen. Vielleicht sollte man aber den Stil von Linus Torvalds vermeiden.
Integrationstests
Integrationstests testen, wenig überraschend, die Integration von Komponenten oder Modulen in einem System. Man kann auch die Integration integrierter Module testen, und es gibt Turtles all the way down.
Es ist oft einfacher, isoliert korrekten Code zu schreiben, sodass eine große Anzahl von Bugs an Systemgrenzen auftritt. Validierung von Eingaben und Formatierung von Ausgaben, Versäumnis, Berechtigungsstufen zu prüfen, oder schlechte Implementierung von Schnittstellenschemata. Dieses Problem wird durch den aktuellen Trend zu Microservices verstärkt, bei dem Schnittstellenversionen zwischen verschiedenen Diensten innerhalb des Systems auseinanderlaufen können.
Auf dieser Ebene ist es am besten, Pass-Through-End-to-End-Tests für Features zu schreiben und zu nutzen, dass wir so viele andere Schutzschichten gegen Fehler haben, dass irgendwann eine davon anschlägt. Tatsächlich zeigt sich, dass die Code-Coverage bei Integrationstests kein verlässlicher Indikator für Ausfallraten ist.
Wenn man es anders betrachtet, ist Produktion einfach ein großer Integrationstest. Ein Trick, den ich gerne anwende, ist die Erstellung einer stummen Produktionsinstanz (Mute-Production-Instanz), die einen Teil des tatsächlichen Produktionsverkehrs erhält, aber niemals Antworten an die Nutzer generiert. Mit ausreichender Investition in eine zustandslose Orchestrierungsschicht können wir sogar Teilbäume von Diensten an strategischen Stellen stumm testen, sie dann aktivieren und den alten Teilbaum verwerfen, sobald die Arbeitslast verschwunden ist.
Verbunden mit den Prinzipien des Aufbaus hochgradig beobachtbarer Systeme nimmt diese Art von Testumgebung viel von der Angst, die entsteht, wenn wir in die Produktion deployen, denn das Mute-Prod erhält genau die gleichen Daten. Je mehr Regler und Sonden wir in Live-Systemen bereitstellen, desto besser wird unsere Einblicksmöglichkeit in die internen Abläufe.
Unit-Testing
Um Module zu integrieren, brauchen wir also eine gewisse Zuversicht, dass die Module selbst gemäß Spezifikation funktionieren. Hier kommt das Unit-Testing ins Spiel.
Unit-Tests sind in der Regel schnell und mehr oder weniger umfassende Tests isolierter, leicht verständlicher Bausteine. Wie schnell? Das Master-Repository von Ruby on Rails führt etwa 67 Tests und 176 Assertions pro Sekunde aus.
Als Faustregel sollte ein Test ein Szenario abdecken, das einem Modul widerfahren kann. Im Vergleich zum Integrationstesting zeigt die gleiche Studie von Niedermayr, Juergens und Wagner, dass Code-Coverage auf Unit-Testing-Ebene die Ausfallraten tatsächlich beeinflusst, sofern sie gut umgesetzt ist.
Eine Studie von Hutchins et al. aus dem Jahr 1994 behauptet, dass Coverage-Level über 90 % bessere Fehlerentdeckungsraten zeigten als kleinere Testmengen, und dass „signifikante“ Verbesserungen auftraten, als die Coverage von 90 % auf 100 % stieg.
Die BDD-Bewegung hat diese lustige Praxis, Spezifikationen zu entwickeln und sie direkt in Unit-Tests umzuwandeln. Die Vorteile klarer, menschenlesbarer Unit-Tests helfen dabei, den Code zu dokumentieren und erleichtern einen Teil des Wissenstransfers, der unvermeidlich stattfinden muss, wenn Entwickler kommen und gehen oder sich die Anforderungen bestehender Komponenten ändern.
Unit-Testing umfasst in meinem Verständnis auch nach dem QuickCheck-Stil generierte Tests. Die Idee von QuickCheck ist, dass der Programmierer, anstatt imperativen If-this-then-that-Code durchzugehen, Annahmen aufstellen kann, die für die Ausgabe der Funktion bei bestimmten Eingaben zutreffen müssen. QuickCheck generiert dann Tests, die versuchen, diese Annahmen mithilfe der Implementierung zu widerlegen, und reduziert sie, falls es eine findet, auf eine minimale Eingabe, die sie widerlegt.
Interessanterweise wird die Anzahl der Szenarien, die ein Unit-Test normalerweise abdecken muss, stark von der Programmiersprache beeinflusst, in der er geschrieben ist. Das führt mich zwangsläufig zu der heiligen Flamewar-Diskussion über statische und dynamische Typisierung.
Typsysteme
Hindley-Milner
Statische Typsysteme im Hindley-Milner-Stil wie Haskell oder Rust zwingen den Programmierer, Verträge festzulegen, die überprüft werden, bevor das Programm ausgeführt werden kann.
Der Programmierer stellt dann fest, dass er plötzlich zwei Sprachen parallel programmiert: Das Typsystem liefert einen Beweis für das Programm, während es zugleich ein Programm gibt, das die Anforderungen erfüllt.
Dies ermöglicht eine Art des Denkens über Korrektheit, die es dem Programmierer – gepaart mit einem hilfreichen Compiler – erlaubt, sich auf Dinge zu konzentrieren, die vom Typsystem nicht bewiesen werden können: die Geschäftslogik.
Natürlich ist das kein reiner Gewinn. In vielen Fällen ist eine Lösung mit einem dynamischen Typsystem viel unkomplizierter und eleganter, oder in anderen Fällen machen die Einschränkungen des Typsystems bestimmte Implementierungen praktisch unmöglich.
In anderen Fällen ermöglichte die Verwendung von Haskell das Schreiben eines deutlich kleineren Programms, das im Vergleich zu den Alternativen erheblich schneller war – so sehr, dass die US Navy den Test aus Unglauben wiederholen musste.
Eleganz liegt im Auge des Betrachters, und eine wunderschön typisierte Abstraktion, die sich bei der Kompilierung auf eine einfache Zustandsmaschine reduziert, kann genauso attraktiv sein wie ein schnelles und schmutziges LISP-Makro. Manchmal ist all diese Komplexität schwer zu rechtfertigen, nur um den Compiler zufriedenzustellen. Es kommt durch Erfahrung, Geschmack und die Anwendung fundierter Urteilskraft in der richtigen Situation. Menschen betrachten Programmieren oft als Handwerkskunst. Ja, wir wissen, wie man die Mathematik richtig macht, aber mit diesem netten Trick kommen wir mit 10 % des Aufwands zu 90 % ans Ziel, und das mag gerade gut genug sein. Und es wird in genau dem Grenzfall explodieren, den ich für unmöglich hielt. Aber ich schweife ab.
Schwach, aber statisch
Etwas zugänglicher, aber mit weniger strenger Verifizierung sind Sprachen aus der C++/Java/C#-artigen OOP-Familie sowie C und Go.
Die Typsysteme hier ermöglichen eine andere Art von Flexibilität und mehr erwünschte Fluchtwege in die dynamische Welt.
Ein schwächeres, aber immer noch statisches Typsystem bietet weniger Garantien für die Korrektheit der Programme – etwas, das wir durch Tests und/oder Coding-Standards ausgleichen müssen. Das Jet Propulsion Lab der NASA, ein Massenhersteller von Mars-Rovern, pflegt eine Reihe von sicheren Programmierrichtlinien für C. Ihre Richtlinien scheinen wirksam zu sein. Opportunity übertraf seine ursprünglich geplante Einsatzzeit von 90 Tagen durch sorgfältige Wartung um 14 Jahre. Curiosity kreuzt noch immer über die Oberfläche des Mars und wird regelmäßig gepatcht.
Dynamisch
Wenn wir schon bei JPL sind: Die Internet-Folklore bewahrt die Geschichte über die Verwendung von LISP am NASA-Labor – eine dynamische, funktionale Programmiersprache aus den 60ern, die immer noch als eine der einflussreichsten Erfindungen oder gar Entdeckungen der Informatik gilt. Der heute am häufigsten verwendete LISP-Dialekt ist Clojure, der in Data-Science-Kreisen zunehmend an Popularität gewinnt.
Dynamische Sprachen bieten größte Freiheit und wenig Sicherheit. In den meisten Fällen ist die einzige Möglichkeit festzustellen, ob ein Codestück in irgendeiner Weise sinnvoll ist, es auszuführen, was bedeutet, dass unsere Teststrategie prinzipientreuer und tatsächlich gründlicher sein muss, da es keine nächste Schicht gibt, auf die man zurückfallen könnte.
Die wahrscheinlich am weitesten verbreitete Nutzung eines dynamischen Typsystems heute kommt von JavaScript. Interessanterweise haben Google, Microsoft und Facebook, während Unternehmen bestrebt sind, die Einstiegshürde für Mitwirkende zu senken und dabei die Entwicklung zu skalieren und die Qualität zu erhalten, jeweils eigene Lösungen entwickelt, um irgendeine Form von statischer Typprüfung in die Sprache einzuführen.
Obwohl Googles Dart keine nennenswerte Verbreitung gefunden hat, war das bei TypeScript von Microsoft anders, und seine Nutzung wird durch große und beliebte Projekte wie VSCode angetrieben. Beide Ansätze führen eine Sprache mit statischer Typprüfung ein, die zu JavaScript compiliert, was es leicht macht, sie auch schrittweise in bestehende Projekte einzuführen.
Im Gegensatz dazu ist Facebooks Flow ein rein auf JavaScript aufgebauter statischer Analysator, der eine eigene Art von Typannotationen einführt. Die Idee ist, dass der Typprüfer, wenn Typannotationen an strategischen Stellen vorhanden sind, in der Lage sein sollte, durch Verfolgen des Datenflusses herauszufinden, ob es in einem Teil des Programms Typfehler gibt.
Begeisterte Programmierer werden Ihnen sagen, dass beide Ansätze zur statischen Typisierung in JavaScript auf ihre eigene Weise fehlerhaft sind, und sie hätten recht. Am Ende laufen viele der Argumente auf subjektive Vorstellungen und Geschmäcker in Bezug auf Softwarearchitektur hinaus. Es scheint jedoch schwer zu bestreiten, dass eine Form der statischen Typprüfung mehrere Vorteile für die Skalierung und Wartung von Softwareprojekten bietet.
Die kleinen Dinge, die durchrutschen
Die Liste der Dinge, die wir tun können, um die Korrektheit von Software sicherzustellen, ist noch lange nicht vollständig. Der Stand der Technik entwickelt sich stetig weiter, und neue Ansätze gewinnen schnell an Popularität, besonders innerhalb der Security-Community.
In absolut kritischen Modulen, etwa bei allem, was mit Kryptographie oder Sicherheit zu tun hat, kann formale Verifikation das Vertrauen in Teile des Systems erhöhen, ist aber schwer zu skalieren.
Ein ähnliches Gefühl steckt hinter den Prinzipien von LangSec. In vielen Fällen erlauben die Mächtigkeit und Ausdrucksstärke unserer Sprachen, dass sich unbeabsichtigte Bugs einschleichen. LangSec sagt: Mache alle ungültigen Zustände durch die Sprache selbst unrepräsentierbar. Beschränke, was der Programmierer tun kann, damit er das vermeiden kann, was er nicht tun sollte. Das ist auch die Motivation hinter Coding-Standards wie dem von JPL, die es erleichtern, über Zustand und Datenfluss im gesamten Programmcode nachzudenken.
Wenn wir uns hinreichend sicher sind, dass das, was wir haben, gut genug ist, können wir anfangen, es zu fuzzen. Fuzzing ist großartig. Es geht darum, unerwartete Zustände in ein System einzuspeisen und darauf zu warten, dass sie Fehler verursachen. Diese einfache Idee hilft, Sicherheitslücken in beliebter Software zu entdecken, oder kann helfen, Chaos in der Cloud zu erzeugen.
Wie immer erfordert die Herstellung eines stabilen und sicheren Systems prinzipientreue Technik – in der Software genauso wie in der Architektur. Wir müssen die Teile verstehen, aus denen sich das Ganze zusammensetzt, sie analysieren und dann ihre Interaktionen intern sowie mit der Umgebung verifizieren. Trotz unserer besten Bemühungen werden sich immer Bugs einschleichen, und alles, was wir tun können, ist sicherzustellen, dass die verbleibenden nicht katastrophal sind.
Sobald Software jedoch live ist, hört die Verifizierung nicht auf. Das Design für Beobachtbarkeit durch das Bereitstellen von Reglern, Tracing, Alerting und das Sammeln einer Reihe von Betriebskennzahlen hilft uns allen, über den Zustand des Systems während seines Betriebs nachzudenken – was letztlich der ultimative Test von allem ist.
Softwareentwicklung ist ein Prozess, und es ist praktisch unmöglich, Perfektion zu erreichen. Solange das Team einen Plan hat, sich ihr anzunähern, und jeder engagiert ist, können wir sagen, dass es gut genug ist, und dann das Büro verlassen, um die Sonne zu genießen.
Wenn Sie Lust haben, noch ein bisschen über Richtig und Falsch beim Softwaretesten zu diskutieren, schreiben Sie mir eine Zeile – @rhapsodhy oder per E-Mail – ich würde mich freuen, Ihre Gedanken zu hören!
Hier bei Red Sift, wenn wir nicht gerade darüber diskutieren, wie man richtig testet, ermöglichen wir es sicherheitsorientierten Organisationen, erfolgreich mit ihren Mitarbeitern, Anbietern und Kunden zu kommunizieren und deren Vertrauen zu sichern. Besuchen Sie unsere Homepage, um mehr darüber zu erfahren, was wir tun.






