.NET Unit Test: spendi in anticipo per salvare in seguito

Pubblicato: 2022-03-11

C'è spesso molta confusione e dubbi riguardo ai test unitari quando ne si discute con le parti interessate e i clienti. Il test unitario a volte suona come il filo interdentale fa a un bambino, "Mi lavo già i denti, perché devo farlo?"

Suggerire unit test spesso suona come una spesa non necessaria per le persone che considerano i loro metodi di test e i test di accettazione degli utenti abbastanza forti.

Ma gli Unit Test sono uno strumento molto potente e sono più semplici di quanto si possa pensare. In questo articolo, daremo un'occhiata allo unit test e quali strumenti sono disponibili in DotNet come Microsoft.VisualStudio.TestTools e Moq .

Cercheremo di costruire una semplice libreria di classi che calcolerà l'ennesimo termine nella sequenza di Fibonacci. Per fare ciò, vorremo creare una classe per il calcolo delle sequenze di Fibonacci che dipende da una classe matematica personalizzata che somma i numeri insieme. Quindi, possiamo utilizzare .NET Testing Framework per garantire che il nostro programma funzioni come previsto.

Che cos'è il test unitario?

Il test unitario scompone il programma nel più piccolo bit di codice, solitamente a livello di funzione, e garantisce che la funzione restituisca il valore che ci si aspetta. Utilizzando un framework di unit test, gli unit test diventano un'entità separata che può quindi eseguire test automatizzati sul programma mentre viene creato.

 [TestClass] public class FibonacciTests { [TestMethod] //Check the first value we calculate public void Fibonacci_GetNthTerm_Input2_AssertResult1() { //Arrange int n = 2; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert Assert.AreEqual(result, 1); } }

Un semplice test unitario che utilizza il test della metodologia Arrange, Act, Assert che la nostra libreria matematica può aggiungere correttamente 2 + 2.

Una volta impostati gli unit test, se viene apportata una modifica al codice, per tenere conto di una condizione aggiuntiva che non era nota quando il programma è stato sviluppato per la prima volta, ad esempio, gli unit test mostreranno se tutti i casi corrispondono ai valori previsti emesso dalla funzione.

Il test unitario non è un test di integrazione. Non è un test end-to-end. Sebbene entrambe siano metodologie potenti, dovrebbero funzionare insieme ai test unitari, non come sostituti.

I vantaggi e lo scopo del test unitario

Il vantaggio più difficile da comprendere dello unit test, ma il più importante, è la possibilità di ripetere il test del codice modificato al volo. Il motivo per cui può essere così difficile da capire è perché così tanti sviluppatori pensano a se stessi: "Non toccherò mai più quella funzione" o "La testerò di nuovo quando avrò finito". E le parti interessate pensano in termini di: "Se quel pezzo è già scritto, perché devo ritestarlo?"

Come qualcuno che è stato su entrambi i lati dello spettro di sviluppo, ho detto entrambe queste cose. Lo sviluppatore dentro di me sa perché dobbiamo ritestarlo.

I cambiamenti che apportiamo quotidianamente possono avere un impatto enorme. Per esempio:

  • Il tuo switch rappresenta correttamente un nuovo valore che hai inserito?
  • Sai quante volte hai usato quell'interruttore?
  • Hai tenuto conto correttamente dei confronti tra stringhe senza distinzione tra maiuscole e minuscole?
  • Stai controllando i valori nulli in modo appropriato?
  • Un'eccezione di lancio viene gestita come previsto?

Il test unitario prende queste domande e le commemora nel codice e in un processo per garantire che queste domande abbiano sempre una risposta. Gli unit test possono essere eseguiti prima di una build per assicurarsi di non aver introdotto nuovi bug. Poiché gli unit test sono progettati per essere atomici, vengono eseguiti molto rapidamente, in genere meno di 10 millisecondi per test. Anche in un'applicazione molto grande, una suite di test completa può essere eseguita in meno di un'ora. Il tuo processo UAT può corrispondere a quello?

Esempio di convenzione di denominazione impostata per cercare facilmente una classe o un metodo all'interno di una classe da testare.
A parte Fibonacci_GetNthTerm_Input2_AssertResult1 che è la prima esecuzione e include il tempo di installazione, tutti i test unitari vengono eseguiti sotto i 5 ms. La mia convenzione di denominazione qui è impostata per cercare facilmente una classe o un metodo all'interno di una classe che voglio testare

Come sviluppatore, però, forse questo suona come più lavoro per te. Sì, stai tranquillo che il codice che stai rilasciando è buono. Ma il test unitario ti offre anche l'opportunità di vedere dove il tuo design è debole. Stai scrivendo gli stessi unit test per due pezzi di codice? Dovrebbero invece essere su un pezzo di codice?

Fare in modo che il tuo codice sia esso stesso testabile per unità è un modo per migliorare il tuo design. E per la maggior parte degli sviluppatori che non hanno mai eseguito il test delle unità o che non impiegano molto tempo per considerare il design prima della codifica, puoi renderti conto di quanto il tuo design migliora rendendolo pronto per il test delle unità.

La tua unità di codice è testabile?

Oltre a DRY, abbiamo anche altre considerazioni.

I tuoi metodi o funzioni stanno cercando di fare troppo?

Se è necessario scrivere unit test eccessivamente complessi che vengono eseguiti più a lungo del previsto, il metodo potrebbe essere troppo complicato e più adatto come metodi multipli.

Stai sfruttando correttamente l'iniezione di dipendenza?

Se il tuo metodo in prova richiede un'altra classe o funzione, la chiamiamo dipendenza. Negli unit test, non ci interessa cosa sta facendo la dipendenza sotto il cofano; ai fini del metodo in prova, è una scatola nera. La dipendenza ha il proprio set di unit test che determineranno se il suo comportamento funziona correttamente.

Come tester, vuoi simulare quella dipendenza e dirgli quali valori restituire in istanze specifiche. Questo ti darà un maggiore controllo sui tuoi casi di test. Per fare ciò, dovrai iniettare una versione fittizia (o come vedremo più avanti, presa in giro) di quella dipendenza.

I tuoi componenti interagiscono tra loro come ti aspetti?

Una volta che hai elaborato le tue dipendenze e la tua iniezione di dipendenze, potresti scoprire di aver introdotto dipendenze cicliche nel tuo codice. Se la Classe A dipende dalla Classe B, che a sua volta dipende dalla Classe A, dovresti riconsiderare il tuo progetto.

La bellezza dell'iniezione di dipendenza

Consideriamo il nostro esempio di Fibonacci. Il tuo capo ti dice di avere una nuova classe che è più efficiente e precisa dell'attuale operatore di aggiunta disponibile in C#.

Sebbene questo particolare esempio non sia molto probabile nel mondo reale, vediamo esempi analoghi in altri componenti, come l'autenticazione, la mappatura degli oggetti e praticamente qualsiasi processo algoritmico. Ai fini di questo articolo, facciamo finta che la nuova funzione di aggiunta del tuo client sia l'ultima e la migliore da quando sono stati inventati i computer.

In quanto tale, il tuo capo ti consegna una libreria black box con una singola classe Math e, in quella classe, una singola funzione Add . È probabile che il tuo lavoro di implementazione di un calcolatore di Fibonacci assomigli a questo:

 public int GetNthTerm(int n) { Math math = new Math(); int nMinusTwoTerm = 1; int nMinusOneTerm = 1; int newTerm = 0; for (int i = 2; i < n; i++) { newTerm = math.Add(nMinusOneTerm, nMinusTwoTerm); nMinusTwoTerm = nMinusOneTerm; nMinusOneTerm = newTerm; } return newTerm; }

Questo non è orrendo. Istanzia una nuova classe di Math e la usi per aggiungere i due termini precedenti per ottenere il successivo. Esegui questo metodo attraverso la tua normale batteria di test, calcolando fino a 100 termini, calcolando il 1000esimo termine, il 10.000esimo termine e così via fino a quando non ti senti soddisfatto che la tua metodologia funziona bene. Poi in futuro, un utente si lamenta del fatto che il termine 501 non funziona come previsto. Passi la serata a guardare il tuo codice e cercare di capire perché questo caso d'angolo non funziona. Cominci a sospettare che l'ultima e migliore lezione di Math non sia così eccezionale come pensa il tuo capo. Ma è una scatola nera e non puoi davvero dimostrarlo: raggiungi un vicolo cieco internamente.

Il problema qui è che la dipendenza Math non viene iniettata nel tuo calcolatore di Fibonacci. Pertanto, nei tuoi test, fai sempre affidamento sui risultati esistenti, non testati e sconosciuti di Math per testare Fibonacci. Se c'è un problema con Math , Fibonacci avrà sempre torto (senza codificare un caso speciale per il termine 501).

L'idea per correggere questo problema è iniettare la classe Math nella calcolatrice di Fibonacci. Ma ancora meglio, è creare un'interfaccia per la classe Math che definisca i metodi pubblici (nel nostro caso, Add ) e implementare l'interfaccia sulla nostra classe Math .

 public interface IMath { int Add(int x, int y); } public class Math : IMath { public int Add(int x, int y) { //super secret implementation here } } }

Invece di iniettare la classe Math in Fibonacci, possiamo iniettare l'interfaccia IMath in Fibonacci. Il vantaggio qui è che potremmo definire la nostra classe OurMath che sappiamo essere accurata e testare la nostra calcolatrice contro quella. Ancora meglio, usando Moq possiamo semplicemente definire cosa restituisce Math.Add . Possiamo definire un numero di somme o possiamo semplicemente dire a Math.Add di restituire x + y.

 private IMath _math; public Fibonacci(IMath math) { _math = math; }

Inietta l'interfaccia IMath nella classe Fibonacci

 //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y);

Utilizzo di Moq per definire cosa restituisce Math.Add .

Ora abbiamo un metodo provato e vero (beh, se quell'operatore + è sbagliato in C# abbiamo problemi più grandi) per aggiungere due numeri. Usando il nostro nuovo Mocked IMath , possiamo codificare uno unit test per il nostro 501° termine e vedere se abbiamo sbagliato la nostra implementazione o se la classe Math personalizzata ha bisogno di un po' più di lavoro.

Non lasciare che un metodo provi a fare troppo

Questo esempio indica anche l'idea di un metodo che fa troppo. Certo, l'addizione è un'operazione abbastanza semplice senza che sia necessario astrarre la sua funzionalità dal nostro metodo GetNthTerm . Ma se l'operazione fosse un po' più complicata? Invece dell'aggiunta, forse era la convalida del modello, la chiamata a una fabbrica per ottenere un oggetto su cui operare o la raccolta di dati aggiuntivi necessari da un repository.

La maggior parte degli sviluppatori cercherà di attenersi all'idea che un metodo abbia uno scopo. Nello unit test, cerchiamo di attenerci al principio che gli unit test dovrebbero essere applicati ai metodi atomici e introducendo troppe operazioni in un metodo lo rendiamo non verificabile. Spesso possiamo creare un problema in cui dobbiamo scrivere così tanti test per testare correttamente la nostra funzione.

Ogni parametro che aggiungiamo a un metodo aumenta esponenzialmente il numero di test che dobbiamo scrivere in base alla complessità del parametro. Se aggiungi un valore booleano alla tua logica, devi raddoppiare il numero di test da scrivere poiché ora devi controllare i casi vero e falso insieme ai test correnti. Nel caso della validazione del modello, la complessità dei nostri test unitari può aumentare molto rapidamente.

Diagramma dei test aumentati necessari quando un booleano viene aggiunto alla logica.

Siamo tutti colpevoli di aggiungere un piccolo extra a un metodo. Ma questi metodi più grandi e complessi creano la necessità di troppi test unitari. E diventa subito evidente quando si scrivono gli unit test che il metodo sta cercando di fare troppo. Se ritieni di provare a testare troppi risultati possibili dai tuoi parametri di input, considera il fatto che il tuo metodo deve essere suddiviso in una serie di più piccoli.

Non ripetere te stesso

Uno dei nostri inquilini preferiti della programmazione. Questo dovrebbe essere abbastanza semplice. Se ti ritrovi a scrivere gli stessi test più di una volta, hai introdotto il codice più di una volta. Potrebbe essere utile eseguire il refactoring di quel lavoro in una classe comune accessibile a entrambe le istanze in cui stai tentando di usarlo.

Quali strumenti di unit test sono disponibili?

DotNet ci offre una piattaforma di unit test molto potente pronta all'uso. Usando questo, puoi implementare quella che è nota come la metodologia Arrange, Act, Assert. Organizza le tue considerazioni iniziali, agisci in base a quelle condizioni con il tuo metodo sotto test, quindi affermi che è successo qualcosa. Puoi affermare qualsiasi cosa, rendendo questo strumento ancora più potente. Puoi affermare che un metodo è stato chiamato un numero specifico di volte, che il metodo ha restituito un valore specifico, che è stato generato un particolare tipo di eccezione o qualsiasi altra cosa tu possa pensare. Per coloro che cercano un framework più avanzato, NUnit e la sua controparte Java JUnit sono opzioni praticabili.

 [TestMethod] //Test To Verify Add Never Called on the First Term public void Fibonacci_GetNthTerm_Input0_AssertAddNeverCalled() { //Arrange int n = 0; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Never); }

Testare che il nostro metodo di Fibonacci gestisce i numeri negativi generando un'eccezione. Gli unit test possono verificare che l'eccezione sia stata generata.

Per gestire l'iniezione delle dipendenze, sulla piattaforma DotNet esistono sia Ninject che Unity. C'è pochissima differenza tra i due e diventa una questione di se si desidera gestire le configurazioni con Fluent Syntax o XML Configuration.

Per simulare le dipendenze, consiglio Moq. Moq può essere difficile da mettere in pratica, ma l'essenza è che crei una versione derisa delle tue dipendenze. Quindi, dici alla dipendenza cosa restituire in condizioni specifiche. Ad esempio, se avessi un metodo chiamato Square(int x) che ha quadrato l'intero, potresti dirlo quando x = 2, restituire 4. Puoi anche dirgli di restituire x^2 per qualsiasi numero intero. Oppure potresti dirgli di restituire 5 quando x = 2. Perché dovresti eseguire l'ultimo caso? Nel caso in cui il metodo nel ruolo del test sia quello di convalidare la risposta dalla dipendenza, potresti voler forzare la restituzione di risposte non valide per assicurarti di rilevare correttamente il bug.

 [TestMethod] //Test To Verify Add Called Three times on the fifth Term public void Fibonacci_GetNthTerm_Input4_AssertAddCalledThreeTimes() { //Arrange int n = 4; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(3)); }

Utilizzo di Moq per dire all'interfaccia IMath derisa come gestire l' Add in fase di test. Puoi impostare casi espliciti con It.Is o un intervallo con It.IsInRange .

Framework di unit test per DotNet

Framework di test unitari Microsoft

Microsoft Unit Testing Framework è la soluzione di unit test predefinita di Microsoft e inclusa in Visual Studio. Poiché viene fornito con VS, si integra perfettamente con esso. Quando inizi un progetto, Visual Studio ti chiederà se desideri creare una libreria di unit test accanto all'applicazione.

Il Microsoft Unit Testing Framework include anche una serie di strumenti per aiutarti ad analizzare meglio le tue procedure di test. Inoltre, poiché è di proprietà e scritto da Microsoft, c'è una sensazione di stabilità nella sua esistenza in futuro.

Ma quando lavori con gli strumenti Microsoft, ottieni quello che ti danno. Il Microsoft Unit Testing Framework può essere complicato da integrare.

NUnità

Il più grande vantaggio per me nell'utilizzo di NUnit sono i test parametrizzati. Nel nostro esempio di Fibonacci sopra, possiamo inserire una serie di casi di test e garantire che i risultati siano veri. E nel caso del nostro problema 501, possiamo sempre aggiungere un nuovo set di parametri per garantire che il test venga sempre eseguito senza la necessità di un nuovo metodo di test.

Il principale svantaggio di NUnit è l'integrazione in Visual Studio. Manca i campanelli e i fischietti forniti con la versione Microsoft e significa che dovrai scaricare il tuo set di strumenti.

xUnit.Net

xUnit è molto popolare in C# perché si integra perfettamente con l'ecosistema .NET esistente. Nuget ha molte estensioni di xUnit disponibili. Si integra bene anche con Team Foundation Server, anche se non sono sicuro di quanti sviluppatori .NET utilizzino ancora TFS su varie implementazioni Git.

Sul lato negativo, molti utenti si lamentano del fatto che la documentazione di xUnit sia un po' carente. Per i nuovi utenti di unit test, questo può causare un enorme mal di testa. Inoltre, l'estensibilità e l'adattabilità di xUnit rendono anche la curva di apprendimento un po' più ripida rispetto a NUnit o allo Unit Testing Framework di Microsoft.

Progettazione/sviluppo basato su test

Il design/sviluppo basato su test (TDD) è un argomento un po' più avanzato che merita un proprio post. Tuttavia, volevo fornire un'introduzione.

L'idea è di iniziare con i tuoi unit test e dire ai tuoi unit test cosa è corretto. Quindi, puoi scrivere il tuo codice attorno a quei test. In teoria, il concetto sembra semplice, ma in pratica è molto difficile allenare il cervello a pensare all'indietro sull'applicazione. Ma l'approccio ha il vantaggio intrinseco di non dover scrivere i tuoi unit test dopo il fatto. Ciò porta a meno refactoring, riscrittura e confusione di classe.

TDD è stato un po' una parola d'ordine negli ultimi anni, ma l'adozione è stata lenta. La sua natura concettuale crea confusione per le parti interessate, il che rende difficile l'approvazione. Ma come sviluppatore, ti incoraggio a scrivere anche una piccola applicazione usando l'approccio TDD per abituarti al processo.

Perché non puoi avere troppi test unitari

Il test unitario è uno degli strumenti di test più potenti a disposizione degli sviluppatori. Non è in alcun modo sufficiente per un test completo dell'applicazione, ma i suoi vantaggi nel test di regressione, nella progettazione del codice e nella documentazione dello scopo non hanno eguali.

Non c'è niente come scrivere troppi test unitari. Ogni caso limite può proporre grossi problemi nel tuo software. La commemorazione dei bug trovati come unit test può garantire che tali bug non trovino modi per insinuarsi nuovamente nel software durante le successive modifiche al codice. Sebbene tu possa aggiungere il 10-20% al budget iniziale del tuo progetto, potresti risparmiare molto di più in formazione, correzioni di bug e documentazione.

Puoi trovare il repository Bitbucket utilizzato in questo articolo qui.