Questo è un estratto dall'eBook Testing Unit Affinity, di Marc Clifton, gentilmente fornito da Syncfusion.
Il test unitario è tutto per dimostrare la correttezza. Per dimostrare che qualcosa sta funzionando correttamente, devi prima capire cosa sono entrambi unità e a test in realtà sono prima di poter esplorare ciò che è dimostrabile nelle capacità del test unitario.
Nel contesto del test unitario, un'unità ha diverse caratteristiche.
Un'unità pura è il metodo più semplice e ideale per scrivere un test unitario. Un'unità pura ha diverse caratteristiche che facilitano il test facile.
Un'unità dovrebbe (idealmente) non chiamare altri metodi
Per quanto riguarda i test unitari, un'unità deve essere innanzitutto un metodo che fa qualcosa senza chiamare altri metodi. Esempi di queste unità pure possono essere trovati nel Stringa
e Matematica
Classi: la maggior parte delle operazioni eseguite non si basa su nessun altro metodo. Ad esempio, il seguente codice (tratto da qualcosa che l'autore ha scritto)
public void SelectedMasters () string currentEntity = dgvModel.DataMember; string navToEntity = cbMasterTables.SelectedItem.ToString (); DataGridViewSelectedRowCollection selectedRows = dgvModel.SelectedRows; Qualificatore StringBuilder = BuildQualifier (selectedRows); UpdateGrid (navToEntity); SetRowFilter (navToEntity, qualifier.ToString ()); ShowNavigateToMaster (navToEntity, qualifier.ToString ());
non dovrebbe essere considerato un'unità per tre motivi:
La prima ragione evidenzia un problema sottile: le proprietà dovrebbero essere considerate chiamate di metodo. In realtà, sono nell'implementazione sottostante. Se il tuo metodo utilizza le proprietà di altre classi, questo è un tipo di chiamata al metodo e dovrebbe essere considerato attentamente quando si scrive un'unità adeguata.
Realisticamente, questo non è sempre possibile. Abbastanza spesso, è necessaria una chiamata al framework o qualche altra API per far funzionare correttamente l'unità. Tuttavia, queste chiamate dovrebbero essere ispezionate per determinare se il metodo potrebbe essere migliorato per creare un'unità pura, ad esempio estraendo le chiamate in un metodo più elevato e passando i risultati delle chiamate come parametro all'unità.
Un corollario di "un'unità non dovrebbe chiamare altri metodi" è che un'unità è un metodo che fa una cosa e una cosa sola Spesso altri metodi sono chiamati per fare più di una cosa-un'abilità preziosa per sapere quando qualcosa in realtà consiste in diverse sottoattività, anche se può essere descritta come un'attività di alto livello, che lo rende simile a una singola attività!
Il seguente codice potrebbe sembrare un'unità ragionevole che fa una cosa: inserisce un nome nel database.
public int Insert (Persona person) DbProviderFactory factory = SqlClientFactory.Instance; using (DbConnection connection = factory.CreateConnection ()) connection.ConnectionString = "Server = localhost; Database = myDataBase; Trusted_Connection = True;"; connection.Open (); using (DbCommand command = connection.CreateCommand ()) command.CommandText = "inserisci nei valori PERSON (ID, NAME) (@Id, @Name)"; command.CommandType = CommandType.Text; DbParameter id = command.CreateParameter (); id.ParameterName = "@Id"; id.DbType = DbType.Int32; id.Value = person.Id; DbParameter name = command.CreateParameter (); name.ParameterName = "@Name"; nome.DbType = DbType.String; name.Size = 50; name.Value = person.Name; command.Parameters.AddRange (new DbParameter [] id, name); int rowsAffected = command.ExecuteNonQuery (); return rowsAffected;
Tuttavia, questo codice sta effettivamente facendo diverse cose:
SqlClient
istanza del fornitore di fabbrica.Ci sono una varietà di problemi con questo codice che lo squalificano dall'essere un'unità e rendono difficile ridurlo in unità di base. Un modo migliore per scrivere questo codice potrebbe essere simile a questo:
public int RefactoredInsert (Persona person) DbProviderFactory factory = SqlClientFactory.Instance; using (DbConnection conn = OpenConnection (factory, "Server = localhost; Database = myDataBase; Trusted_Connection = True;")) utilizzando (DbCommand cmd = CreateTextCommand (conn, "inserisci in PERSON (ID, NAME) valori (@Id, @ Nome) ")) AddParameter (cmd," @Id ", person.Id); AddParameter (cmd, "@Name", 50, person.Name); int rowsAffected = cmd.ExecuteNonQuery (); return rowsAffected; DbConnection OpenConnection protetto (DbProviderFactory factory, string connectString) DbConnection conn = factory.CreateConnection (); conn.ConnectionString = connectString; conn.Open (); return conn; DbCommand protected CreateTextCommand (DbConnection conn, string cmdText) DbCommand cmd = conn.CreateCommand (); cmd.CommandText = cmdText; cmd.CommandType = CommandType.Text; ritorno cmd; AddParameter void protetto (DbCommand cmd, string paramName, int paramValue) DbParameter param = cmd.CreateParameter (); param.ParameterName = paramName; param.DbType = DbType.Int32; param.Value = paramValue; cmd.Parameters.Add (param); AddParameter void protected (DbCommand cmd, string paramName, int size, string paramValue) DbParameter param = cmd.CreateParameter (); param.ParameterName = paramName; param.DbType = DbType.String; param.Size = size; param.Value = paramValue; cmd.Parameters.Add (param);
Si noti come, oltre a sembrare più puliti, i metodi OpenConnection
, CreateTextCommand
, e addParameter
sono più adatti ai test unitari (ignorando il fatto che sono metodi protetti). Questi metodi fanno una sola cosa e, come unità, possono essere testati per garantire che facciano una cosa correttamente. Da questo, diventa poco importante testare il RefactoredInsert
metodo, poiché si basa interamente su altre funzioni che hanno test unitari. Nella migliore delle ipotesi, si potrebbe voler scrivere alcuni casi di test di gestione delle eccezioni e possibilmente qualche convalida sui campi nel Persona
tavolo.
Cosa succede se il metodo di livello superiore fa qualcosa di più che chiamare semplicemente altri metodi per i quali esistono test di unità, ad esempio una sorta di calcolo aggiuntivo? In questo caso, il codice che esegue il calcolo dovrebbe essere spostato sul proprio metodo, i test dovrebbero essere scritti per questo, e ancora il metodo di livello superiore può fare affidamento sulla correttezza del codice che chiama. Questo è il processo di costruzione di codice provabilmente corretto. La correttezza dei metodi di livello superiore migliora quando tutto ciò che fanno è chiamare metodi di livello inferiore che hanno prove (unit test) di correttezza.
La complessità ciclomatica è la rovina dei test unitari e delle applicazioni in generale, poiché aumenta la difficoltà di testare tutti i percorsi del codice. Idealmente, un'unità non ne ha Se
o interruttore
dichiarazioni. Il corpo di tali affermazioni dovrebbe essere considerato come le unità (supponendo che soddisfino gli altri criteri di un'unità) e per essere reso testabile, dovrebbe essere estratto nei propri metodi.
Ecco un altro esempio tratto dal progetto MyXaml dell'autore (parte del parser):
if (tagName == "*") foreach (nodo XmlNode in topElement.ChildNodes) if (! (nodo è XmlComment)) objectNode = node; rompere; foreach (XmlAttribute attr in objectNode.Attributes) if (attr.LocalName == "Name") nameAttr = attr; rompere; else ... etc ...
Qui abbiamo più percorsi di codice che coinvolgono Se
, altro
, e per ciascuno
dichiarazioni, che:
Ovviamente, ramificazioni condizionali, loop, istruzioni caso, ecc. Non possono essere evitate, ma può valere la pena di considerare il refactoring del codice in modo che le parti interne delle condizioni e dei loop siano metodi separati che possono essere testati indipendentemente. Quindi i test per il metodo di livello superiore possono semplicemente garantire che gli stati (rappresentati da condizioni, cicli, interruttori, ecc.) Siano gestiti correttamente, indipendentemente dai calcoli che eseguono.
I metodi che dipendono da altre classi, dati e informazioni sullo stato sono più complessi da testare perché queste dipendenze si traducono in requisiti per oggetti istanziati, esistenza di dati e stato predeterminato.
Nella sua forma più semplice, le unità dipendenti hanno precondizioni che devono essere soddisfatte. I motori di test unitario forniscono meccanismi per istanziare le dipendenze di test, sia per i singoli test sia per tutti i test all'interno di un gruppo di test, o "fixture".
Le unità dipendenti complicate richiedono che servizi come le connessioni al database siano istanziati o simulati. Nell'esempio di codice precedente, il Inserire
il metodo non può essere testato unitamente senza la possibilità di connettersi a un vero database. Questo codice diventa più testabile se l'interazione del database può essere simulata, in genere attraverso l'uso di interfacce o classi base (astratte o meno).
I metodi refactored nel Inserire
il codice descritto in precedenza è un buon esempio perché DbProviderFactory
è una classe base astratta, quindi puoi facilmente creare una classe derivante da DbProviderFactory
per simulare la connessione al database.
Le unità dipendenti, poiché inviano chiamate ad altre API o metodi, sono anche più fragili: potrebbero dover gestire in modo esplicito gli errori potenzialmente generati dai metodi che chiamano. Nell'esempio di codice precedente, il Inserire
il codice del metodo potrebbe essere racchiuso in un blocco try-catch, perché è certamente possibile che la connessione al database non possa esistere. Il gestore di eccezioni potrebbe restituire 0
per il numero di righe interessate, riportando l'errore tramite un altro meccanismo. In tale scenario, i test unitari devono essere in grado di simulare questa eccezione per garantire che tutti i percorsi del codice siano eseguiti correttamente, incluso catturare
e finalmente
blocchi.
Un test fornisce un'utile affermazione della correttezza dell'unità. I test che affermano la correttezza di un'unità tipicamente esercitano l'unità in due modi:
Testare come si comporta l'unità in condizioni normali è di gran lunga il test più facile da scrivere. Dopotutto, quando scriviamo una funzione, la stiamo scrivendo per soddisfare un'esigenza esplicita o implicita. L'implementazione riflette la comprensione di tale requisito, che in parte comprende ciò che ci aspettiamo come input per la funzione e come ci aspettiamo che la funzione si comporti con tali input. Pertanto, stiamo testando il risultato della funzione in base agli input previsti, indipendentemente dal fatto che il risultato della funzione sia un valore di ritorno o una modifica di stato. Inoltre, se l'unità dipende da altre funzioni o servizi, ci si aspetta anche che si comportino correttamente e stiano scrivendo un test con quell'ipotesi implicita.
Testare come si comporta l'unità in condizioni anormali è molto più difficile. Richiede determinare quale sia una condizione anormale, che di solito non è ovvia esaminando il codice. Ciò è reso più complicato quando si verifica un'unità dipendente, un'unità che si aspetta che un'altra funzione o servizio si comporti correttamente. Inoltre, non sappiamo in che modo un altro programmatore o utente potrebbe esercitare l'unità.
Il test unitario non sostituisce altre pratiche di test; dovrebbe integrare altre pratiche di test, fornendo ulteriore supporto e sicurezza della documentazione. La Figura 1 illustra un concetto del "flusso di sviluppo dell'applicazione", in cui altri test si integrano con i test unitari. Si noti che il cliente può essere coinvolto in qualsiasi fase, anche se di solito nella procedura di test di accettazione (ATP), integrazione del sistema e fasi di usabilità.
Confrontalo con il modello V del processo di sviluppo e test del software. Sebbene sia correlato al modello a cascata di sviluppo software (che, in definitiva, tutti gli altri modelli di sviluppo software sono o un sottoinsieme o un'estensione di), il modello V fornisce una buona immagine di quale tipo di test è richiesto per ogni strato di il processo di sviluppo del software:
Il V-Model of TestingInoltre, quando un test point fallisce in qualche altra pratica di prova, una parte specifica di codice può essere generalmente identificata come responsabile del fallimento. Quando questo è il caso, diventa possibile trattare quel pezzo di codice come un'unità e scrivere un test unitario per creare prima l'errore e, quando il codice è stato modificato, per verificare la correzione.
Una procedura di test di accettazione (ATP) viene spesso utilizzata come requisito contrattuale per dimostrare che alcune funzionalità sono state implementate. Gli ATP sono spesso associati a pietre miliari e le pietre miliari sono spesso associate a pagamenti o ulteriori finanziamenti di progetti. Un ATP differisce da un test unitario poiché l'ATP dimostra che è stata implementata la funzionalità rispetto all'intero requisito dell'elemento pubblicitario. Ad esempio, un test unitario può determinare se il calcolo è corretto. Tuttavia, l'ATP potrebbe verificare che gli elementi utente siano forniti nell'interfaccia utente e che l'interfaccia utente visualizzi il risultato del calcolo come specificato dal requisito. Questi requisiti non sono coperti dal test unitario.
Inizialmente, un ATP può essere scritto come una serie di interazioni dell'interfaccia utente (UI) per verificare che i requisiti siano stati soddisfatti. Il test di regressione dell'applicazione mentre continua ad evolversi è applicabile al test unitario e ai test di accettazione. Il test automatizzato dell'interfaccia utente è un altro strumento completamente separato dai test delle unità che consente di risparmiare tempo e risorse umane, riducendo al contempo gli errori di test. Come con gli ATP, i test unitari non sostituiscono in alcun modo il valore dei test dell'interfaccia utente automatizzati.
Test unitari, ATP e test dell'interfaccia utente automatizzati non sostituiscono in alcun modo i test di usabilità, mettendo l'applicazione di fronte agli utenti e ottenendo il loro feedback "user experience". Il test di usabilità non dovrebbe riguardare la ricerca di difetti computazionali (bug), e quindi è completamente al di fuori della portata dei test unitari.
Alcuni strumenti di test unitario forniscono un mezzo per misurare le prestazioni di un metodo. Ad esempio, il motore di test di Visual Studio riporta i tempi di esecuzione e NUnit dispone di attributi che possono essere utilizzati per verificare che un metodo venga eseguito entro un tempo prestabilito.
Idealmente, uno strumento di test delle unità per i linguaggi .NET dovrebbe implementare esplicitamente il test delle prestazioni per compensare la compilazione del codice just-in-time (JIT) la prima volta che viene eseguito il codice.
La maggior parte dei test di carico (e dei test relativi alle prestazioni) non sono adatti per i test unitari. Alcune forme di test di carico possono essere eseguite anche con test unitari, almeno ai limiti dell'hardware e del sistema operativo, come ad esempio:
Tuttavia, questi tipi di test richiedono idealmente il supporto del framework o dell'OS del sistema operativo per simulare questi tipi di carichi per l'applicazione da testare. Forzare l'intero sistema operativo a consumare una grande quantità di memoria, risorse o entrambi, riguarda tutte le applicazioni, inclusa l'applicazione di test dell'unità. Questo non è un approccio desiderabile.
Altri tipi di test del carico, come la simulazione di più istanze di eseguire un'operazione contemporaneamente, non sono candidati per il test dell'unità. Ad esempio, testare le prestazioni di un servizio Web con un carico di un milione di transazioni al minuto probabilmente non è possibile utilizzando una singola macchina. Mentre questo tipo di test può essere facilmente scritto come un'unità, il test effettivo coinvolgerebbe una suite di macchine di test. E alla fine, hai solo testato un comportamento molto stretto del servizio web in condizioni di rete molto specifiche, che in nessun modo rappresentano il mondo reale.
Per questo motivo, i test delle prestazioni e del carico hanno un'applicazione limitata con test dell'unità.