Scrivi i test che contano: affronta prima il codice più complesso

Pubblicato: 2022-03-11

Ci sono molte discussioni, articoli e blog sull'argomento della qualità del codice. La gente dice: usa le tecniche Test Driven! I test sono un "must have" per iniziare qualsiasi refactoring! Va tutto bene, ma è il 2016 e c'è un enorme volume di prodotti e basi di codice ancora in produzione che sono stati creati dieci, quindici o anche vent'anni fa. Non è un segreto che molti di loro abbiano un codice legacy con una bassa copertura dei test.

Anche se mi piacerebbe essere sempre all'avanguardia, o addirittura sanguinante, margine del mondo tecnologico - impegnato con nuovi progetti e tecnologie interessanti - sfortunatamente non è sempre possibile e spesso devo fare i conti con vecchi sistemi. Mi piace dire che quando sviluppi da zero, agisci come un creatore, padroneggiando nuova materia. Ma quando lavori su un codice legacy, sei più simile a un chirurgo: sai come funziona il sistema in generale, ma non sai mai con certezza se il paziente sopravviverà alla tua "operazione". E poiché si tratta di codice legacy, non ci sono molti test aggiornati su cui fare affidamento. Ciò significa che molto spesso uno dei primissimi passi è coprirlo con dei test. Più precisamente, non solo per fornire copertura, ma per sviluppare una strategia di copertura di prova.

Accoppiamento e complessità ciclomatica: metriche per una copertura del test più intelligente

Dimentica la copertura al 100%. Prova in modo più intelligente identificando le classi che hanno maggiori probabilità di interrompersi.
Twitta

Fondamentalmente, quello che dovevo determinare era quali parti (classi/pacchetti) del sistema dovevamo coprire con i test in primo luogo, dove avevamo bisogno di unit test, dove i test di integrazione sarebbero stati più utili ecc. Ci sono certamente molti modi per approccio a questo tipo di analisi e quella che ho usato potrebbe non essere la migliore, ma è una specie di approccio automatico. Una volta implementato il mio approccio, ci vuole un tempo minimo per eseguire effettivamente l'analisi stessa e, cosa più importante, porta un po' di divertimento nell'analisi del codice legacy.

L'idea principale qui è quella di analizzare due parametri: accoppiamento (cioè accoppiamento afferente o CA) e complessità (cioè complessità ciclomatica).

Il primo misura quante classi usano la nostra classe, quindi sostanzialmente ci dice quanto una particolare classe sia vicina al cuore del sistema; più classi ci sono che usano la nostra classe, più importante è coprirla con dei test.

D'altra parte, se una classe è molto semplice (ad es. contiene solo costanti), anche se è usata da molte altre parti del sistema, non è così importante creare un test per. Qui è dove la seconda metrica può aiutare. Se una classe contiene molta logica, la complessità Ciclomatica sarà elevata.

La stessa logica può essere applicata anche al contrario; vale a dire, anche se una classe non è utilizzata da molte classi e rappresenta solo un caso d'uso particolare, ha comunque senso coprirla con test se la sua logica interna è complessa.

C'è però un avvertimento: diciamo che abbiamo due classi: una con CA 100 e complessità 2 e l'altra con CA 60 e complessità 20. Anche se la somma delle metriche è più alta per la prima, dovremmo assolutamente coprire prima la seconda. Questo perché la prima classe è utilizzata da molte altre classi, ma non è molto complessa. D'altra parte, la seconda classe viene utilizzata anche da molte altre classi, ma è relativamente più complessa della prima classe.

Riassumendo: dobbiamo identificare classi con CA elevata e complessità Ciclomatica. In termini matematici è necessaria una funzione di fitness che possa essere utilizzata come rating - f(CA,Complessità) - i cui valori aumentano insieme a CA e Complessità.

In generale, le classi con le differenze più piccole tra le due metriche dovrebbero avere la massima priorità per la copertura del test.

Trovare strumenti per calcolare CA e complessità per l'intera base di codice e fornire un modo semplice per estrarre queste informazioni in formato CSV si è rivelata una sfida. Durante la mia ricerca, mi sono imbattuto in due strumenti gratuiti, quindi sarebbe ingiusto non menzionarli:

  • Metriche di accoppiamento: www.spinellis.gr/sw/ckjm/
  • Complessità: cyvis.sourceforge.net/

Un po' di matematica

Il problema principale qui è che abbiamo due criteri – CA e complessità ciclomatica – quindi dobbiamo combinarli e convertirli in un valore scalare. Se avessimo un compito leggermente diverso, ad esempio trovare una classe con la peggiore combinazione dei nostri criteri, avremmo un classico problema di ottimizzazione multi-obiettivo:

Bisognerebbe trovare un punto sul cosiddetto fronte di Pareto (rosso nella foto sopra). La cosa interessante dell'insieme di Pareto è che ogni punto dell'insieme è una soluzione al compito di ottimizzazione. Ogni volta che ci muoviamo lungo la linea rossa, dobbiamo fare un compromesso tra i nostri criteri: se uno migliora, l'altro peggiora. Questo si chiama scalarizzazione e il risultato finale dipende da come lo facciamo.

Ci sono molte tecniche che possiamo usare qui. Ognuno ha i suoi pro e contro. Tuttavia, i più diffusi sono la scalarizzazione lineare e quella basata su un punto di riferimento. Lineare è il più semplice. La nostra funzione fitness apparirà come una combinazione lineare di CA e complessità:

f(CA, Complessità) = A×CA + B×Complessità

dove A e B sono alcuni coefficienti.

Il punto che rappresenta una soluzione al nostro problema di ottimizzazione giace sulla linea (blu nell'immagine sotto). Più precisamente, sarà all'incrocio tra la linea blu e il fronte rosso di Pareto. Il nostro problema originale non è esattamente un problema di ottimizzazione. Piuttosto, dobbiamo creare una funzione di classificazione. Consideriamo due valori della nostra funzione di ranking, fondamentalmente due valori nella nostra colonna Rank:

R1 = A∗CA + B∗Complessità e R2 = A∗CA + B∗Complessità

Entrambe le formule scritte sopra sono equazioni di rette, inoltre queste rette sono parallele. Prendendo in considerazione più valori di rango, otterremo più linee e quindi più punti in cui la linea di Pareto si interseca con le linee blu (tratteggiate). Questi punti saranno classi corrispondenti a un particolare valore di rango.

Sfortunatamente, c'è un problema con questo approccio. Per ogni linea (valore Rank), avremo punti con CA molto piccola e complessità molto grande (e viceversa) che giacciono su di essa. Questo mette immediatamente i punti con una grande differenza tra i valori metrici in cima all'elenco, che è esattamente quello che volevamo evitare.

L'altro modo per eseguire la scalarizzazione è basato sul punto di riferimento. Il punto di riferimento è un punto con i valori massimi di entrambi i criteri:

(max(CA), max(Complessità))

La funzione fitness sarà la distanza tra il punto di riferimento e i punti dati:

f(CA,Complessità) = √((CA−CA ) 2 + (Complessità−Complessità) 2 )

Possiamo pensare a questa funzione di fitness come a un cerchio con il centro nel punto di riferimento. Il raggio in questo caso è il valore del Rank. La soluzione al problema di ottimizzazione sarà il punto in cui il cerchio tocca il fronte di Pareto. La soluzione al problema originale sarà costituita da insiemi di punti corrispondenti ai diversi raggi del cerchio come mostrato nell'immagine seguente (parti di cerchi per ranghi diversi sono mostrate come curve blu tratteggiate):

Questo approccio tratta meglio i valori estremi, ma ci sono ancora due problemi: Primo – mi piacerebbe avere più punti vicino ai punti di riferimento per superare meglio il problema che abbiamo affrontato con la combinazione lineare. Secondo: CA e complessità ciclomatica sono intrinsecamente diverse e hanno valori impostati diversi, quindi è necessario normalizzarli (ad es. in modo che tutti i valori di entrambe le metriche siano compresi tra 1 e 100).

Ecco un piccolo trucco che possiamo applicare per risolvere il primo problema: invece di guardare il CA e la complessità ciclomatica, possiamo guardare i loro valori invertiti. Il punto di riferimento in questo caso sarà (0,0). Per risolvere il secondo problema, possiamo semplicemente normalizzare le metriche utilizzando il valore minimo. Ecco come appare:

Complessità invertita e normalizzata – NormComplexity:

(1 + min(Complessità)) / (1 + Complessità)∗100

CA invertita e normalizzata – NormCA:

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

Nota: ho aggiunto 1 per assicurarmi che non vi sia alcuna divisione per 0.

L'immagine seguente mostra un grafico con i valori invertiti:

Classifica finale

Ora stiamo arrivando all'ultimo passaggio: calcolare il grado. Come accennato, sto usando il metodo del punto di riferimento, quindi l'unica cosa che dobbiamo fare è calcolare la lunghezza del vettore, normalizzarlo e farlo salire con l'importanza della creazione di un test unitario per una classe. Ecco la formula finale:

Rango(Complessità Norma , NormCA) = 100 − √(Complessità Norm 2 + NormCA 2 ) / √2

Altre statistiche

C'è un altro pensiero che vorrei aggiungere, ma diamo prima un'occhiata ad alcune statistiche. Ecco un istogramma delle metriche di Coupling:

Ciò che è interessante in questa immagine è il numero di classi con CA bassa (0-2). Le classi con CA 0 non vengono utilizzate affatto o sono servizi di livello superiore. Questi rappresentano gli endpoint API, quindi va bene che ne abbiamo molti. Ma le classi con CA 1 sono quelle utilizzate direttamente dagli endpoint e abbiamo più di queste classi che endpoint. Cosa significa questo dal punto di vista dell'architettura/design?

In generale, significa che abbiamo una sorta di approccio orientato allo script: scriviamo ogni business case separatamente (non possiamo davvero riutilizzare il codice poiché i business case sono troppo diversi). Se questo è il caso, allora è sicuramente un odore di codice e dobbiamo fare il refactoring. In caso contrario, significa che la coesione del nostro sistema è bassa, nel qual caso è necessario anche il refactoring, ma questa volta il refactoring architettonico.

Ulteriori informazioni utili che possiamo ottenere dall'istogramma sopra sono che possiamo filtrare completamente le classi con basso accoppiamento (CA in {0,1}) dall'elenco delle classi idonee alla copertura con unit test. Le stesse classi, però, sono dei buoni candidati per i test di integrazione/funzionali.

Puoi trovare tutti gli script e le risorse che ho usato in questo repository GitHub: ashalitkin/code-base-stats.

Funziona sempre?

Non necessariamente. Prima di tutto si tratta di analisi statica, non di runtime. Se una classe è collegata da molte altre classi può essere un segno che è molto utilizzata, ma non è sempre vero. Ad esempio, non sappiamo se la funzionalità sia davvero molto utilizzata dagli utenti finali. In secondo luogo, se il design e la qualità del sistema sono sufficientemente buoni, molto probabilmente parti/strati diversi di esso vengono disaccoppiati tramite interfacce, quindi l'analisi statica del CA non ci darà un quadro reale. Immagino che sia uno dei motivi principali per cui CA non è così popolare in strumenti come Sonar. Fortunatamente, per noi va benissimo poiché, se ricordi, siamo interessati ad applicarlo specificamente a vecchie basi di codice brutte.

In generale, direi che l'analisi di runtime darebbe risultati molto migliori, ma sfortunatamente è molto più costosa, dispendiosa in termini di tempo e complessa, quindi il nostro approccio è un'alternativa potenzialmente utile e a basso costo.

Correlati: Principio di responsabilità unica: una ricetta per un grande codice