Typen oder Tests: Warum nicht beides?

Avatar of Shawn Wang
Shawn Wang on

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Immer wieder entbrennt eine Debatte über den Wert von typisiertem JavaScript. „Schreibt einfach mehr Tests!“, schreien einige Gegner. „Ersetzt Unit-Tests durch Typen!“, rufen andere. Beide haben in gewisser Weise Recht und in gewisser Weise Unrecht. Twitter bietet wenig Raum für Nuancen. Aber im Rahmen dieses Artikels können wir versuchen, ein begründetes Argument dafür darzulegen, wie beides koexistieren kann und sollte.

Korrektheit: was wir alle wirklich wollen

Es ist am besten, am Ende zu beginnen. Was wir uns letztendlich von all dieser Meta-Entwicklung erhoffen, ist Korrektheit. Ich meine damit nicht die strikte theoretische Informatikdefinition, sondern eine allgemeinere Einhaltung des Programmverhaltens gemäß seiner Spezifikation: Wir haben eine Vorstellung davon, wie unser Programm in unserem Kopf funktionieren sollte, und der Prozess des *Programmierens* ordnet Bits und Bytes an, um diese Idee in die Realität umzusetzen. Da wir nicht immer präzise sind, was wir wollen, und da wir Vertrauen haben möchten, dass unser Programm bei einer Änderung nicht kaputtgeht, schreiben wir Typen und Tests zusätzlich zum reinen Code, den wir ohnehin schreiben müssen, um die Dinge überhaupt erst zum Laufen zu bringen.

Wenn wir also akzeptieren, dass Korrektheit das ist, was wir wollen, und Typen und Tests nur automatisierte Wege dorthin sind, wäre es großartig, ein visuelles Modell zu haben, wie Typen und Tests uns helfen, Korrektheit zu erreichen, und somit zu verstehen, wo sie sich überschneiden und wo sie sich ergänzen.

Ein visuelles Modell der Programmkorrektheit

Wenn wir den gesamten unendlichen Turing-vollständigen möglichen Raum dessen, was Programme jemals tun können – einschließlich Fehler – als eine riesige graue Fläche vorstellen, dann ist das, was wir wollen, dass unser Programm tut, unsere Spezifikation, eine sehr, sehr, sehr kleine Teilmenge dieses möglichen Raumes (die unten gezeigte grüne Raute, zur Verdeutlichung übertrieben groß dargestellt)

A large gray box with a green diamond in the bottom-right hand corner that is labeled correct.

Unsere Aufgabe bei der Programmierung ist es, unser Programm so nah wie möglich an die Spezifikation heranzuführen (wobei wir natürlich wissen, dass wir unvollkommen sind und unsere Spezifikation sich ständig ändert, z. B. aufgrund menschlicher Fehler, neuer Funktionen oder unterdefinierten Verhaltens; daher schaffen wir es nie ganz, eine exakte Überlappung zu erreichen)

The same gray box and green diamond shown earlier, but with a green border around the diamond that is slightly off center to indicate room for error.

Beachten Sie noch einmal, dass die Grenzen des Programmverhaltens für unsere Diskussion auch geplante und ungeplante Fehler einschließen. Unsere Bedeutung von „Korrektheit“ schließt geplante Fehler ein, aber nicht ungeplante Fehler.

Tests und Korrektheit

Wir schreiben Tests, um sicherzustellen, dass unser Programm unseren Erwartungen entspricht, haben aber eine Reihe von Wahlmöglichkeiten, was wir testen wollen.

A series of red, purple and orange dots have been added to the diagram to represent different possible tests.

Die idealen Tests sind die orangen Punkte im Diagramm – sie testen genau, dass unser Programm mit der Spezifikation übereinstimmt. In dieser Visualisierung unterscheiden wir nicht wirklich zwischen Testarten, aber Sie können sich Unit-Tests als wirklich *kleine* Punkte vorstellen, während Integrations-/End-to-End-Tests *große* Punkte sind. In jedem Fall sind es Punkte, denn kein einzelner Test beschreibt jeden Pfad durch ein Programm vollständig. (Tatsächlich können Sie 100% Code-Abdeckung haben und immer noch nicht jeden Pfad testen, aufgrund der kombinatorischen Explosion!)

Der blaue Punkt in diesem Diagramm ist ein schlechter Test. Sicher, er testet, dass unser Programm funktioniert, aber er ordnet es nicht wirklich der zugrunde liegenden Spezifikation zu (was wir letztendlich von unserem Programm wollen). Sobald wir unser Programm korrigieren, um es näher an die Spezifikation anzupassen, bricht dieser Test, was zu einem falsch positiven Ergebnis führt.

Der violette Punkt ist ein wertvoller Test, da er testet, wie wir glauben, dass unser Programm funktionieren sollte, und einen Bereich identifiziert, in dem unser Programm derzeit nicht funktioniert. Mit violetten Tests zu beginnen und die Programmimplementierung entsprechend anzupassen, ist auch als Test-Driven Development bekannt.

Der rote Test in diesem Diagramm ist ein *seltener* Test. Anstatt normaler (oranger) Tests, die „Happy Paths“ (einschließlich geplanter Fehlerzustände) testen, ist dies ein Test, der erwartet und verifiziert, dass „*un*glückliche Pfade“ fehlschlagen. Wenn dieser Test „besteht“, wo er „fehlschlagen“ sollte, ist das ein riesiges frühes Warnsignal, dass etwas schief gelaufen ist – aber es ist im Grunde unmöglich, genügend Tests zu schreiben, um die riesige Fläche möglicher unglücklicher Pfade abzudecken, die außerhalb des grünen Spezifikationsbereichs liegen. Leute finden selten einen Wert darin, zu testen, dass Dinge, die nicht funktionieren sollten, nicht funktionieren, also tun sie es nicht; es kann aber dennoch ein hilfreiches frühes Warnsignal sein, wenn etwas schief geht.

Typen und Korrektheit

Wo Tests einzelne Punkte im Möglichkeitsraum dessen darstellen, was unser Programm tun kann, stellen Typen Kategorien dar, die ganze Abschnitte vom gesamten möglichen Raum abschneiden. Wir können sie als Rechtecke visualisieren.

A purple box has been drawn around the green bordered diamond in the chart to represent the boundary for different types of tests for the program.

Wir wählen ein Rechteck, um die Raute, die das Programm darstellt, zu kontrastieren, da kein Typsystem allein unser Programmverhalten nur anhand von Typen vollständig beschreiben kann. (Um ein triviales Beispiel dafür zu wählen: Eine id, die immer eine positive Ganzzahl sein sollte, ist vom Typ number, aber der Typ number akzeptiert auch Brüche und negative Zahlen. Es gibt keine Möglichkeit, einen number-Typ auf einen bestimmten Bereich zu beschränken, abgesehen von einer sehr einfachen Vereinigung von Zahlenliteralen.)

Several more different colored borders are added to the diamond in the chart to represent different tests. Any tests outside of the purple box that was drawn earlier are considered invalid.

Typen dienen als Einschränkung dafür, wohin sich unser Programm bewegen kann, während Sie codieren. Wenn unser Programm beginnt, die angegebenen Grenzen der Typen Ihres Programms zu überschreiten, wird unser Type-Checker (wie TypeScript oder Flow) einfach nicht zulassen, dass wir unser Programm kompilieren. Das ist gut, denn in einer dynamischen Sprache wie JavaScript ist es sehr einfach, versehentlich ein abstürzendes Programm zu erstellen, das sicherlich nicht das war, was Sie beabsichtigt haben. Der einfachste Mehrwert ist die automatisierte Nullprüfung. Wenn foo keine Methode namens bar hat, führt das Aufrufen von foo.bar() zu der allzu bekannten Laufzeit-Ausnahme undefined is not a function. Wenn foo überhaupt typisiert wäre, hätte dies vom Type-Checker *während des Schreibens* abgefangen werden können, mit spezifischer Zuordnung zur problematischen Codezeile (mit Autovervollständigung als begünstigendem Vorteil). Dies ist etwas, das Tests einfach nicht leisten können.

Wir möchten vielleicht strenge Typen für unser Programm schreiben, als ob wir das kleinstmögliche Rechteck schreiben würden, das immer noch zu unserer Spezifikation passt. Dies hat jedoch eine Lernkurve, da die volle Nutzung von Typsystemen das Erlernen einer völlig neuen Syntax und Grammatik von Operatoren und generischer Typenlogik erfordert, die benötigt wird, um den vollen dynamischen Bereich von JavaScript zu modellieren. Handbücher und Spickzettel helfen, diese Lernkurve zu senken, und hier ist mehr Investition erforderlich.

Glücklicherweise muss diese Einarbeitungs-/Lernkurve uns nicht aufhalten. Da die Typüberprüfung bei Flow ein Opt-in-Prozess ist und bei TypeScript konfigurierbare Strictness-Stufen vorhanden sind (mit der Möglichkeit, problematische Codezeilen selektiv zu ignoren), haben wir die Wahl aus einem Spektrum von Typsicherheit. Wir können dies auch modellieren.

Larger green and red box borders have been drawn around the tests. With the purple box, these represent types of tests.

Größere Rechtecke, wie das große rote in der obigen Grafik, stellen eine sehr permissive Annahme eines Typsystems in unserer Codebasis dar – zum Beispiel, indem implicitAny erlaubt wird und wir uns vollständig auf die Typinferenz verlassen, um unser Programm lediglich vor den schlimmsten unserer Programmierfehler zu bewahren.

Mittlere Strictness (wie das mittelgroße grüne Rechteck) könnte eine treuere Typisierung darstellen, aber mit vielen Ausstiegsmöglichkeiten, wie der Verwendung von expliziten any-Instanzen im gesamten Code und manuellen Typzusicherungen. Dennoch ist die mögliche Oberfläche von gültigen Programmen, die nicht mit unserer Spezifikation übereinstimmen, selbst bei dieser leichten Tipparbeit massiv reduziert.

Maximale Strictness, wie das violette Rechteck, hält die Dinge so eng an unserer Spezifikation, dass es manchmal Teile Ihres Programms findet, die nicht passen (und dies sind oft ungeplante Fehler im Programmverhalten). Das Finden von Fehlern in einem bestehenden Programm wie diesem ist eine sehr häufige Geschichte von Teams, die reine JavaScript-Codebasen konvertieren. Die maximale Typsicherheit aus unserem Type-Checker zu erhalten, erfordert wahrscheinlich die Nutzung von generischen Typen und speziellen Operatoren, die entwickelt wurden, um den möglichen Typraum für jede Variable und Funktion zu verfeinern und einzugrenzen.

Beachten Sie, dass wir technisch gesehen unser Programm nicht zuerst schreiben müssen, bevor wir die Typen schreiben. Wir wollen schließlich, dass unsere Typen unsere Spezifikation eng modellieren, also können wir wirklich zuerst unsere Typen schreiben und dann die Implementierung nachträglich auffüllen. Theoretisch wäre dies Type-Driven Development; praktisch entwickeln nur wenige Leute auf diese Weise, da Typen die tatsächliche Programmcode tief durchdringen und miteinander verflechten.

Zusammenführen

Was wir letztendlich aufbauen, ist eine intuitive Visualisierung, wie Typen und Tests einander ergänzen, um die Korrektheit unseres Programms zu gewährleisten.

Back to the original diagram with a green diamond representing correctness, a green border that is slightly off center that represents parameters for correctness, an orange dot in each border of the green diamond border representing tests, and a purple box border around everything to represent the possible test types.

Unsere Tests behaupten, dass unser Programm in ausgewählten Schlüsselpfaden spezifisch wie beabsichtigt funktioniert (obwohl es bestimmte andere Variationen von Tests gibt, wie oben diskutiert, tun die meisten Tests dies). In der Sprache der von uns entwickelten Visualisierung „fixieren“ sie die dunkelgrüne Raute unseres Programms an die hellgrüne Raute unserer Spezifikation. Jede Abweichung unseres Programms bricht diese Tests, was sie aufheulen lässt. Das ist ausgezeichnet! Tests sind auch unendlich flexibel und konfigurierbar für die individuellsten Anwendungsfälle.

Unsere Typen behaupten, dass unser Programm uns nicht entkommt, indem sie mögliche Fehler Modi über eine Grenze hinaus verbieten, die wir hoffentlich so eng wie möglich um unsere Spezifikation ziehen. In der Sprache unserer Visualisierung „enthalten“ sie die mögliche Abweichung unseres Programms von unserer Spezifikation (da wir immer unvollkommen sind und jeder Fehler, den wir machen, zusätzliches Fehlerverhalten zu unserem Programm hinzufügt). Typen sind auch stumpfe, aber mächtige (dank Typinferenz und Editor-Tooling) Werkzeuge, die von einer starken Community profitieren, die Typen liefert, die man nicht von Grund auf neu schreiben muss.

Kurz gesagt

  • Tests eignen sich am besten, um sicherzustellen, dass Happy Paths funktionieren.
  • Typen eignen sich am besten, um zu verhindern, dass Unhappy Paths überhaupt existieren.

Nutzen Sie sie basierend auf ihren Stärken gemeinsam, um beste Ergebnisse zu erzielen!


Wenn Sie mehr darüber lesen möchten, wie Typen und Tests zusammenwirken, waren Gary Bernhardts exzellenter Vortrag über Boundaries und Kent C. Dodds' Testing Trophy bedeutende Einflüsse auf mein Denken für diesen Artikel.