Unit test, come scrivere codice verificabile e perché è importante

Pubblicato: 2022-03-11

Il test unitario è uno strumento essenziale nella cassetta degli attrezzi di qualsiasi sviluppatore di software serio. Tuttavia, a volte può essere abbastanza difficile scrivere un buon unit test per un particolare pezzo di codice. Avendo difficoltà a testare il proprio codice o quello di qualcun altro, gli sviluppatori spesso pensano che le loro difficoltà siano causate dalla mancanza di alcune conoscenze fondamentali sui test o di tecniche segrete di unit test.

In questo tutorial sugli unit test, intendo dimostrare che gli unit test sono abbastanza semplici; i veri problemi che complicano gli unit test e introducono costose complessità sono il risultato di un codice mal progettato e non testabile . Discuteremo cosa rende il codice difficile da testare, quali anti-pattern e cattive pratiche dovremmo evitare per migliorare la testabilità e quali altri vantaggi possiamo ottenere scrivendo codice testabile. Vedremo che scrivere unit test e generare codice verificabile non significa solo rendere i test meno problematici, ma anche rendere il codice stesso più robusto e più facile da mantenere.

Tutorial test unitari: illustrazione di copertina

Che cos'è il test unitario?

In sostanza, uno unit test è un metodo che istanzia una piccola parte della nostra applicazione e ne verifica il comportamento indipendentemente dalle altre parti . Un tipico unit test contiene 3 fasi: in primo luogo, inizializza un piccolo pezzo di un'applicazione che vuole testare (noto anche come il sistema in prova, o SUT), quindi applica uno stimolo al sistema in prova (di solito chiamando un metodo su di esso) e, infine, osserva il comportamento risultante. Se il comportamento osservato è coerente con le aspettative, lo unit test supera, altrimenti fallisce, indicando che c'è un problema da qualche parte nel sistema sottoposto a test. Queste tre fasi del test unitario sono anche note come Arrange, Act e Assert o semplicemente AAA.

Uno unit test può verificare diversi aspetti comportamentali del sistema sotto test, ma molto probabilmente rientrerà in una delle seguenti due categorie: basato sullo stato o basato sull'interazione . La verifica che il sistema sottoposto a test produca risultati corretti o che il suo stato risultante sia corretto è chiamato unit test basato sullo stato , mentre la verifica che invochi correttamente determinati metodi è chiamata unit test basata sull'interazione .

Come metafora per il corretto test delle unità software, immagina uno scienziato pazzo che vuole costruire una chimera soprannaturale, con zampe di rana, tentacoli di polpo, ali di uccello e testa di cane. (Questa metafora è abbastanza simile a ciò che i programmatori fanno effettivamente al lavoro). In che modo quello scienziato si assicurerebbe che ogni parte (o unità) che ha scelto funzioni davvero? Bene, può prendere, diciamo, una singola zampa di rana, applicarvi uno stimolo elettrico e controllare la corretta contrazione muscolare. Quello che sta facendo sono essenzialmente gli stessi passaggi Arrange-Act-Assert del test unitario; l'unica differenza è che, in questo caso, unità si riferisce a un oggetto fisico, non a un oggetto astratto da cui costruiamo i nostri programmi.

cos'è il test unitario: illustrazione

Userò C# per tutti gli esempi in questo articolo, ma i concetti descritti si applicano a tutti i linguaggi di programmazione orientati agli oggetti.

Un esempio di un semplice unit test potrebbe essere simile a questo:

 [TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }

Test unitario e test di integrazione

Un'altra cosa importante da considerare è la differenza tra test di unità e test di integrazione.

Lo scopo di un test unitario nell'ingegneria del software è verificare il comportamento di un software relativamente piccolo, indipendentemente dalle altre parti. I test unitari sono di portata ristretta e ci consentono di coprire tutti i casi, assicurando che ogni singola parte funzioni correttamente.

D'altra parte, i test di integrazione dimostrano che diverse parti di un sistema lavorano insieme nell'ambiente reale . Convalidano scenari complessi (possiamo pensare ai test di integrazione come a un utente che esegue alcune operazioni di alto livello all'interno del nostro sistema) e di solito richiedono la presenza di risorse esterne, come database o server Web.

Torniamo alla metafora del nostro scienziato pazzo e supponiamo che abbia combinato con successo tutte le parti della chimera. Vuole eseguire un test di integrazione della creatura risultante, assicurandosi che possa, diciamo, camminare su diversi tipi di terreno. Prima di tutto, lo scienziato deve emulare un ambiente su cui la creatura può camminare. Quindi, lancia la creatura in quell'ambiente e la colpisce con un bastone, osservando se cammina e si muove come previsto. Dopo aver terminato un test, lo scienziato pazzo ripulisce tutto lo sporco, la sabbia e le rocce che ora sono sparse nel suo adorabile laboratorio.

illustrazione di esempio di test unitario

Si noti la differenza significativa tra unit test e test di integrazione: uno unit test verifica il comportamento di una piccola parte dell'applicazione, isolata dall'ambiente e da altre parti, ed è abbastanza facile da implementare, mentre un test di integrazione copre le interazioni tra diversi componenti, nel un ambiente simile alla vita reale e richiede uno sforzo maggiore, comprese fasi di configurazione e smontaggio aggiuntive.

Una ragionevole combinazione di unità e test di integrazione garantisce che ogni singola unità funzioni correttamente, indipendentemente dalle altre, e che tutte queste unità funzionino bene quando integrate, dandoci un alto livello di fiducia che l'intero sistema funzioni come previsto.

Tuttavia, dobbiamo ricordarci di identificare sempre che tipo di test stiamo implementando: un test di unità o di integrazione. La differenza a volte può ingannare. Se pensiamo di scrivere uno unit test per verificare qualche sottile caso limite in una classe di logica aziendale e ci rendiamo conto che richiede la presenza di risorse esterne come servizi Web o database, qualcosa non va – essenzialmente, stiamo usando una mazza per rompi una noce. E questo significa cattivo design.

Cosa rende un buon test unitario?

Prima di addentrarci nella parte principale di questo tutorial e di scrivere unit test, discutiamo rapidamente le proprietà di un buon unit test. I principi del test unitario richiedono che un buon test sia:

  • Facile da scrivere. Gli sviluppatori in genere scrivono molti test unitari per coprire diversi casi e aspetti del comportamento dell'applicazione, quindi dovrebbe essere facile codificare tutte quelle routine di test senza enormi sforzi.

  • Leggibile. L'intento di un test unitario dovrebbe essere chiaro. Un buon unit test racconta una storia su alcuni aspetti comportamentali della nostra applicazione, quindi dovrebbe essere facile capire quale scenario viene testato e, se il test fallisce, rilevare facilmente come affrontare il problema. Con un buon unit test, possiamo correggere un bug senza effettivamente eseguire il debug del codice!

  • Affidabile. Gli unit test dovrebbero fallire solo se c'è un bug nel sistema sotto test. Sembra abbastanza ovvio, ma i programmatori spesso incontrano problemi quando i loro test falliscono anche quando non sono stati introdotti bug. Ad esempio, i test possono essere superati durante l'esecuzione uno per uno, ma non riuscire quando si esegue l'intera suite di test oppure passare sulla nostra macchina di sviluppo e fallire sul server di integrazione continua. Queste situazioni sono indicative di un difetto di progettazione. I buoni unit test dovrebbero essere riproducibili e indipendenti da fattori esterni come l'ambiente o l'ordine di marcia.

  • Veloce. Gli sviluppatori scrivono unit test in modo che possano eseguirli ripetutamente e verificare che non siano stati introdotti bug. Se gli unit test sono lenti, è più probabile che gli sviluppatori saltino l'esecuzione sui propri computer. Un test lento non farà una differenza significativa; aggiungine altri mille e siamo sicuramente bloccati ad aspettare un po'. Gli unit test lenti possono anche indicare che il sistema sottoposto a test, o il test stesso, interagisce con sistemi esterni, rendendolo dipendente dall'ambiente.

  • Veramente unità, non integrazione. Come abbiamo già discusso, i test unitari e di integrazione hanno scopi diversi. Sia lo unit test che il sistema in prova non devono accedere alle risorse di rete, ai database, al file system, ecc., per eliminare l'influenza di fattori esterni.

Questo è tutto: non ci sono segreti per scrivere unit test . Tuttavia, ci sono alcune tecniche che ci consentono di scrivere codice testabile .

Codice verificabile e non verificabile

Alcuni codici sono scritti in modo tale che sia difficile, o addirittura impossibile, scrivere un buon unit test per esso. Quindi, cosa rende il codice difficile da testare? Esaminiamo alcuni anti-pattern, odori di codice e cattive pratiche che dovremmo evitare quando scriviamo codice testabile.

Avvelenamento della codebase con fattori non deterministici

Cominciamo con un semplice esempio. Immagina di scrivere un programma per un microcontrollore per la casa intelligente e uno dei requisiti è accendere automaticamente la luce nel cortile se viene rilevato un movimento durante la sera o la notte. Siamo partiti dal basso implementando un metodo che restituisce una rappresentazione in stringa dell'ora approssimativa del giorno (“Notte”, “Mattino”, “Pomeriggio” o “Sera”):

 public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }

In sostanza, questo metodo legge l'ora di sistema corrente e restituisce un risultato basato su quel valore. Allora, cosa c'è di sbagliato in questo codice?

Se ci pensiamo dal punto di vista del test di unità, vedremo che non è possibile scrivere un test di unità basato sullo stato appropriato per questo metodo. DateTime.Now è, essenzialmente, un input nascosto, che probabilmente cambierà durante l'esecuzione del programma o tra le esecuzioni di test. Pertanto, le successive chiamate produrranno risultati diversi.

Tale comportamento non deterministico rende impossibile verificare la logica interna del metodo GetTimeOfDay() senza modificare effettivamente la data e l'ora del sistema. Diamo un'occhiata a come dovrebbe essere implementato tale test:

 [TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }

Test come questo violerebbero molte delle regole discusse in precedenza. Sarebbe costoso da scrivere (a causa della logica di installazione e smontaggio non banale), inaffidabile (potrebbe fallire anche se non ci sono bug nel sistema in prova, a causa di problemi di autorizzazione del sistema, ad esempio) e non garantito correre veloce. E, infine, questo test non sarebbe in realtà uno unit test: sarebbe una via di mezzo tra un test di unità e di integrazione, perché pretende di testare un semplice caso limite ma richiede che un ambiente sia impostato in un modo particolare. Il risultato non vale lo sforzo, eh?

Si scopre che tutti questi problemi di verificabilità sono causati GetTimeOfDay() di bassa qualità. Nella sua forma attuale, questo metodo soffre di diversi problemi:

  • È strettamente accoppiato all'origine dati concreta. Non è possibile riutilizzare questo metodo per elaborare la data e l'ora recuperate da altre fonti o passate come argomento; il metodo funziona solo con la data e l'ora della particolare macchina che esegue il codice. L'accoppiamento stretto è la radice principale della maggior parte dei problemi di verificabilità.

  • Viola il principio di responsabilità unica (SRP). Il metodo ha molteplici responsabilità; consuma le informazioni e le elabora. Un altro indicatore di violazione SRP è quando una singola classe o metodo ha più di un motivo per cambiare . Da questo punto di vista, il metodo GetTimeOfDay() può essere modificato a causa di modifiche logiche interne o perché è necessario modificare la data e l'ora.

  • Mente sulle informazioni necessarie per portare a termine il proprio lavoro. Gli sviluppatori devono leggere ogni riga del codice sorgente effettivo per capire quali input nascosti vengono utilizzati e da dove provengono. La firma del metodo da sola non è sufficiente per comprendere il comportamento del metodo.

  • È difficile da prevedere e mantenere. Il comportamento di un metodo che dipende da uno stato globale mutevole non può essere previsto semplicemente leggendo il codice sorgente; è necessario tener conto del suo valore attuale, insieme a tutta la sequenza di eventi che avrebbero potuto modificarlo prima. In un'applicazione del mondo reale, provare a svelare tutta quella roba diventa un vero mal di testa.

Dopo aver esaminato l'API, risolviamo finalmente il problema! Fortunatamente, questo è molto più facile che discutere di tutti i suoi difetti: dobbiamo solo rompere le preoccupazioni strettamente accoppiate.

Correzione dell'API: introduzione di un argomento del metodo

Il modo più ovvio e semplice per correggere l'API è introdurre un argomento del metodo:

 public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }

Ora il metodo richiede che il chiamante fornisca un argomento DateTime , invece di cercare segretamente queste informazioni da solo. Dal punto di vista dei test unitari, questo è fantastico; il metodo è ora deterministico (ovvero, il suo valore restituito dipende completamente dall'input), quindi il test basato sullo stato è facile come passare un valore DateTime e controllare il risultato:

 [TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }

Si noti che questo semplice refactor ha anche risolto tutti i problemi dell'API discussi in precedenza (accoppiamento stretto, violazione SRP, API poco chiara e difficile da capire) introducendo un nesso chiaro tra quali dati dovrebbero essere elaborati e come dovrebbero essere eseguiti.

Eccellente: il metodo è testabile, ma per quanto riguarda i suoi clienti ? Ora è responsabilità del chiamante fornire data e ora al GetTimeOfDay(DateTime dateTime) , il che significa che potrebbero diventare non verificabili se non prestiamo sufficiente attenzione. Diamo un'occhiata a come possiamo affrontarlo.

Correzione dell'API client: inserimento delle dipendenze

Supponiamo di continuare a lavorare sul sistema di casa intelligente e di implementare il seguente client del GetTimeOfDay(DateTime dateTime) , il suddetto codice del microcontrollore per la casa intelligente responsabile dell'accensione o dello spegnimento della luce, in base all'ora del giorno e al rilevamento del movimento :

 public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }

Ahia! Abbiamo lo stesso tipo di problema di input DateTime.Now nascosto: l'unica differenza è che si trova a un livello un po' più alto di astrazione. Per risolvere questo problema, possiamo introdurre un altro argomento, delegando ancora una volta la responsabilità di fornire un valore DateTime al chiamante di un nuovo metodo con firma ActuateLights(bool motionDetected, DateTime dateTime) . Ma, invece di spostare ancora una volta il problema a un livello più alto nello stack di chiamate, impieghiamo un'altra tecnica che ci consentirà di mantenere testabili sia ActuateLights(bool motionDetected) che i suoi client: Inversion of Control o IoC.

L'inversione del controllo è una tecnica semplice, ma estremamente utile, per il disaccoppiamento del codice e in particolare per gli unit test. (Dopo tutto, mantenere le cose liberamente accoppiate è essenziale per poterle analizzare indipendentemente l'una dall'altra.) Il punto chiave dell'IoC è separare il codice decisionale ( quando fare qualcosa) dal codice di azione ( cosa fare quando succede qualcosa ). Questa tecnica aumenta la flessibilità, rende il nostro codice più modulare e riduce l'accoppiamento tra i componenti.

L'inversione del controllo può essere implementata in diversi modi; diamo un'occhiata a un esempio particolare - Iniezione di dipendenze utilizzando un costruttore - e come può aiutare nella creazione di un'API SmartHomeController testabile.

Innanzitutto, creiamo un'interfaccia IDateTimeProvider , contenente una firma del metodo per ottenere una data e un'ora:

 public interface IDateTimeProvider { DateTime GetDateTime(); }

Quindi, fai in modo che SmartHomeController faccia riferimento a un'implementazione di IDateTimeProvider e delegagli la responsabilità di ottenere data e ora:

 public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }

Ora possiamo vedere perché Inversion of Control è così chiamato: il controllo di quale meccanismo utilizzare per leggere la data e l'ora è stato invertito , e ora appartiene al client di SmartHomeController , non a SmartHomeController stesso. Pertanto, l'esecuzione del ActuateLights(bool motionDetected) dipende completamente da due cose che possono essere facilmente gestite dall'esterno: l'argomento motionDetected e un'implementazione concreta di IDateTimeProvider , passata in un costruttore SmartHomeController .

Perché questo è importante per i test unitari? Significa che diverse implementazioni di IDateTimeProvider possono essere usate nel codice di produzione e nel codice di unit test. Nell'ambiente di produzione, verrà iniettata un'implementazione reale (ad esempio, una che legge l'ora effettiva del sistema). Nello unit test, tuttavia, possiamo iniettare un'implementazione "falsa" che restituisce un valore DateTime costante o predefinito adatto per testare il particolare scenario.

Una falsa implementazione di IDateTimeProvider potrebbe assomigliare a questa:

 public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }

Con l'aiuto di questa classe, è possibile isolare SmartHomeController da fattori non deterministici ed eseguire uno unit test basato sullo stato. Verifichiamo che, se è stato rilevato un movimento, il tempo di quel movimento viene registrato nella proprietà LastMotionTime :

 [TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }

Grande! Un test come questo non era possibile prima del refactoring. Ora che abbiamo eliminato i fattori non deterministici e verificato lo scenario basato sullo stato, pensi che SmartHomeController sia completamente testabile?

Avvelenamento del Codebase con effetti collaterali

Nonostante abbiamo risolto i problemi causati dall'input nascosto non deterministico e siamo stati in grado di testare alcune funzionalità, il codice (o, almeno, parte di esso) è ancora non testabile!

Esaminiamo la parte seguente del ActuateLights(bool motionDetected) responsabile dell'accensione o dello spegnimento della luce:

 // If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }

Come possiamo vedere, SmartHomeController delega la responsabilità di accendere o spegnere la luce a un oggetto BackyardLightSwitcher , che implementa un pattern Singleton. Cosa c'è di sbagliato in questo design?

Per testare completamente il ActuateLights(bool motionDetected) , dovremmo eseguire test basati sull'interazione oltre al test basato sullo stato; cioè, dovremmo garantire che i metodi per accendere o spegnere la luce siano chiamati se, e solo se, sono soddisfatte le condizioni appropriate. Sfortunatamente, il design attuale non ci consente di farlo: i TurnOn() e TurnOff() di BackyardLightSwitcher attivano alcuni cambiamenti di stato nel sistema, o, in altre parole, producono effetti collaterali . L'unico modo per verificare che questi metodi siano stati chiamati è controllare se i loro effetti collaterali corrispondenti si sono effettivamente verificati o meno, il che potrebbe essere doloroso.

Supponiamo infatti che il sensore di movimento, la lanterna del cortile e il microcontrollore della casa intelligente siano collegati a una rete Internet of Things e comunichino utilizzando un protocollo wireless. In questo caso, uno unit test può tentare di ricevere e analizzare quel traffico di rete. Oppure, se i componenti hardware sono collegati con un filo, l'unità di test può verificare se la tensione è stata applicata al circuito elettrico appropriato. Oppure, dopotutto, può verificare che la luce sia effettivamente accesa o spenta utilizzando un sensore di luce aggiuntivo.

Come possiamo vedere, i metodi di unit test con effetti collaterali potrebbero essere difficili quanto quelli non deterministici di unit test e potrebbero anche essere impossibili. Qualsiasi tentativo porterà a problemi simili a quelli che abbiamo già visto. Il test risultante sarà difficile da implementare, inaffidabile, potenzialmente lento e non realmente unitario. E, dopo tutto ciò, il lampeggio della luce ogni volta che eseguiamo la test suite alla fine ci farà impazzire!

Ancora una volta, tutti questi problemi di testabilità sono causati dalla cattiva API, non dalla capacità dello sviluppatore di scrivere unit test. Indipendentemente dall'esatta implementazione del controllo della luce, l'API SmartHomeController soffre di questi problemi già familiari:

  • È strettamente legato all'attuazione concreta. L'API si basa sull'istanza concreta e codificata di BackyardLightSwitcher . Non è possibile riutilizzare il ActuateLights(bool motionDetected) per accendere una luce diversa da quella nel cortile.

  • Viola il principio della responsabilità unica. L'API ha due motivi per cambiare: in primo luogo, cambia la logica interna (come scegliere di far accendere la luce solo di notte, ma non di sera) e in secondo luogo, se il meccanismo di accensione della luce viene sostituito con un altro.

  • Mente sulle sue dipendenze. Non c'è modo per gli sviluppatori di sapere che SmartHomeController dipende dal componente BackyardLightSwitcher hardcoded, oltre a scavare nel codice sorgente.

  • È difficile da capire e mantenere. Cosa succede se la luce si rifiuta di accendersi quando le condizioni sono giuste? Potremmo dedicare molto tempo a cercare di riparare lo SmartHomeController inutilmente, solo per renderci conto che il problema è stato causato da un bug nel BackyardLightSwitcher (o, ancora più divertente, da una lampadina bruciata!).

La soluzione dei problemi di testabilità e API di bassa qualità è, non sorprendentemente, rompere i componenti strettamente accoppiati l'uno dall'altro. Come con l'esempio precedente, l'utilizzo di Dependency Injection risolverebbe questi problemi; basta aggiungere una dipendenza ILightSwitcher a SmartHomeController , delegargli la responsabilità di capovolgere l'interruttore della luce e passare un'implementazione ILightSwitcher falsa di solo test che registrerà se i metodi appropriati sono stati chiamati nelle giuste condizioni. Tuttavia, invece di utilizzare nuovamente l'inserimento delle dipendenze, esaminiamo un approccio alternativo interessante per disaccoppiare le responsabilità.

Correzione dell'API: funzioni di ordine superiore

Questo approccio è un'opzione in qualsiasi linguaggio orientato agli oggetti che supporta funzioni di prima classe . Sfruttiamo le caratteristiche funzionali di C# e facciamo in modo che il ActuateLights(bool motionDetected) accetti altri due argomenti: una coppia di delegati Action , che puntano ai metodi che dovrebbero essere chiamati per accendere e spegnere la luce. Questa soluzione converte il metodo in una funzione di ordine superiore :

 public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }

Questa è una soluzione più funzionale rispetto al classico approccio di Dependency Injection orientato agli oggetti che abbiamo visto prima; tuttavia, ci consente di ottenere lo stesso risultato con meno codice e più espressività rispetto a Dependency Injection. Non è più necessario implementare una classe conforme ad un'interfaccia per fornire a SmartHomeController le funzionalità richieste; invece, possiamo semplicemente passare una definizione di funzione. Le funzioni di ordine superiore possono essere considerate un altro modo per implementare l'inversione del controllo.

Ora, per eseguire un test unitario basato sull'interazione del metodo risultante, possiamo passare azioni false facilmente verificabili al suo interno:

 [TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }

Infine, abbiamo reso l'API SmartHomeController completamente testabile e siamo in grado di eseguire unit test sia basati sullo stato che basati sull'interazione per essa. Ancora una volta, si noti che, oltre a una migliore testabilità, l'introduzione di una separazione tra il processo decisionale e il codice di azione ha contribuito a risolvere il problema dell'accoppiamento stretto e ha portato a un'API più pulita e riutilizzabile.

Ora, per ottenere la copertura completa degli unit test, possiamo semplicemente implementare una serie di test dall'aspetto simile per convalidare tutti i casi possibili, non è un grosso problema poiché gli unit test sono ora abbastanza facili da implementare.

Impurità e testabilità

Il non determinismo incontrollato e gli effetti collaterali sono simili nei loro effetti distruttivi sulla base di codice. Se usati con noncuranza, portano a codice ingannevole, difficile da comprendere e mantenere, strettamente accoppiato, non riutilizzabile e non verificabile.

D'altra parte, i metodi deterministici e privi di effetti collaterali sono molto più facili da testare, ragionare e riutilizzare per creare programmi più grandi. In termini di programmazione funzionale, tali metodi sono chiamati funzioni pure . Raramente avremo un problema nell'unità di test di una funzione pura; tutto ciò che dobbiamo fare è passare alcuni argomenti e verificare la correttezza del risultato. Ciò che rende davvero non verificabile il codice sono fattori impuri e codificati che non possono essere sostituiti, sovrascritti o astratti in qualche altro modo.

L'impurità è tossica: se il metodo Foo() dipende dal metodo non deterministico o con effetti collaterali Bar() , allora Foo() diventa anche non deterministico o con effetti collaterali. Alla fine, potremmo finire per avvelenare l'intera codebase. Moltiplica tutti questi problemi per le dimensioni di una complessa applicazione reale e ci ritroveremo ingombrati da una base di codice difficile da mantenere piena di odori, anti-modelli, dipendenze segrete e ogni sorta di cose brutte e spiacevoli.

esempio di unit test: illustrazione

Tuttavia, l'impurità è inevitabile; qualsiasi applicazione reale deve, a un certo punto, leggere e manipolare lo stato interagendo con l'ambiente, i database, i file di configurazione, i servizi Web o altri sistemi esterni. Quindi, invece di mirare a eliminare del tutto l'impurità, è una buona idea limitare questi fattori, evitare che avvelenano la tua base di codice e rompere il più possibile le dipendenze codificate, in modo da essere in grado di analizzare e testare le cose in modo indipendente.

Segnali di avvertimento comuni di codice difficile da testare

Problemi con la scrittura dei test? Il problema non è nella tua suite di test. È nel tuo codice.
Twitta

Infine, esaminiamo alcuni segnali di avviso comuni che indicano che il nostro codice potrebbe essere difficile da testare.

Proprietà statiche e campi

Le proprietà ei campi statici o, semplicemente, lo stato globale, possono complicare la comprensione e la verificabilità del codice, nascondendo le informazioni necessarie affinché un metodo svolga il proprio lavoro, introducendo il non determinismo o promuovendo un uso estensivo degli effetti collaterali. Le funzioni che leggono o modificano lo stato globale mutevole sono intrinsecamente impure.

Ad esempio, è difficile ragionare sul codice seguente, che dipende da una proprietà accessibile a livello globale:

 if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }

Cosa succede se il metodo HeatWater() non viene chiamato quando siamo sicuri che avrebbe dovuto essere? Poiché qualsiasi parte dell'applicazione potrebbe aver modificato il valore CostSavingEnabled , dobbiamo trovare e analizzare tutti i punti che modificano quel valore per scoprire cosa c'è che non va. Inoltre, come abbiamo già visto, non è possibile impostare alcune proprietà statiche a scopo di test (ad es. DateTime.Now o Environment.MachineName ; sono di sola lettura, ma non deterministiche).

D'altra parte, lo stato globale immutabile e deterministico è totalmente OK. In effetti, c'è un nome più familiare per questo: una costante. Valori costanti come Math.PI non introducono alcun non determinismo e, poiché i loro valori non possono essere modificati, non consentono effetti collaterali:

 double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!

Singleton

In sostanza, il modello Singleton è solo un'altra forma dello stato globale. I singleton promuovono API oscure che mentono su dipendenze reali e introducono un accoppiamento inutilmente stretto tra i componenti. Inoltre violano il principio di responsabilità unica perché, oltre ai loro compiti primari, controllano la propria inizializzazione e ciclo di vita.

I singleton possono facilmente rendere gli unit test dipendenti dall'ordine perché mantengono lo stato per tutta la durata dell'intera applicazione o della suite di unit test. Dai un'occhiata al seguente esempio:

 User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }

In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache after each unit test run.

Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.

The new Operator

Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.

For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:

 using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }

However, sometimes new is absolutely harmless: for example, it is OK to create simple entity objects:

 var person = new Person("John", "Doe", new DateTime(1970, 12, 31));

It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack methods were called or not — we just check if the end result is correct:

 string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }

Static Methods

Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.

For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:

 void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }

However, pure static functions are OK: any combination of them will still be a pure function. Per esempio:

 double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }

Benefits of Unit Testing

Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.

As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.