Scrittura di codice verificabile
Pubblicato: 2022-11-03Il test unitario è uno strumento essenziale nella cassetta degli attrezzi di qualsiasi sviluppatore di software. Scrivere unit test è relativamente facile quando si ha a che fare con una base di codice che segue le migliori pratiche, modelli e principi di progettazione del software. Il vero problema sorge quando si tenta di testare un codice mal progettato e non testabile.
Questo blog discuterà come scrivere più codice testabile per unità e quali modelli e pratiche scorrette evitare per migliorare la testabilità.
Codice testabile e non testabile
Quando si lavora su applicazioni su larga scala che devono essere gestibili a lungo termine, dobbiamo fare affidamento su test automatizzati per mantenere alta la qualità complessiva del sistema. Rispetto ai test di integrazione, in cui si testano più unità nel loro insieme, gli unit test hanno il vantaggio di essere veloci e stabili. Veloce perché stiamo istanziando, idealmente, solo la classe sottoposta a test e stabile perché di solito prendiamo in giro le dipendenze esterne, ad esempio, il database o la connessione di rete.
Se non hai familiarità con l'esatta differenza tra test unitari e di integrazione, puoi leggere ulteriori informazioni su questo argomento nel nostro blog Introduzione ai test.
Il codice testabile può essere isolato dal resto della nostra base di codice. In altre parole, le unità più piccole possono essere testate in modo indipendente. Il codice non verificabile è scritto in modo tale che sia difficile, o addirittura impossibile, scrivere un buon unit test per esso.
Esaminiamo alcuni anti-pattern e pratiche scorrette che dovremmo evitare quando scriviamo codice testabile.
Gli esempi sono scritti in Java, ma le convenzioni di codifica qui menzionate si applicano a qualsiasi linguaggio di programmazione orientato agli oggetti e framework di test. Useremo assertj e JUnit5 per esempi in questo post del blog.
Iniezione di dipendenza
L'iniezione delle dipendenze è uno dei modelli di progettazione più importanti per ottenere l'isolamento del test. L'iniezione di dipendenza è un modello di progettazione in cui un oggetto riceve altri oggetti (dipendenze) tramite parametri del costruttore o setter invece di doverli costruire da solo.
Con l'iniezione delle dipendenze, possiamo facilmente isolare la classe sottoposta a test simulando le dipendenze di un oggetto.
Diamo un'occhiata a un esempio senza iniezione di dipendenza:

Poiché la dipendenza dal motore viene costruita nel costruttore della classe Car, puoi dire che le classi Car e Engine sono strettamente accoppiate. Sono fortemente dipendenti l'uno dall'altro: cambiare uno richiederebbe un cambiamento nell'altro.
Dal punto di vista del test, non è possibile testare la classe Car in isolamento perché l'esempio precedente non può sostituire l'implementazione concreta di Engine con un test double.
Tuttavia, possiamo ottenere l'isolamento con l'uso dell'iniezione di dipendenza e del polimorfismo:

Ora possiamo costruire più implementazioni di motori e, quindi, auto con motori diversi:

Il test in isolamento è ora possibile perché possiamo creare un'implementazione beffarda dell'astrazione del motore e passarla alla nostra classe Car:

Quando si ha a che fare con oggetti che richiedono altri oggetti (dipendenze), è necessario fornirli tramite parametri del costruttore (iniezione di dipendenze), idealmente nascosti dietro qualche astrazione.
Seguendo questo schema, il tuo codice diventa più leggibile e adattabile al cambiamento nel tempo. Inoltre, dovresti evitare di svolgere un lavoro effettivo nei costruttori: qualsiasi cosa più delle assegnazioni sul campo è un lavoro effettivo. La parola chiave `new` nei costruttori è sempre un segnale di avvertimento di codice non verificabile.
Una cosa da notare qui è che in alcune situazioni, l'accoppiamento stretto (ad esempio, nuove parole chiave nei costruttori, classi interne per l'isolamento logico, mappatori di oggetti) non è una cattiva pratica – classi che non avrebbero senso come classe “autonoma”.
Stato globale
La condivisione di uno stato globale può spesso produrre test instabili (a volte superati, a volte falliti), specialmente in ambienti multithread.
Immagina uno scenario in cui più oggetti sottoposti a test condividono lo stesso stato globale: se un metodo in uno degli oggetti attiva un effetto collaterale che modifica il valore dello stato globale condiviso, l'output di un metodo nell'altro oggetto diventa imprevedibile. Evitare l'uso di metodi statici impuri perché mutano in qualche modo lo stato globale o sono collegati a uno stato globale.
Diamo un'occhiata a questo metodo statico impuro:


In sostanza, questo metodo legge la data e l'ora correnti del sistema e restituisce un risultato basato su quel valore. Sarebbe molto difficile scrivere un adeguato unit test basato sullo stato per questo metodo perché la chiamata statica LocalDateTime.now() produrrà risultati diversi durante l'esecuzione dei nostri test. Scrivere test per questo metodo è impossibile senza modificare la data e l'ora del sistema.
Per risolvere questo problema, passeremo il metodo date time al metodo timeOfDay come argomento:

Il metodo statico timeOfDay è ora puro: gli stessi input producono sempre gli stessi risultati. Ora possiamo passare facilmente oggetti dateTime isolati come argomenti nei nostri test:

Legge di Demetra
La legge di Demetra, o principio di minima conoscenza, afferma che gli oggetti dovrebbero conoscere solo gli oggetti strettamente correlati al primo oggetto. In altre parole, un oggetto dovrebbe avere accesso solo agli oggetti di cui ha bisogno. Ad esempio, abbiamo un metodo che accetta un oggetto di contesto come argomento:

Questo metodo viola la legge di Demetra perché ha bisogno di percorrere un grafico a oggetti per ottenere le informazioni richieste per svolgere il suo lavoro. Passare informazioni non necessarie in classi e metodi danneggia la verificabilità.
Immagina un enorme oggetto BillingContext contenente riferimenti ad altri oggetti:

Come possiamo vedere, il nostro test è pieno di informazioni non essenziali. I test che creano grafici di oggetti complessi sono difficili da leggere e introducono una complessità non necessaria.
Risolviamo il nostro esempio precedente:

Dovresti sempre passare le dipendenze dirette nelle tue classi e metodi. Tuttavia, anche il passaggio di molti argomenti nei metodi non è una buona pratica: idealmente, dovresti passare due argomenti al massimo o racchiudere argomenti correlati in oggetti dati.
Dio obietta
L'oggetto di Dio è un oggetto che fa riferimento a molti altri oggetti distinti, ha più di una responsabilità e ha molteplici ragioni per cambiare. Se è difficile riassumere ciò che fa la classe, o se riassumere include la parola "e", è probabile che la classe abbia più di una responsabilità.
Gli oggetti di Dio sono difficili da testare poiché abbiamo a che fare con più dipendenze non correlate, mescolando vari livelli di astrazioni e preoccupazioni e producono molti effetti collaterali. Di conseguenza, è difficile raggiungere lo stato desiderato per i nostri casi di test.
Per esempio:

UserService ha più di una responsabilità: registrare nuovi utenti e inviare e-mail. Durante il test della registrazione degli utenti, dobbiamo occuparci del servizio di posta elettronica e viceversa:

Immagina un UserService con più di due dipendenze non correlate. Queste dipendenze hanno le proprie dipendenze e così via. Finiremmo con un test illeggibile, gonfio di informazioni non correlate e molto difficile da capire. Pertanto, ogni classe dovrebbe avere solo una responsabilità e una ragione per cambiare. Una classe che ha un solo motivo per cambiare è uno dei cinque principi di progettazione del software chiamati Principio di responsabilità unica.
Puoi leggere di più sui principi SOLID qui.
Conclusione
Una base di codice che segue le migliori pratiche di progettazione del software rende la scrittura di unit test molto più gestibile.
D'altra parte, può essere molto impegnativo, o talvolta addirittura impossibile, scrivere un test dell'unità di lavoro per una base di codice utilizzando gli anti-pattern menzionati. Scrivere un buon codice verificabile richiede molta pratica, disciplina e uno sforzo extra. Il vantaggio più significativo del codice verificabile è la facilità di verificabilità e la capacità di comprendere, mantenere ed estendere tale codice.
Ci auguriamo che questo blog ti aiuti a scrivere codice testabile.