Scrieți teste care contează: abordați mai întâi cel mai complex cod

Publicat: 2022-03-11

Există o mulțime de discuții, articole și bloguri pe tema calității codului. Oamenii spun - folosește tehnici Test Driven! Testele sunt un „must have” pentru a începe orice refactorizare! Totul este grozav, dar este 2016 și există încă un volum masiv de produse și baze de coduri care au fost create acum zece, cincisprezece sau chiar douăzeci de ani. Nu este un secret pentru nimeni că multe dintre ele au cod moștenit cu acoperire scăzută de testare.

Deși mi-aș dori să fiu mereu la vârful lumii tehnologiei, sau chiar la sângerarea, - angajat cu noi proiecte și tehnologii interesante -, din păcate, nu este întotdeauna posibil și adesea trebuie să mă ocup de sisteme vechi. Îmi place să spun că atunci când te dezvolți de la zero, acționezi ca un creator, stăpânind materie nouă. Dar când lucrezi la un cod moștenit, ești mai mult ca un chirurg – știi cum funcționează sistemul în general, dar nu știi niciodată sigur dacă pacientul va supraviețui „operației”. Și din moment ce este un cod moștenit, nu există multe teste actualizate pe care să te bazezi. Aceasta înseamnă că, foarte frecvent, unul dintre primii pași este acoperirea acestuia cu teste. Mai precis, nu doar pentru a oferi acoperire, ci pentru a dezvolta o strategie de acoperire a testelor.

Cuplare și complexitate ciclomatică: metrici pentru o acoperire mai inteligentă a testelor

Uitați de acoperire 100%. Testați mai inteligent identificând clasele care au mai multe șanse să se întrerupă.
Tweet

Practic, ceea ce trebuia să stabilesc era ce părți (clasele/pachetele) ale sistemului trebuia să acoperim cu teste în primul rând, unde aveam nevoie de teste unitare, unde testele de integrare ar fi mai utile etc. Desigur, există multe moduri de a abordați acest tip de analiză și cea pe care am folosit-o poate să nu fie cea mai bună, dar este un fel de abordare automată. Odată ce abordarea mea este implementată, este nevoie de timp minim pentru a face analiza în sine și, ceea ce este mai important, aduce puțină distracție în analiza codului moștenit.

Ideea principală aici este de a analiza două metrici – cuplarea (adică, cuplarea aferentă sau CA) și complexitatea (adică complexitatea ciclomatică).

Prima măsoară câte clase folosesc clasa noastră, deci practic ne spune cât de aproape este o anumită clasă de inima sistemului; cu cât sunt mai multe clase care folosesc clasa noastră, cu atât mai important este să o acoperim cu teste.

Pe de altă parte, dacă o clasă este foarte simplă (de exemplu, conține doar constante), atunci chiar dacă este folosită de multe alte părți ale sistemului, nu este atât de important să creați un test pentru. Aici este locul în care a doua măsurătoare poate ajuta. Dacă o clasă conține multă logică, complexitatea ciclomatică va fi mare.

Aceeași logică poate fi aplicată și invers; adică, chiar dacă o clasă nu este folosită de mai multe clase și reprezintă doar un anumit caz de utilizare, este totuși logic să o acoperiți cu teste dacă logica sa internă este complexă.

Există totuși un avertisment: să presupunem că avem două clase – una cu CA 100 și complexitatea 2 și cealaltă cu CA 60 și complexitatea 20. Chiar dacă suma valorilor este mai mare pentru prima, ar trebui neapărat să o acoperim al doilea primul. Acest lucru se datorează faptului că prima clasă este folosită de o mulțime de alte clase, dar nu este foarte complexă. Pe de altă parte, a doua clasă este folosită și de multe alte clase, dar este relativ mai complexă decât prima clasă.

Pentru a rezuma: trebuie să identificăm clase cu CA ridicată și complexitate ciclomatică. În termeni matematici, este nevoie de o funcție de fitness care poate fi folosită ca rating - f(CA,Complexity) - ale cărei valori cresc odată cu CA și Complexitate.

În general, clasele cu cele mai mici diferențe între cele două valori ar trebui să aibă cea mai mare prioritate pentru acoperirea testului.

Găsirea de instrumente pentru a calcula CA și complexitate pentru întreaga bază de cod și pentru a oferi o modalitate simplă de a extrage aceste informații în format CSV, sa dovedit a fi o provocare. În timpul căutării mele, am dat peste două instrumente care sunt gratuite, așa că ar fi nedrept să nu le menționez:

  • Valori de cuplare: www.spinellis.gr/sw/ckjm/
  • Complexitate: cyvis.sourceforge.net/

Un pic de matematică

Principala problemă aici este că avem două criterii – CA și complexitatea ciclomatică – așa că trebuie să le combinăm și să le transformăm într-o singură valoare scalară. Dacă am avea o sarcină puțin diferită – de exemplu, să găsim o clasă cu cea mai proastă combinație a criteriilor noastre – am avea o problemă clasică de optimizare multi-obiectivă:

Ar trebui să găsim un punct pe așa-numitul front Pareto (roșu în imaginea de mai sus). Ceea ce este interesant despre mulțimea Pareto este că fiecare punct din mulțime este o soluție la sarcina de optimizare. Ori de câte ori trecem de-a lungul liniei roșii trebuie să facem un compromis între criteriile noastre – dacă unul se îmbunătățește, celălalt se înrăutățește. Aceasta se numește scalarizare și rezultatul final depinde de modul în care o facem.

Există o mulțime de tehnici pe care le putem folosi aici. Fiecare are propriile sale avantaje și dezavantaje. Cu toate acestea, cele mai populare sunt scalarizarea liniară și cea bazată pe un punct de referință. Linear este cel mai simplu. Funcția noastră de fitness va arăta ca o combinație liniară de CA și Complexitate:

f(CA, Complexitate) = A×CA + B×Complexitate

unde A și B sunt niște coeficienți.

Punctul care reprezintă o soluție la problema noastră de optimizare se va afla pe linie (albastru în imaginea de mai jos). Mai exact, va fi la intersecția liniei albastre și a frontului Pareto roșu. Problema noastră inițială nu este tocmai o problemă de optimizare. Mai degrabă, trebuie să creăm o funcție de clasare. Să luăm în considerare două valori ale funcției noastre de clasare, practic două valori din coloana noastră Rank:

R1 = A∗CA + B∗Complexitate și R2 = A∗CA + B∗Complexitate

Ambele formule scrise mai sus sunt ecuații de drepte, în plus, aceste linii sunt paralele. Luând în considerare mai multe valori de rang, vom obține mai multe linii și, prin urmare, mai multe puncte în care linia Pareto se intersectează cu liniile albastre (punctate). Aceste puncte vor fi clase corespunzătoare unei anumite valori de rang.

Din păcate, există o problemă cu această abordare. Pentru orice linie (valoare de rang), vom avea puncte cu CA foarte mic și Complexitate foarte mare (și invers) pe ea. Acest lucru pune imediat puncte cu o diferență mare între valorile metrice în partea de sus a listei, ceea ce am vrut să evităm.

Cealaltă modalitate de a face scalarea se bazează pe punctul de referință. Punctul de referință este un punct cu valorile maxime ale ambelor criterii:

(max(CA), max(Complexitate))

Funcția de fitness va fi distanța dintre punctul de referință și punctele de date:

f(CA,Complexitate) = √((CA−CA ) 2 + (Complexitate−Complexitate) 2 )

Ne putem gândi la această funcție de fitness ca un cerc cu centrul la punctul de referință. Raza în acest caz este valoarea Rangului. Soluția problemei de optimizare va fi punctul în care cercul atinge frontul Pareto. Soluția problemei inițiale va fi seturi de puncte corespunzătoare diferitelor raze ale cercului, așa cum se arată în imaginea următoare (părți de cerc pentru diferite ranguri sunt afișate ca curbe cu puncte albastre):

Această abordare se ocupă mai bine de valorile extreme, dar există încă două probleme: În primul rând – aș dori să am mai multe puncte în apropierea punctelor de referință pentru a depăși mai bine problema cu care ne-am confruntat cu combinația liniară. În al doilea rând – CA și complexitatea ciclomatică sunt în mod inerent diferite și au valori diferite setate, așa că trebuie să le normalizăm (de exemplu, astfel încât toate valorile ambelor metrici să fie de la 1 la 100).

Iată un mic truc pe care îl putem aplica pentru a rezolva prima problemă – în loc să ne uităm la CA și Complexitatea ciclomatică, putem să ne uităm la valorile inversate. Punctul de referință în acest caz va fi (0,0). Pentru a rezolva a doua problemă, putem doar să normalizăm valorile folosind valoarea minimă. Iată cum arată:

Complexitate inversată și normalizată – NormComplexity:

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

CA inversată și normalizată – NormCA:

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

Notă: am adăugat 1 pentru a mă asigura că nu există nicio împărțire cu 0.

Următoarea imagine prezintă un grafic cu valorile inversate:

Clasamentul final

Acum ajungem la ultimul pas - calcularea rangului. După cum am menționat, folosesc metoda punctului de referință, așa că singurul lucru pe care trebuie să-l facem este să calculăm lungimea vectorului, să-l normalizăm și să-l facem să urce cu importanța creării unui test unitar pentru o clasă. Iată formula finală:

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

Mai multe statistici

Mai este un gând pe care aș dori să îl adaug, dar mai întâi să aruncăm o privire la câteva statistici. Iată o histogramă a valorilor de cuplare:

Ceea ce este interesant la această imagine este numărul de clase cu CA scăzut (0-2). Clasele cu CA 0 fie nu sunt folosite deloc, fie sunt servicii de nivel superior. Acestea reprezintă puncte finale API, așa că este bine că avem multe dintre ele. Dar clasele cu CA 1 sunt cele care sunt utilizate direct de punctele finale și avem mai multe dintre aceste clase decât punctele finale. Ce înseamnă asta din perspectiva arhitecturii/designului?

În general, înseamnă că avem un fel de abordare orientată spre script – scriem fiecare caz de afaceri separat (nu putem cu adevărat reutiliza codul, deoarece cazurile de afaceri sunt prea diverse). Dacă acesta este cazul, atunci este cu siguranță un miros de cod și trebuie să facem refactoring. În rest, înseamnă că coeziunea sistemului nostru este scăzută, caz în care avem nevoie și de refactorizare, dar de data aceasta refactorizare arhitecturală.

Informații suplimentare utile pe care le putem obține din histograma de mai sus sunt că putem filtra complet clasele cu cuplare scăzută (CA în {0,1}) din lista claselor eligibile pentru acoperire cu teste unitare. Aceleași clase, însă, sunt candidați buni pentru testele de integrare/funcționale.

Puteți găsi toate scripturile și resursele pe care le-am folosit în acest depozit GitHub: ashalitkin/code-base-stats.

Funcționează întotdeauna?

Nu neaparat. În primul rând, totul este despre analiza statică, nu despre timpul de rulare. Dacă o clasă este legată de multe alte clase, poate fi un semn că este folosită intens, dar nu este întotdeauna adevărat. De exemplu, nu știm dacă funcționalitatea este într-adevăr utilizată intens de utilizatorii finali. În al doilea rând, dacă designul și calitatea sistemului sunt suficient de bune, atunci cel mai probabil diferite părți/straturi ale acestuia sunt decuplate prin interfețe, astfel încât analiza statică a CA nu ne va oferi o imagine adevărată. Bănuiesc că este unul dintre motivele principale pentru care CA nu este atât de popular în instrumente precum Sonar. Din fericire, este foarte bine pentru noi, deoarece, dacă vă amintiți, suntem interesați să aplicăm acest lucru în mod specific bazelor vechi de coduri urâte.

În general, aș spune că analiza timpului de execuție ar da rezultate mult mai bune, dar, din păcate, este mult mai costisitoare, consumatoare de timp și complexă, așa că abordarea noastră este o alternativă potențial utilă și cu costuri mai mici.

Înrudit: Principiul responsabilității unice: O rețetă pentru un cod grozav