Schreiben Sie wichtige Tests: Nehmen Sie zuerst den komplexesten Code in Angriff

Veröffentlicht: 2022-03-11

Es gibt viele Diskussionen, Artikel und Blogs rund um das Thema Codequalität. Die Leute sagen - verwenden Sie testgetriebene Techniken! Tests sind ein „Muss“, um ein Refactoring zu starten! Das ist alles cool, aber es ist 2016 und es gibt eine riesige Menge an Produkten und Codebasen, die noch in Produktion sind und vor zehn, fünfzehn oder sogar zwanzig Jahren erstellt wurden. Es ist kein Geheimnis, dass viele von ihnen Legacy-Code mit geringer Testabdeckung haben.

Obwohl ich gerne immer an der Spitze oder sogar am Rande der Technologiewelt sein möchte – mit neuen coolen Projekten und Technologien beschäftigt – ist das leider nicht immer möglich und ich muss mich oft mit alten Systemen auseinandersetzen. Ich sage gerne, dass Sie, wenn Sie von Grund auf neu entwickeln, als Schöpfer agieren und neue Materie meistern. Aber wenn Sie an Legacy-Code arbeiten, sind Sie eher wie ein Chirurg – Sie wissen, wie das System im Allgemeinen funktioniert, aber Sie wissen nie sicher, ob der Patient Ihre „Operation“ überleben wird. Und da es sich um Legacy-Code handelt, gibt es nicht viele aktuelle Tests, auf die Sie sich verlassen können. Dies bedeutet, dass sehr häufig einer der allerersten Schritte darin besteht, es mit Tests abzudecken. Genauer gesagt, nicht nur um Abdeckung bereitzustellen, sondern um eine Testabdeckungsstrategie zu entwickeln.

Kopplung und zyklomatische Komplexität: Metriken für eine intelligentere Testabdeckung

Vergessen Sie 100% Abdeckung. Testen Sie intelligenter, indem Sie Klassen identifizieren, die mit größerer Wahrscheinlichkeit kaputt gehen.
Twittern

Im Grunde musste ich bestimmen, welche Teile (Klassen / Pakete) des Systems wir überhaupt mit Tests abdecken mussten, wo wir Unit-Tests brauchten, wo Integrationstests hilfreicher wären usw. Es gibt zugegebenermaßen viele Möglichkeiten, dies zu tun Gehen Sie diese Art von Analyse an, und die, die ich verwendet habe, ist vielleicht nicht die beste, aber es ist eine Art automatischer Ansatz. Sobald mein Ansatz implementiert ist, dauert es nur minimal, die Analyse selbst durchzuführen, und, was noch wichtiger ist, es bringt etwas Spaß in die Analyse von Legacy-Code.

Die Hauptidee hier ist, zwei Metriken zu analysieren – Kopplung (dh afferente Kopplung oder CA) und Komplexität (dh zyklomatische Komplexität).

Der erste misst, wie viele Klassen unsere Klasse verwenden, sagt uns also im Grunde, wie nah eine bestimmte Klasse am Kern des Systems ist; Je mehr Klassen unsere Klasse verwenden, desto wichtiger ist es, sie mit Tests abzudecken.

Wenn andererseits eine Klasse sehr einfach ist (z. B. nur Konstanten enthält), ist es nicht annähernd so wichtig, einen Test dafür zu erstellen, selbst wenn sie von vielen anderen Teilen des Systems verwendet wird. Hier kann die zweite Metrik helfen. Wenn eine Klasse viel Logik enthält, ist die zyklomatische Komplexität hoch.

Dieselbe Logik kann auch umgekehrt angewendet werden; dh selbst wenn eine Klasse nicht von vielen Klassen verwendet wird und nur einen bestimmten Anwendungsfall darstellt, ist es dennoch sinnvoll, sie mit Tests zu überziehen, wenn ihre interne Logik komplex ist.

Es gibt jedoch einen Vorbehalt: Nehmen wir an, wir haben zwei Klassen – eine mit CA 100 und Komplexität 2 und die andere mit CA 60 und Komplexität 20. Auch wenn die Summe der Metriken für die erste höher ist, sollten wir sie definitiv abdecken der zweite zuerst. Dies liegt daran, dass die erste Klasse von vielen anderen Klassen verwendet wird, aber nicht sehr komplex ist. Andererseits wird die zweite Klasse auch von vielen anderen Klassen verwendet, ist aber relativ komplexer als die erste Klasse.

Zusammenfassend: Wir müssen Klassen mit hoher CA- und zyklomatischer Komplexität identifizieren. In mathematischer Hinsicht wird eine Fitnessfunktion benötigt, die als Bewertung verwendet werden kann - f (CA, Komplexität) - deren Werte mit CA und Komplexität steigen.

Im Allgemeinen sollten die Klassen mit den geringsten Unterschieden zwischen den beiden Metriken die höchste Priorität für die Testabdeckung erhalten.

Die Suche nach Tools zur Berechnung von CA und Komplexität für die gesamte Codebasis und zur Bereitstellung einer einfachen Möglichkeit, diese Informationen im CSV-Format zu extrahieren, erwies sich als Herausforderung. Bei meiner Suche bin ich auf zwei kostenlose Tools gestoßen, daher wäre es unfair, sie nicht zu erwähnen:

  • Kopplungsmetriken: www.spinellis.gr/sw/ckjm/
  • Komplexität: cyvis.sourceforge.net/

Ein bisschen Mathe

Das Hauptproblem hier ist, dass wir zwei Kriterien haben – CA und zyklomatische Komplexität – also müssen wir sie kombinieren und in einen Skalarwert umwandeln. Wenn wir eine etwas andere Aufgabe hätten – z. B. eine Klasse mit der schlechtesten Kombination unserer Kriterien zu finden – hätten wir ein klassisches Mehrziel-Optimierungsproblem:

Wir müssten einen Punkt auf der sogenannten Pareto-Front finden (rot im Bild oben). Das Interessante an der Pareto-Menge ist, dass jeder Punkt in der Menge eine Lösung der Optimierungsaufgabe ist. Wann immer wir uns entlang der roten Linie bewegen, müssen wir einen Kompromiss zwischen unseren Kriterien eingehen – wenn eines besser wird, wird das andere schlechter. Dies wird als Skalierung bezeichnet und das Endergebnis hängt davon ab, wie wir es tun.

Es gibt viele Techniken, die wir hier anwenden können. Jedes hat seine eigenen Vor- und Nachteile. Die beliebtesten sind jedoch die lineare Skalierung und die auf einem Referenzpunkt basierende. Linear ist am einfachsten. Unsere Fitnessfunktion wird wie eine lineare Kombination aus CA und Komplexität aussehen:

f(CA, Komplexität) = A×CA + B×Komplexität

wobei A und B einige Koeffizienten sind.

Der Punkt, der eine Lösung unseres Optimierungsproblems darstellt, wird auf der Linie liegen (im Bild unten blau). Genauer gesagt wird es am Schnittpunkt der blauen Linie und der roten Pareto-Front liegen. Unser ursprüngliches Problem ist nicht gerade ein Optimierungsproblem. Vielmehr müssen wir eine Ranking-Funktion erstellen. Betrachten wir zwei Werte unserer Ranking-Funktion, im Grunde zwei Werte in unserer Rank-Spalte:

R1 = A∗CA + B∗Komplexität und R2 = A∗CA + B∗Komplexität

Beide oben geschriebenen Formeln sind Liniengleichungen, außerdem sind diese Linien parallel. Wenn wir mehr Rangwerte berücksichtigen, erhalten wir mehr Linien und damit mehr Punkte, an denen sich die Pareto-Linie mit den (gepunkteten) blauen Linien schneidet. Diese Punkte sind Klassen, die einem bestimmten Rangwert entsprechen.

Leider gibt es bei diesem Ansatz ein Problem. Für jede Linie (Rangwert) liegen Punkte mit sehr kleinem CA und sehr großer Komplexität (und umgekehrt) darauf. Dadurch werden Punkte mit einem großen Unterschied zwischen Metrikwerten sofort an die Spitze der Liste gesetzt, was genau das ist, was wir vermeiden wollten.

Die andere Art der Skalierung basiert auf dem Referenzpunkt. Referenzpunkt ist ein Punkt mit den Maximalwerten beider Kriterien:

(max(CA), max(Komplexität))

Die Fitnessfunktion ist der Abstand zwischen dem Referenzpunkt und den Datenpunkten:

f(CA,Komplexität) = √((CA−CA ) 2 + (Komplexität−Komplexität) 2 )

Wir können uns diese Fitnessfunktion als einen Kreis vorstellen, dessen Mittelpunkt der Bezugspunkt ist. Der Radius ist in diesem Fall der Wert des Rangs. Die Lösung des Optimierungsproblems ist der Punkt, an dem der Kreis die Pareto-Front berührt. Die Lösung des ursprünglichen Problems sind Punktmengen, die den verschiedenen Kreisradien entsprechen, wie im folgenden Bild gezeigt (Teile von Kreisen für verschiedene Ränge sind als gepunktete blaue Kurven dargestellt):

Dieser Ansatz geht besser mit Extremwerten um, aber es gibt immer noch zwei Probleme: Erstens – ich hätte gerne mehr Punkte in der Nähe der Referenzpunkte, um das Problem, mit dem wir bei der linearen Kombination konfrontiert sind, besser zu lösen. Zweitens – CA- und zyklomatische Komplexität sind von Natur aus unterschiedlich und haben unterschiedliche Werte, daher müssen wir sie normalisieren (z. B. so, dass alle Werte beider Metriken zwischen 1 und 100 liegen).

Hier ist ein kleiner Trick, den wir anwenden können, um das erste Problem zu lösen – anstatt den CA und die zyklomatische Komplexität zu betrachten, können wir uns ihre invertierten Werte ansehen. Der Bezugspunkt ist in diesem Fall (0,0). Um das zweite Problem zu lösen, können wir Metriken einfach mit dem Mindestwert normalisieren. So sieht es aus:

Invertierte und normalisierte Komplexität – NormComplexity:

(1 + min(Komplexität)) / (1 + Komplexität)∗100

Invertierter und normalisierter CA – NormCA:

(1 + min(CA)) / (1+CA)∗100

Hinweis: Ich habe 1 hinzugefügt, um sicherzustellen, dass es keine Division durch 0 gibt.

Das folgende Bild zeigt einen Plot mit den invertierten Werten:

Schlussrangliste

Wir kommen nun zum letzten Schritt – der Rangberechnung. Wie bereits erwähnt, verwende ich die Referenzpunktmethode. Das einzige, was wir tun müssen, ist, die Länge des Vektors zu berechnen, ihn zu normalisieren und ihn mit der Bedeutung einer Komponententesterstellung für eine Klasse aufsteigen zu lassen. Hier ist die endgültige Formel:

Rank(NormComplexity , NormCA) = 100 − √(NormComplexity 2 + NormCA 2 ) / √2

Mehr Statistiken

Es gibt noch einen weiteren Gedanken, den ich hinzufügen möchte, aber lassen Sie uns zuerst einen Blick auf einige Statistiken werfen. Hier ist ein Histogramm der Kopplungsmetriken:

Interessant an diesem Bild ist die Anzahl der Klassen mit niedrigem CA (0-2). Klassen mit CA 0 werden entweder gar nicht genutzt oder sind Dienste der obersten Ebene. Diese stellen API-Endpunkte dar, also ist es in Ordnung, dass wir viele davon haben. Klassen mit CA 1 werden jedoch direkt von den Endpunkten verwendet, und wir haben mehr dieser Klassen als Endpunkte. Was bedeutet das aus architektonischer / gestalterischer Sicht?

Im Allgemeinen bedeutet dies, dass wir eine Art Skript-orientierten Ansatz haben – wir skripten jeden Geschäftsfall separat (wir können den Code nicht wirklich wiederverwenden, da die Geschäftsfälle zu unterschiedlich sind). Wenn das der Fall ist, dann ist es definitiv ein Code-Geruch und wir müssen ein Refactoring durchführen. Andernfalls bedeutet dies, dass die Kohäsion unseres Systems gering ist, in diesem Fall brauchen wir auch Refactoring, diesmal aber Architektur-Refactoring.

Zusätzliche nützliche Informationen, die wir aus dem obigen Histogramm erhalten können, sind, dass wir Klassen mit geringer Kopplung (CA in {0,1}) vollständig aus der Liste der Klassen herausfiltern können, die für die Abdeckung mit Einheitentests in Frage kommen. Dieselben Klassen sind jedoch gute Kandidaten für die Integrations-/Funktionstests.

Sie finden alle Skripte und Ressourcen, die ich verwendet habe, in diesem GitHub-Repository: ashalitkin/code-base-stats.

Funktioniert es immer?

Nicht unbedingt. Zunächst einmal geht es um statische Analyse, nicht um Laufzeit. Wenn eine Klasse mit vielen anderen Klassen verknüpft ist, kann dies ein Zeichen dafür sein, dass sie stark verwendet wird, aber das stimmt nicht immer. Wir wissen zum Beispiel nicht, ob die Funktionalität wirklich stark von Endnutzern genutzt wird. Zweitens, wenn das Design und die Qualität des Systems gut genug sind, dann werden höchstwahrscheinlich verschiedene Teile / Schichten davon über Schnittstellen entkoppelt, so dass die statische Analyse der CA uns kein wahres Bild geben wird. Ich denke, das ist einer der Hauptgründe, warum CA in Tools wie Sonar nicht so beliebt ist. Glücklicherweise ist es für uns völlig in Ordnung, da wir, wie Sie sich erinnern, daran interessiert sind, dies speziell auf alte hässliche Codebasen anzuwenden.

Im Allgemeinen würde ich sagen, dass die Laufzeitanalyse viel bessere Ergebnisse liefern würde, aber leider ist sie viel kostspieliger, zeitaufwändiger und komplexer, sodass unser Ansatz eine potenziell nützliche und kostengünstigere Alternative darstellt.

Siehe auch: Single-Responsibility-Prinzip: Ein Rezept für großartigen Code