Unit Testing: concetti e soluzioni per .NET Core ed Angular

Unit Testing è il nome dato ad una metodologia di test del software che aiuta a determinare se i moduli di un programma funzionano correttamente.

Data questa definizione, è abbastanza facile capire l’importanza di suddividere ed isolare correttamente il codice in piccole unità, con pochi input e un singolo output, così da ottenere componenti adatti al riutilizzo ed al testing. Nella programmazione ad oggetti (OOP), in cui il codice sorgente del programma è suddiviso in classi, un’unità lo è spesso un metodo di una classe o può anche essere una funzione statica di una classe di supporto.

Strategie di Unit Testing:
STD vs TDD vs BDD

STD (Standard Testing Development), questo approccio prevede che prima venga scritto il codice e poi (forse) i test. In altre parole, il nostro codice sorgente può essere (e quindi solitamente viene) scritto prima (o anche senza) test case.

TDD (Test Driven Development) è più una pratica di programmazione che un approccio di test e può essere un’ottima pratica, almeno per determinati scenari. In breve, uno sviluppatore che adotta la metodologia TDD convertirà tutti i requisiti software in test case specifici, quindi scriverà il nuovo codice (o migliorerà il codice esistente) in modo che i test vengano superati.
La differenza principale tra i STD e TDD è che in TDD i test sono condizioni obbligatorie che dobbiamo soddisfare, mentre nel caso STD sono principalmente la prova del funzionamento del nostro codice esistente.
Di seguito uno schema riassuntivo di quali sono i 3 step che caratterizzano TDD:

BDD (Behavior-Driven Development) è un processo di sviluppo software che condivide lo stesso approccio test-first di TDD, ma concentrando i test su quella che dovrà essere la prospettiva dell’utente finale anziché sull’implementazione. Se vogliamo testare l’implementazione effettiva dei nostri metodi/unità, TDD potrebbe essere la strada giusta da percorrere. Tuttavia, se puntiamo a dei test più orientati all’utilizzo dell’utente finale, il solo approccio TDD potrebbe dare dei falsi positivi, specialmente se il sistema evolve (come spesso fanno i progetti guidati da Agile).

TDD ha lo scopo di imporre agli sviluppatori il controllo sul codice scritto, mentre BDD mira a soddisfare sia lo sviluppatore che l’utente finale.
Pertanto, possiamo concludere che BDD estende TDD e non lo sostituisce.

Tutorial: Unit Testing

Backend – .NET Core

Partendo dal back-end, prenderemo in esame un esempio di come poter realizzare un server sviluppato in .NET Core utilizzando come framework di test xUnit.net.

  1. Creare un progetto di test di tipo Web Application .NET Core utilizzando il template Angular. Usiamo come nome del progetto ad esempio UnitTestSample
  2. Aprire un prompt dei comandi e posizionarsi nella cartella root della solution
  3. Digitare il seguente comando per creare un progetto di test vuoto
    dotnet new xunit -o UnitTestSample.Tests
    In questo modo .NET CLI creerà per noi un nuovo progetto di test e si occuperà di svolgere alcune operazioni di post process dopo la creazione.
  4. Tornare in Visual Studio ed eliminare il file UnitTest1.cs perché andremo poi a creare le classi che serviranno.
  5. Referenziare nel progetto di test creato il pacchetto Moq
    Install-Package Moq
  6. Referenziare nel progetto di test creato il pacchetto Microsoft.EntityFrameworkCore.InMemory
    Install-Package Microsoft.EntityFrameworkCore.InMemory

Moq: è un mocking framework, ovvero è uno strumento che permette di creare oggetti sostitutivi che simulino il comportamento di quelli reali. Il concetto di mocking quindi è strettamente legato allo unit testing.

Microsoft.EntityFrameworkCore.InMemory è un database provider per Entity Framework Core che può essere utilizzato a scopo di test in quanto inizializza una base dati che rimane solamente in memoria, di fatto risulta essere un finto database

Una volta terminata la configurazione dell’ambiente, possiamo procedere con l’implementazione dei test. Come prima cosa, aggiungere al progetto UnitTestSample.Test la dipendenza a UnitTestSample.

Nel mondo Web API, quello che solitamente viene testato sono i metodi esposti dai Controller, ovvero le chiamate che vengono esposte all’esterno. Le API però sfruttano due importanti dipendenze solitamente, che sono l’HttpContext e l’ApplicationDbContext. Supponiamo di voler testare un’API GetCar() che ha come funzione quella di ritornare una città. Per scrivere questo test, creo un nuovo file CarsController_Test.cs all’interno del progetto di Test ed inizializzo la classe nel modo seguente:

public class CarsController_Test
{
	/// <summary>
	/// Test GetCar() method
	/// </summary>
	/// <returns></returns>
	[Fact]
	public async void GetCar()
	{
		#region Arrange
		//todo: define required assets
		#endregion

		#region Act
		//todo: invoke the test
		#endregion

		#region Assert
		//todo: verify that conditions are met
		#endregion
	}
}
  • Arrange: definisce le risorse necessarie per eseguire il test
  • Act: richiama il comportamento del soggetto di test
  • Assert: verifica che le condizioni previste siano soddisfatte valutando il valore di ritorno o misurandolo rispetto ad alcune regole definite dall’utente

Vediamo come popolare le tre region affinchè venga popolato il context EntityFramework con un record di Car, il tutto senza andare però ad utilizzare il database finisco, ma lasciandolo solo in memoria.

public class CarsController_Test
{
	/// <summary>
	/// Test GetCar() method
	/// </summary>
	/// <returns></returns>
	[Fact]
	public async void GetCar()
	{
		#region Arrange
		var options = new DbContextOptionsBuilder<ModelContext>()
			.UseInMemoryDatabase(databaseName: "cars")
			.Options;

		using (var ctx = new ModelContext(options))
		{
			await ctx.Car.AddAsync(new Car
			{
				CarId = 1,
				Brand = "Ferrari",
				Model = "Fxx",
				Cv = 1050
			});
			await ctx.SaveChangesAsync();
		}
		Car existingCar = null;
		Car non_existingCar = null;
		#endregion

		#region Act
		using (var ctx = new ModelContext(options))
		{
			var controller = new CarsController(ctx);
			existingCar = await controller.GetCar(1);
			non_existingCar = await controller.GetCar(2);

		}
		#endregion

		#region Assert
		Assert.True(existingCar != null && non_existingCar == null);
		#endregion
	}
}

Ora è possibile mandare in esecuzione il test appena scritto; ci sono due modi per farlo:

  1. .NET Core CLI: Da riga di comando, posizionarsi nella cartella contenente il progetto di Test appena scritto e digitare il comando dotnet test
  2. Visual Studio Test Explorer: Tramite la finestra Test Explorer di Visual Studio è possibile visualizzare l’elenco dei test scritti, avviarli e consultare l’esito dopo averli eseguiti.

Frontend – Angular

Partendo dagli stessi principi elencati in precedenza, anche per la parte del front-end possiamo pensare di utilizzare esattamente lo stesso tipo di approccio per andare a testare la nostra app.
Gli strumenti da utilizzare per raggiungere l’obiettivo sono:

  • Jasmine: testing framework Javascript che supporta perfettamente l’approccio di tipo BDD.
  • Karma: strumento che permette di creare delle istanze del browser in cui avviare i test scritti con Jasmine e consultarne l’esito
  • Protractor: framework di tipo end-to-end che permette di testare le applicazioni Angular all’interno di un browser, simulando un’interazione simile a quella che avrebbe un utente reale.

Karma

I Karma test vengono scritti sfruttando il framework Jasmine e solitamente sono costruiti utilizzando queste 3 principali API:

  1. describe(): contesto in cui vengono rinchiuse una suite di test
  2. it(): dichiarazione di un singolo test
  3. expect(): il risultato atteso da un test

Queste API sono utilizzabili all’interno dei files *.spec.ts.
Supponiamo di voler testare un componente CarsComponent (definiamo il nome del file del componente con cars.component.ts), quello che ci sarà da fare in primis sarà creare un nuovo file di test cars.component.spec.ts. Uno scheletro “tipo” sarà come segue:

describe('CarsComponent', () => {
    let fixture: ComponentFixture <CarsComponent> ;
    let component: CarsComponent;

    // async beforeEach(): testBed initialization
    beforeEach(async (() => {
        //todo: initialize required providers
        TestBed.configureTestingModule({
            declarations: [CarsComponent],
            imports: [
                BrowserAnimationsModule
            ],
            providers: [
                //todo: reference required providers
            ]
        }).compileComponents();
    }));

    // synchronous beforeEach(): fixtures and components setup
    beforeEach(() => {
        fixture = TestBed.createComponent(CarsComponent);
        component = fixture.componentInstance;
        //todo: configure fixture/component/children/etc.
    });
    //todo: implement some tests
});

Alcune note riguardo questo schema:

  • Tutto il set di test del componente CarsComponent è definito all’interno di un unico describe;
  • La property fixture contiene un’istanza di CarsComponent con il suo stato. Tramite fixture posso interagire con il componente ed i suoi child elements;
  • La property component contiene l’istanza vera e propria del componente
  • Metodo async beforeEach() in cui so che TestBed è stato creato e inizializzato
  • Metodo synchronous beforeEach(), in cui fixture e components sono istanziati e configurati

Una volta definiti questi elementi, si può procedere alla creazione di un mock service; è possibile utilizzare uno strumento messo a disposizione da Jasmine chiamato Spy. Tramite Spy è possibile creare un mock di un oggetto e fare override dei suoi metodi. Nel nostro esempio, andremo a creare un mock service per simulare ciò che il CarService dovrebbe fare, ovvero recuperare le informazioni dal server. Di seguito un esempio di come inizializzare un service tramite Spy

    // Create a mock carService object with a mock 'getData' method
    let carsService = jasmine.createSpyObj <CarsService> ('CarsService', ['getData']);
    // Configure the 'getData' spy method
    carsService.getData.and.returnValue(
        // return an Observable with some test data
        of <Car[]> ([{
                carId: 1,
                brand: 'Ferrari',
                model: 'FXXX',
                cv: 1050
            },
            {
                carId: 2,
                brand: 'Lamborghini',
                model: 'Aventador',
                cv: 770
            }
        ]));

Una volta inizializzato il service, ricordarsi di referenziarlo nella sezione providers di TestBed

TestBed.configureTestingModule({
    declarations: [CarsComponent],
    imports: [
        BrowserAnimationsModule
    ],
    providers: [{
        provide: CarsService,
        useValue: carsService
    }]
}).compileComponents();

Terminata la configurazione di TestBed all’interno del metodo beforeEach asincrono, spostarsi nel beforeEach sincrono ed aggiungere l’istruzione fixture.detectChanges();.

// synchronous beforeEach(): fixtures and components setup
beforeEach(() => {
    fixture = TestBed.createComponent(CarsComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
});

Questo comando solleva il trigger di rilevamento modifiche lato client. In questo modo, avverrà correttamente il popolamento delle parti di codice che sono costruite dinamicamente (ad esempio la tabella che mostra i risultati provenienti dal mock service).
Ultimata questa fase di configurazione dell’ambiente di test, si possono iniziare a scrivere i test veri e propri. Jasmine prevede il comando it() da utilizzare per la definizione del test, mentre il comando expect() viene usato per il check vero e proprio che vogliamo effettuare sul test specifico.
Un semplice esempio: “voglio testare che la tabella che mostra l’elenco dei record di cars contenga almeno una riga“. Ecco l’esempio di come è possibile implementare questa cosa:

it('should contain a table with a list of one or more cars',
    async (() => {
        let table = fixture.nativeElement.querySelector('table.table-striped');
        let tableRows = table.querySelectorAll('tr');
        expect(tableRows.length).toBeGreaterThan(0);
    }));

Come prima cosa assegno una descrizione al test che andrò ad eseguire, dopo di che utilizzo la variabile fixture per interrogare il DOM. Nel caso specifico, mi voglio assicurare che esista almeno una riga nella tabella. Ultimata la definizione dei test, si è pronti per mandarli in esecuzione.
Aprire una finestra di prompt dei comandi e posizionarsi nella cartella ClientApp e lanciare il seguente comando ng test . L’esecuzione di questa istruzione avvierà il Karma test runner, quindi verrà aperta una nuova istanza del browser configurato nel file karma.conf.js e verranno eseguiti in cascata tutti i test definiti per ogni blocco it() dichiarato. Il risultato finale sarà un report simile a questo

Protractor

Protractor è un test framework di tipo end-to-end, quindi di fatto simula a tutti gli effetti le azioni che l’utente dovrebbe compiere sul browser.
Come prima cosa, se il progetto è stato creato direttamente da Visual Studio 2019 utilizzando come template di partenza quello della Webapp ASP.NET Core con Angular, automaticamente verrà creata una cartella /ClientApp/e2e, all’interno della quale ci saranno tutta una serie di files di configurazione di base per utilizzare Protractor.
Partendo da questa preconfigurazione, elencherò passo per passo le operazioni da fare per poter inizializzare ed utilizzare Protractor framework.
Si comincerà con l’installare il framework con le sue dipendenze; da prompt dei comandi, posizionarsi nella cartella ClientApp ed eseguire i seguenti comandi:

npm install -g protractor
webdriver-manager update
npm install jasmine-spec-reporter --save-dev
  • webdriver-manager è uno strumento di supporto per ottenere un’istanza di un server Selenium
  • jasmine-spec-reporter è una libreria che permette di avere in console una visualizzazione più dettagliata degli esiti dei test

A questo punto spostarsi nel file protractor.conf.js ed impostare come parametro di baseUrl l’URL del sito che si vuole andare a testare (impostare l’url della webapp che viene creata da Visual Studio).
Tra i parametri del file protractor.conf.js si evidenzia specs; esso conterrà l’elenco di tutti i file .ts che contengono i test e che si vorranno mandare in esecuzione. Di default Visual Studio propone il parametro “./src/**/*.e2e-spec.ts” così che tutti i files nella cartella e2e/src che terminano con .e2e-spec.ts saranno eseguiti automaticamente.
Fatte queste premesse, spostarsi ora nel file app.e2e-spec.ts per scrivere il primo test. Così come per Karma, anche Protractor si avvale di Jasmine come strumento di scrittura e definizione dei test; quindi il set di test è aperto dal comando describe() seguito poi dalla definizione dei singoli test tramite i metodi it(). Prendiamo questo esempio come riferimento:

describe('App', () => {  
  it('check correct number of rows', async () => {
    await browser.get('/');    
    await browser.waitForAngular();
    let elems = element.all(by.tagName('tbody tr'));    
    expect(elems.count()).toEqual(1);    
  });
});

Notare la sequenza delle operazioni programmate:

  1. L’istanza globale browser è generata da Protractor ed è utilizzata per invocare comandi a livello browser (ad esempio la navigazione tra pagine). Nello specifico di questo esempio, si impone al browser di navigare al baseUrl impostato nel file di configurazione di cui scritto in precedenza
  2. waitForAngular() attende che Angular abbia terminato tutte le chiamate http ed abbia ultimato il rendering a video dei componenti
  3. element.all recupera tutti gli elementi HTML che corrispondono al selector passato
  4. Dato che nella base dati che si sta utilizzando contiene un solo record, mi aspetto che ci sia solamente una riga all’interno della tabella

A questo punto tutto è pronto per poter eseguire il test appena definito. Eseguire questa sequenza di comandi per avviare Protractor nel modo corretto:

  1. Avviare la webapp da Visual Studio
  2. Da prompt dei comandi, posizionarsi nella cartella ClientApp
  3. Eseguire il test appena scritto tramite il comando protractor .\e2e\protractor.conf.js

Se tutto è stato svolto correttamente, comparirà nella console un riepilogo simile al seguente:

E’ possibile andare in debug dei test scritti con Protractor?

Certo che è possibile, però non lo si può fare solamente aggiungendo un classico breakpoint debugger nel codice, ma sono necessari alcuni passaggi per far si che il comando funzioni:

  1. Avviare la webapp da Visual Studio
  2. In una finestra Powershell, posizionarsi nella cartella ClientApp
  3. Avviare Protractor tramite il comando node --inspect-brk $env:APPDATA\npm\node_modules\protractor\bin\protractor .\e2e\protractor.conf.js
  4. Aprire il browser Chrome e digitare nella barra dell’indirizzo chrome://inspect/#devices
  5. Attendere che nell’elenco dei “Remote Targets” compaia l’esecuzione lanciata la punto 3

Conclusioni

Spesso testare il codice è una pratica che viene messa in secondo piano perchè la frenesia di avere risultati porta a sacrificare “ciò che non è necessario”. Testare il codice ha un costo effettivamente sia in termini di tempo di analisi che di implementazione, ma è un investimento per il futuro. Se i test vengono scritti nel modo corretto e funzionale, più il software evolverà e più il tempo speso inizialmente tornerà come tempo risparmiato nel bugfixing. Un altro vantaggio importante che porta lo Unit Testing è l’agevolare l’integrazione di moduli di software scritti da diversi membri del team di sviluppo.
In sostanza: state iniziando un nuovo progetto? Affrontatelo fin da subito con un approccio BDD, è un investimento che verrà sicuramente ripagato nel tempo.

L’esempio presente in questa guida è scaricabile a questo link.

Link utili

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *