Nessun risultato. Prova con un altro termine.
Guide
Notizie
Software
Tutorial

Testing di Web Form con NUnit e Rhino.mock

Creare pagine "testabili" creando un proxy per lo strato di presentazione
Creare pagine "testabili" creando un proxy per lo strato di presentazione
Link copiato negli appunti

Sebbene ASP.NET non sia l'ambiente più "test friendly" in circolazione, è comunque possibile, con qualche accorgimento, strutturare le proprie pagine con la possibilità di eseguire test sulla logica di interfaccia. Naturalmente un approccio migliore si ha con l'utilizzo di tecnologie nate con lo unit testing in mente come ASP.NET MVC, ma anche in web.forms si può ottenere una buona "testabilità" seguendo alcune linee guida base.

Nell'esempio che proponiamo, abbiamo inserito la pagina default.aspx, realizzata come classica pagina master detail in cui l'utente può digitare l'inizio di un codice cliente e premere un bottone di ricerca, il sistema mostra la lista dei clienti il cui codice inizia con i caratteri richiesti ed è poi possibile selezionare un singolo cliente per visualizzarne gli ordini. Per rendere il tutto più interessante l'accesso ai dati è stato effettuato con Entity Framework e la pagina è stata scritta senza nemmeno una riga di codice grazie agli EntityDataSource. Le web form sono infatti insuperabili nella realizzazione di pagine con logiche standard, dove molto spesso gli oggetti base permettono di operare solamente tramite designer. Purtroppo però le situazioni reali sono normalmente più complesse, l'approccio "designer only" non è sufficiente e lo sviluppatore è obbligato a scrivere codice per gestire la logica di pagina.

Il cliente, dopo avere osservato la pagina, richiede un miglioramento: dato che l'utente spesso conosce a memoria il codice del cliente di cui vuole vedere gli ordini, digita nella textbox di ricerca il codice completo, così facendo un unico cliente soddisfa il filtro, ma bisogna cliccare nuovamente su di esso per visualizzarne gli ordini. La funzionalità della pagina può essere migliorata aggiungendo una condizione "se la ricerca produce un solo risultato, visualizzare subito gli ordini di quel cliente".

Un'implementazione funzionante è nella pagina BetterPage.aspx, ma in questo caso tutta la logica di interfaccia è purtroppo contenuta nel code behind. Il funzionamento è semplice, si tratta di effettuare la query con LINQ to Entity, controllare il numero di risultati e gestire la visibilità delle gridView in maniera coerente. In situazioni reali le logiche di interfaccia sono assai più complesse ed avere la possibilità di scrivere test per verificarne la correttezza è fondamentale, soprattutto in ambienti dove le richieste del cliente cambiano rapidamente e si vuole evitare che le modifiche vadano a corrompere funzionalità già implementate.

MVC

La soluzione migliore è utilizzare un framework MVC o similare, che permette di mantenere la logica separata dalla rappresentazione visuale. Se non si struttura la propria applicazione in modo corretto, l'unico modo per effettuare un test è infatti interagire direttamente con il browser, soluzione che porta a test fragili, difficili da scrivere e da leggere.

Nel caso di un sito internet infatti il test dovrebbe presupporre che l'applicazione sia stata raggiungibile su un'URL conosciuto ed è poi necessario utilizzare un framework che permetta l'interazione con un browser per simulare le azioni dell'utente. Dato che i test debbono essere veloci, semplici da eseguire e soprattutto ripetibili, si preferisce avere una struttura in cui i test possano essere scritti non interagendo con i controlli visuali.

Come noto, il pattern MVC è basato su tre componenti principali: View, che realizza l'interfaccia utente; Model, che rapprensenta il dominio (tipicamente i dati) e ne gestisce la logica; Controller, che si occupa del flusso dell'applicazione, di preparare i dati per la visualizzazione e di elaborare le richieste dell'utente, pilotando, in effetti gli altri due componenti View e Model;.

Figura 1. Pattern MVC
Pattern MVC

Le Web Form non sono state concepite per supportare MVC e quindi la soluzione migliore consisterebbe nell'adottare ASP.NET MVC, Monorail o qualsivoglia framework basato nativamente sul concetto di MVC. In situazioni reali però spesso si lavora su un progetto già iniziato o le specifiche obbligano all'uso di winform impedendo l'adozione di soluzioni alternative. Anche se ci si trova in questo scenario non è il caso di disperarsi, perché è comunque possibile architettare le proprie pagine simulando la struttura MVC.

Il primo passo è creare una interfaccia con cui il controller interagisce con la view. In questo caso il termine interfaccia si riferisce ad una Interface di C# e non al concetto di interfaccia visuale. Ecco qui la sua definizione:

public interface IBetterPage
{
  void SetCustomerList(List<Customers> customers);
  void SetMessage(String message);
  void SetOrdersList(List<Orders> orders);
}

Lo scopo è far sì che il controller non dipenda da nessun elemento visuale, ma possa accedervi tramite IBetterPage. In questo modo si può effettuare un test del controller passando una interfaccia fittizia, rimuovendo cosi la necessità di interagire direttamente con gli elementi visuali (il browser). La regola per determinare le operazioni da inserire nella IBetterPage è: "evitare che l'implementazione fisica della View contenga logica".

Nel caso in esame si include una funzione per ogni dato che deve essere visualizzato all'utente: SetCustomerList, SetMessage e SetOrdersList. In generale la reale implementazione della vista non deve contenere nemmeno un if, in questo modo non è necessario sottoporla a test perché la sua correttezza è implicita. L'implementazione di questa vista è contenuta nella BetterPageMVC.aspx e come si può immaginare il codice è veramente minimale.

public void SetCustomerList(List<Customers> customers)
{
	GridView1.DataSource = customers;
	GridView1.DataBind();
}

public void SetMessage(string message)
{
	lblResult.Text = message;
}

public void SetOrdersList(List<Orders> orders)
{
	GridView2.DataSource = orders;
	GridView2.DataBind();
}

Ogni funzione non fa altro che prendere i dati passati dal controller e mostrarli all'utente grazie al binding, lo scopo è, come detto precedentemente, scrivere nella vista codice cosi banale da non richiedere alcun test. Nell'intestazione della pagina vi è l'acquisizione del controller.

private BetterPageController controller;
private BetterPageController Controller
{
	get
	{
		return controller ?? (
			controller = IoC.Resolve<BetterPageController>("View", this));
	}
}

In questo caso la proprietà Controller utilizza il Lazy Load per creare il controller solamente se richiesto. La creazione è inoltre fatta con un motore di Inversione di Controllo, in modo da non legare la vista a nessun controller concreto.

L'aspetto più importante è che la pagina passa se stessa al controller come vista, in questo modo il controller interagirà con la pagina tramite IBetterPage ignorando l'effettiva implementazione.

L'ultima operazione che deve essere svolta dalla vista è intercettare i comandi dell'utente e passarli al controller. Questo aspetto snatura un poco il pattern MVC, perché i comandi dovrebbero essere inviati al controller che, in base alla logica, decide la vista da utilizzare.

Con le Web Form questa struttura non è realizzabile in modo facile, per cui si può accettare che sia la view ad intercettare i comandi utente e passarli in maniera trasparente al controller. D'altra parte un pattern non ha una sola ed unica implementazione ed esistono diverse versioni di MVC, si può quindi vivere tranquilli anche usando una versione poco "canonica", se questa migliora la struttura e la "testabilità" delle nostre pagine. L'intercettazione delle azioni utente è fatta quindi con gli handler standard dei controlli ASP.NET

protected void btnSearch_Click(object sender, EventArgs e)
{
	Controller.Search(txtSearch.Text);
}

protected void Selected(object sender, EventArgs e)
{
	Controller.SelectCustomer((String) GridView1.SelectedValue);
}

L'aspetto fondamentale è che questi handler passino i parametri al controller senza effettuare validazione o altre operazioni. Lo scopo è infatti mantenere tutta la logica di interfaccia nel controller in modo da rendere superfluo lo scrivere test per la vista.

Test Double

L'ultimo passo è scrivere i test per verificare le logiche di pagina, ora contenute nel controller. Il primo test dovrà verificare che, se si effettua la ricerca "alfk", vengano direttamente mostrati gli ordini di alfki, l'unico cliente che soddisfa il filtro.

Prima di mostrare il codice è necessario introdurre il concetto di Test Double, fondamentale nel mondo dello unit testing. Un test double è un componente fittizio usato in un test il cui scopo è simulare un componente reale.

Nel pattern classico MVC si hanno infatti tre componenti, la vista il controller ed il modello e quando si vuole esercitare il controller non si può utilizzare la vista web form realizzata precedentemente, perché altrimenti si torna al problema principale di dover interagire con il browser.

Figura 2. Inserire "test double" per effettuare i test
Interporre un

La soluzione è sostituire alla vista reale (web form) un altro oggetto che implementi l'interfaccia IBetterPageView come rappresentato in figura 2, dove la vista è rappresentata in maniera traslucida, ad indicare che non è l'implementazione reale, ma solamente un oggetto usato per il test, detto appunto Test Double. Concettualmente si possono individuare quattro distinte tipologie di Test Double:

  • Test Stub: serve a fornire i dati di ingresso ad un sistema ad esempio al posto di un database
  • Test Dummy: non fa nulla, serve solamente per permettere al test di essere eseguito (Es. DummyLogger)
  • Test Spy e Mock Object: forniscono dati in ingresso e sono in grado di impostare delle condizioni su come l'oggetto viene utilizzato. Ad esempio "accertati che venga chiamata la funzione X con il parametro Y"
  • Fake Object: mima le reali funzionalità di un sistema in maniera ridotta (Es. Database in memoria)

Utilizzare un Test Double come View

Per il nostro test la view deve essere sostituita da un mock, che permetterà di verificare che le funzioni della IBetterPage siano chiamate nel modo desiderato. Per il test richiesto si vuole infatti verificare che cercando la stringa "alfk" siano soddisfatti i seguenti requisiti:

  1. venga chiamato il metodo SetCustomers con un valore null, perché dato che c'è un solo cliente la lista clienti non deve essere mostrata all'utente
  2. venga chiamato il metodo SetOrders con un valore non nullo, ovvero vengano mostrati gli ordini di ALFKI
  3. venga chiamato il metodo SetMessage con un valore nullo, dato che è stato trovato un solo risultato

Grazie a Rhino.Mocks scrivere questo test è semplicissimo, in primo luogo dobbiamo inserire un prologo ed un epilogo per ogni test.

[SetUp]
public void SetUp()
{
	mrepo = new MockRepository();
}

[TearDown]
public void TearDown()
{
	mrepo.VerifyAll();
}

Prima di ogni test si crea un oggetto MockRepository, che costituisce il punto di ingresso per la creazione di oggetti mock, alla fine di ogni test si verificheranno tutte le condizioni imposte grazie al metodo VerifyAll(). Finalmente è arrivato il momento di mostrare il codice completo del test.

[Test]
public void TestOneCustomerShowOrders()
{
	IBetterPage view = mrepo.CreateMock();
	Expect.Call(() => view.SetOrdersList(null))
		.Constraints(RhinoIs.NotNull());
	Expect.Call(() => view.SetMessage(String.Empty));
	Expect.Call(() => view.SetCustomerList(null));
	mrepo.ReplayAll();

	BetterPageController sut = new BetterPageController();
	sut.View = view;
	sut.Search("ALFK");
}

Nella prima riga viene creato un mock per l'interfaccia IBetterPage; l'oggetto restituito implementa a tutti gli effetti IBetterPage ed internamente ha una serie di strutture atte a registrare le chiamate e verificare le nostre aspettative. Appena creato, un mock è in stato di registrazione, ovvero è pronto ad accettare le nostre richieste di verifica.

Nella riga successiva, la sintassi Expect.Call permette di indicare al mock che ci si aspetta una chiamata al metodo SetOrdersList con un criterio sugli argomenti di tipo RhinoIs.NotNull. Il risultato è semplicemente richiedere a Rhino.Mocks di verificare che venga chiamata la funzione SetOrdersList con un parametro non nullo.

Le successive due righe impongono di verificare la chiamata alla funzione SetMessage con parametro stringa vuota e SetCustomerList con un parametro nullo. Se non si impongono criteri particolari con la funzione Constraint, Rhino.Mock imposta un semplice controllo di eguaglianza con i parametri utilizzati. Quando si è terminato di indicare le aspettative, basta invocare ReplayAll() per indicare la fine della fase di registrazione e l'inizio della fase di replay. L'ultima operazione rimasta è creare un controller, impostare come View l'oggetto mock appena preparato ed invocare l'operazione Search.

Mock del modello

Il test che abbiamo creato nella prima parte dell'articolo ha un grandissimo difetto, il controller interagisce direttamente con il modello reale e quindi tutto il test è basato sui dati presenti nel database NorthWind. Questo aspetto rende il test fragile: ad esempio se un precedente test inserisce un nuovo cliente con id pari ad ALFKO il test fallisce perché ora ci sono due clienti che soddisfano il filtro. In questo caso abbiamo un test che fallisce, ma a causa di un altro precedentemente eseguito!

Questo scenario è da evitare perché vanifica l'utilità dei test, dato che è molto difficile capire se un test fallisce perché realmente il componente ha un errore o perché le condizioni al contorno sono cambiate.

Figura 3. Esercitare un componente alla volta
Esercitare un componente alla volta

Affinché un test sia robusto è necessario esercitare un solo componente alla volta, quindi si deve astrarre anche il modello con una interfaccia per poterlo sostituire con un test double. In questo caso abbiamo necessità di un TestStub, ovvero di un componente che fornisca dati fittizi predeterminati dal test stesso.

Figura 4. Class diargram dell'astrazione
Class diargram dell'astrazione

In figura viene rappresentato il grafico delle entità in gioco con le relative interfacce. L'aspetto importante da notare è che BetterPageController2 ha un costruttore che accetta la vista e il modello da usare, sarà il motore di inversione di controllo ad occuparsi di assemblare il tutto. Grazie a questa ulteriore astrazione si può ora scrivere il seguente test.

IBetterPage view = mrepo.CreateMock<IBetterPage>();
List<Orders> orderList = new List<Orders>();
List<Customers> customerList = new List<Customers>();
Customers retCustomer = new Customers() {CustomerID = "ALFKU"};

customerList.Add(retCustomer);

Expect.Call(() => view.SetOrdersList(orderList));
Expect.Call(() => view.SetMessage(String.Empty));
Expect.Call(() => view.SetCustomerList(null));

ICustomerModel model = mrepo.DynamicMock<ICustomerModel>();

Expect.Call(model.Search("ALFK")).Return(customerList);
Expect.Call(model.GetOrdersFromCustomer("ALFKU")).Return(orderList);

mrepo.ReplayAll();

BetterPageController2 sut = new BetterPageController2(view, model);
sut.Search("ALFK");

Questo test è decisamente più lungo del precedente, ma più robusto e veloce. Nelle righe 2-5 vengono create le due liste che dovranno essere tornate dal modello. Dato che si vuole verificare la logica «se un solo cliente soddisfa il filtro si mostri subito la sua lista degli ordini» la lista dei clienti deve contenere un solo oggetto con un id fittizio ALFKU.

Le tre righe successive sono eguali al test precedente, con la sola eccezione che, in questo caso, per la view ci si attende una chiamata al metodo SetOrderList e la lista passata deve essere la stessa creata in precedenza.

Di seguito viene creato il TestStub del modello, in questo caso si verifica che venga chiamata la funzione Search passando il valore ALFK e si chiede al TestStub di ritornare la lista costruita in precedenza con il solo cliente ALFKU. Successivamente si imposta il controllo sulla invocazione della funzione GetOrdersFromCustomer ed in questo caso l'id del cliente deve essere quello dell'unico componente tornato ovvero ALFKU e si torna la lista di ordini creata precedentemente.

Il codice da scrivere è sicuramente più lungo rispetto al caso precedente, ma in questo modo il test è indipendente dal database e quindi più robusto e veloce.

Rifattorizzare il test

I test sono "first class code" ovvero si deve dedicare alla loro scrittura la stessa cura usata per il codice di produzione. L'esempio precedente può infatti essere leggermente modificato con una tecnica chiamata "Test Specific Subclass" in cui il controller viene ereditato da una classe specifica per il test chiamata TSSBetterPageController2.

Grazie a questa separazione il test precedente diviene più semplice

String UniqueCustomerId = "ALFKU";
TSSBetterPageController2 sut = CreateMockedController();
List orderList = new List();
List customerList = CreateCustomerListByIdList(UniqueCustomerId);
sut.SetViewExpectation(String.Empty, null, orderList);
sut.SetModelSearch(UniqueCustomerId, customerList);
sut.SetModelGetOrders(UniqueCustomerId, orderList);
mrepo.ReplayAll();
sut.Search(UniqueCustomerId);

In questo caso la creazione del controllore viene delegata ad una funzione factory CreateMockedController che si occupa di creare il controller ed impostare i mock di Vista e Modello.

Successivamente si creano le liste da tornare, anche qui con un metodo factory che permette di creare una lista con un solo cliente specificando un solo l'id. Infine le expectation vengono impostate da specifici metodi della Test Specific Subclass.

Il risultato è esattamente lo stesso del caso precedente, ma il codice è più leggibile e le funzionalità base possono essere riutilizzate per scrivere altri test. Ecco come verificare che, quando la ricerca torna due clienti, venga impostato un messaggio corretto, resettata la lista degli ordini e mostrata la lista dei clienti.

TSSBetterPageController2 sut = CreateMockedController();
List customerList = CreateCustomerListByIdList(Generate.AString(), Generate.AString());
sut.SetViewExpectation("Trovati 2 elementi", customerList, null);
sut.SetModelSearch(null, customerList);
mrepo.ReplayAll();
sut.Search(null); 

In questo caso si verifica che, se la stringa ha un match con due elementi, la stringa di messaggio deve essere impostata con il valore "Trovati 2 elementi" e le due liste passate alla vista sono la lista clienti e null per la lista ordini in modo da cancellare ogni lista di ordini precedentemente visualizzata.

Conclusioni

Per rendere una applicazione Web Form verificabile da Unit Test è necessario operare una separazione completa della logica di interfaccia dalla rappresentazione visuale. Come spiegato in questo articolo, si può raggiungere un buon risultato, astraendo le funzionalità visuali con una interfaccia che rappresenta una generica View in grado di far interagire l'utente con i dati. La logica viene spostata in un controller esterno al progetto, che comunica con la vista solo tramite l'interfaccia e quindi è fortemente "testabile" grazie all'uso di Test Doubles per simulare vista e modello.

Riferimenti

Ti consigliamo anche