Napisz testy, które mają znaczenie: najpierw zmierz się z najbardziej złożonym kodem

Opublikowany: 2022-03-11

Istnieje wiele dyskusji, artykułów i blogów na temat jakości kodu. Ludzie mówią – używaj technik Test Driven! Testy to „must have” do rozpoczęcia jakiejkolwiek refaktoryzacji! To wszystko fajnie, ale jest rok 2016 i wciąż jest w produkcji ogromna liczba produktów i baz kodu, które powstały dziesięć, piętnaście, a nawet dwadzieścia lat temu. Nie jest tajemnicą, że wiele z nich ma przestarzały kod o niskim pokryciu testów.

Chociaż chciałbym być zawsze na czele, a nawet krwawiącej krawędzi świata technologii - zaangażowany w nowe fajne projekty i technologie - niestety nie zawsze jest to możliwe i często mam do czynienia ze starymi systemami. Lubię mówić, że rozwijając się od zera, zachowujesz się jak twórca, opanowując nową materię. Ale kiedy pracujesz nad starym kodem, jesteś bardziej jak chirurg – wiesz, jak system działa w ogóle, ale nigdy nie masz pewności, czy pacjent przeżyje Twoją „operację”. A ponieważ jest to przestarzały kod, nie ma wielu aktualnych testów, na których można by polegać. Oznacza to, że bardzo często jednym z pierwszych kroków jest objęcie go testami. Dokładniej, nie tylko w celu zapewnienia pokrycia, ale także w celu opracowania strategii pokrycia testów.

Sprzężenie i złożoność cyklomatyczna: metryki dla lepszego pokrycia testów

Zapomnij o 100% zasięgu. Testuj mądrzej, identyfikując klasy, które są bardziej podatne na złamanie.
Ćwierkać

W zasadzie to, co musiałem ustalić, to jakie części (klasy/pakiety) systemu musieliśmy objąć testami w pierwszej kolejności, gdzie potrzebowaliśmy testów jednostkowych, w których bardziej pomocne byłyby testy integracyjne itp. Jest wprawdzie wiele sposobów na podejście do tego typu analizy i to, którego używałem, może nie jest najlepsze, ale jest to rodzaj automatycznego podejścia. Po wdrożeniu mojego podejścia, sama analiza zajmuje niewiele czasu, a co ważniejsze, wnosi trochę zabawy do analizy starszego kodu.

Główną ideą jest tutaj analiza dwóch metryk – sprzężenia (tj. sprzężenia aferentnego lub CA) i złożoności (tj. złożoności cyklomatycznej).

Pierwsza z nich mierzy, ile klas korzysta z naszej klasy, więc w zasadzie mówi nam, jak blisko jest dana klasa od serca systemu; im więcej klas korzysta z naszej klasy, tym ważniejsze jest pokrycie jej testami.

Z drugiej strony, jeśli klasa jest bardzo prosta (np. zawiera tylko stałe), to nawet jeśli jest używana przez wiele innych części systemu, tworzenie testu nie jest aż tak ważne. Tutaj może pomóc druga metryka. Jeśli klasa zawiera dużo logiki, złożoność Cyclomatic będzie wysoka.

Ta sama logika może być również zastosowana w odwrotnej kolejności; tj. nawet jeśli klasa nie jest używana przez wiele klas i reprezentuje tylko jeden konkretny przypadek użycia, nadal ma sens obłożenie jej testami, jeśli jej wewnętrzna logika jest złożona.

Jest jednak jedno zastrzeżenie: powiedzmy, że mamy dwie klasy – jedną z CA 100 i złożonością 2, a drugą z CA 60 i złożonością 20. Mimo że suma metryk jest wyższa w przypadku pierwszej, zdecydowanie powinniśmy ją uwzględnić drugi pierwszy. Dzieje się tak, ponieważ pierwsza klasa jest używana przez wiele innych klas, ale nie jest zbyt skomplikowana. Z drugiej strony druga klasa jest również używana przez wiele innych klas, ale jest stosunkowo bardziej złożona niż pierwsza klasa.

Podsumowując: musimy zidentyfikować klasy o wysokiej złożoności CA i Cyclomatic. W kategoriach matematycznych potrzebna jest funkcja dopasowania, której można użyć jako oceny — f(CA,Complexity) — której wartości rosną wraz z CA i Complexity.

Ogólnie rzecz biorąc, klasom o najmniejszych różnicach między tymi dwiema metrykami należy nadać najwyższy priorytet w zakresie pokrycia testami.

Wyzwaniem okazało się znalezienie narzędzi do obliczania CA i złożoności dla całej bazy kodu oraz zapewnienie prostego sposobu na wyodrębnienie tych informacji w formacie CSV. Podczas moich poszukiwań natknąłem się na dwa narzędzia, które są darmowe, więc byłoby niesprawiedliwe nie wspomnieć o nich:

  • Mierniki sprzężenia: www.spinellis.gr/sw/ckjm/
  • Złożoność: cyvis.sourceforge.net/

Trochę matematyki

Główny problem polega na tym, że mamy dwa kryteria – CA i złożoność cyklomatyczną – więc musimy je połączyć i przeliczyć na jedną wartość skalarną. Gdybyśmy mieli nieco inne zadanie – np. znalezienie klasy z najgorszą kombinacją naszych kryteriów – mielibyśmy klasyczny problem optymalizacji wielokryterialnej:

Musielibyśmy znaleźć punkt na tzw. froncie Pareto (czerwony na powyższym obrazku). Co ciekawe w zbiorze Pareto, każdy punkt w zbiorze jest rozwiązaniem zadania optymalizacyjnego. Ilekroć poruszamy się wzdłuż czerwonej linii, musimy iść na kompromis między naszymi kryteriami – jeśli jedno się poprawia, drugie się pogarsza. Nazywa się to skalaryzacją, a ostateczny wynik zależy od tego, jak to zrobimy.

Istnieje wiele technik, których możemy tutaj użyć. Każdy ma swoje plusy i minusy. Najpopularniejszymi są jednak skalaryzacje liniowe i oparte na punkcie odniesienia. Liniowy jest najłatwiejszy. Nasza funkcja fitness będzie wyglądać jak liniowa kombinacja CA i Złożoności:

f(CA, Złożoność) = A×CA + B×Złożoność

gdzie A i B to niektóre współczynniki.

Na linii (niebieski na poniższym obrazku) będzie leżeć punkt, który reprezentuje rozwiązanie naszego problemu optymalizacyjnego. Dokładniej będzie na przecięciu niebieskiej linii i czerwonego frontu Pareto. Nasz pierwotny problem nie dotyczy dokładnie optymalizacji. Zamiast tego musimy stworzyć funkcję rankingową. Rozważmy dwie wartości naszej funkcji rankingowej, w zasadzie dwie wartości w naszej kolumnie Ranga:

R1 = A∗CA + B∗Złożoność i R2 = A∗CA + B∗Złożoność

Obie formuły napisane powyżej są równaniami prostych, co więcej, linie te są równoległe. Biorąc pod uwagę więcej wartości rang, otrzymamy więcej linii, a tym samym więcej punktów, w których linia Pareto przecina się z (kropkowaną) niebieską linią. Punkty te będą klasami odpowiadającymi określonej wartości rangi.

Niestety, z takim podejściem jest problem. Dla dowolnej linii (wartość rangi) będziemy mieć punkty z bardzo małym CA i bardzo dużą złożonością (i na odwrót). To natychmiast umieszcza punkty z dużą różnicą między wartościami metryk na górze listy, czego dokładnie chcieliśmy uniknąć.

Drugi sposób skalaryzowania opiera się na punkcie odniesienia. Punkt odniesienia to punkt, w którym występują maksymalne wartości obu kryteriów:

(maks.(CA), maks.(złożoność))

Funkcja fitness będzie odległością między punktem odniesienia a punktami danych:

f(CA,Złożoność) = √((CA-CA ) 2 + (Złożoność-Złożoność) 2 )

Możemy myśleć o tej funkcji fitness jako o okręgu ze środkiem w punkcie odniesienia. Promień w tym przypadku jest wartością rangi. Rozwiązaniem problemu optymalizacji będzie punkt, w którym okrąg styka się z czołem Pareto. Rozwiązaniem pierwotnego problemu będą zestawy punktów odpowiadających różnym promieniom okręgów, jak pokazano na poniższym rysunku (części okręgów dla różnych rang są pokazane jako kropkowane niebieskie krzywe):

To podejście lepiej radzi sobie z wartościami ekstremalnymi, ale nadal istnieją dwa problemy: Po pierwsze – chciałbym mieć więcej punktów w pobliżu punktów odniesienia, aby lepiej przezwyciężyć problem, z którym mieliśmy do czynienia z kombinacją liniową. Po drugie – złożoność CA i Cyclomatic są z natury różne i mają ustawione różne wartości, więc musimy je znormalizować (np. tak, aby wszystkie wartości obu metryk wynosiły od 1 do 100).

Oto mała sztuczka, którą możemy zastosować do rozwiązania pierwszego problemu – zamiast patrzeć na CA i Cyclomatic Complexity, możemy spojrzeć na ich odwrócone wartości. Punktem odniesienia w tym przypadku będzie (0,0). Aby rozwiązać drugi problem, możemy po prostu znormalizować metryki przy użyciu wartości minimalnej. Oto jak to wygląda:

Odwrócona i znormalizowana złożoność – NormComplexity:

(1 + min (złożoność)) / (1 + złożoność)∗100

Odwrócony i znormalizowany CA – NormCA:

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

Uwaga: dodałem 1, aby upewnić się, że nie ma dzielenia przez 0.

Poniższy rysunek przedstawia wykres z odwróconymi wartościami:

Ranking końcowy

Przechodzimy teraz do ostatniego kroku - obliczania rangi. Jak wspomniano, używam metody punktu odniesienia, więc jedyne, co musimy zrobić, to obliczyć długość wektora, znormalizować go i sprawić, by był wzniesiony ze znaczeniem tworzenia testu jednostkowego dla klasy. Oto ostateczna formuła:

Ranga(Złożoność normy , Złożoność normy , Złożoność normy ) = 100 − √(Złożoność normy 2 + Złożoność normy 2 ) / √2

Więcej statystyk

Jest jeszcze jedna myśl, którą chciałbym dodać, ale najpierw przyjrzyjmy się statystykom. Oto histogram metryk sprzężenia:

Interesujące w tym obrazie jest liczba klas z niskim CA (0-2). Klasy z CA 0 albo w ogóle nie są używane, albo są usługami najwyższego poziomu. Reprezentują one punkty końcowe API, więc dobrze, że mamy ich dużo. Ale klasy z CA 1 to te, które są bezpośrednio używane przez punkty końcowe i mamy więcej tych klas niż punktów końcowych. Co to oznacza z perspektywy architektury/projektu?

Ogólnie oznacza to, że mamy podejście zorientowane na skrypty – każdy przypadek biznesowy piszemy osobno (nie możemy tak naprawdę ponownie wykorzystać kodu, ponieważ przypadki biznesowe są zbyt różnorodne). Jeśli tak jest, to z pewnością jest to zapach kodu i musimy przeprowadzić refaktoryzację. W przeciwnym razie oznacza to, że spójność naszego systemu jest niska, w takim przypadku również potrzebujemy refaktoryzacji, ale tym razem refaktoryzacji architektonicznej.

Dodatkową przydatną informacją, jaką możemy uzyskać z powyższego histogramu, jest to, że możemy całkowicie odfiltrować klasy z niskim sprzężeniem (CA w {0,1}) z listy klas kwalifikujących się do pokrycia testami jednostkowymi. Jednak te same klasy są dobrymi kandydatami do testów integracyjnych/funkcjonalnych.

Możesz znaleźć wszystkie skrypty i zasoby, z których korzystałem w tym repozytorium GitHub: ashalitkin/code-base-stats.

Czy to zawsze działa?

Niekoniecznie. Przede wszystkim chodzi o analizę statyczną, a nie runtime. Jeśli klasa jest połączona z wieloma innymi klasami, może to oznaczać, że jest intensywnie używana, ale nie zawsze jest to prawda. Na przykład nie wiemy, czy funkcjonalność jest rzeczywiście intensywnie wykorzystywana przez użytkowników końcowych. Po drugie, jeśli projekt i jakość systemu są wystarczająco dobre, to najprawdopodobniej różne jego części/warstwy są odsprzęgane za pomocą interfejsów, więc statyczna analiza CA nie da nam prawdziwego obrazu. Myślę, że to jeden z głównych powodów, dla których CA nie jest tak popularne w narzędziach takich jak Sonar. Na szczęście jest to dla nas całkowicie w porządku, ponieważ, jeśli pamiętasz, jesteśmy zainteresowani zastosowaniem tego specjalnie do starych, brzydkich baz kodu.

Ogólnie powiedziałbym, że analiza runtime dałaby znacznie lepsze wyniki, ale niestety jest dużo bardziej kosztowna, czasochłonna i złożona, więc nasze podejście jest potencjalnie użyteczną i tańszą alternatywą.

Powiązane: Zasada pojedynczej odpowiedzialności: przepis na świetny kod