Questo è un estratto dall'eBook Testing Unit Affinity, di Marc Clifton, gentilmente fornito da Syncfusion.
La frase "proving correctness" è normalmente usata nel contesto della veridicità di un calcolo, ma per quanto riguarda il test unitario, la dimostrazione della correttezza ha in realtà tre grandi categorie, solo la seconda delle quali si riferisce ai calcoli stessi:
Ci sono molti aspetti di un'applicazione in cui solitamente non è possibile applicare il test unitario per dimostrare la correttezza. Questi includono la maggior parte delle funzionalità dell'interfaccia utente come layout e usabilità. In molti casi, il test unitario non è la tecnologia appropriata per testare i requisiti e il comportamento delle applicazioni in termini di prestazioni, carico e così via.
Dimostrare la correttezza implica:
Esaminiamo alcuni esempi di ciascuna di queste categorie, i loro punti di forza, i punti deboli e i problemi che potremmo incontrare con il nostro codice.
La forma più elementare di test unitario è verificare che lo sviluppatore abbia scritto un metodo che indichi chiaramente il "contratto" tra il chiamante e il metodo chiamato. Questo di solito assume la forma di verificare che gli input non validi di un metodo determinino il lancio di un'eccezione. Ad esempio, un metodo "divide per" potrebbe lanciare un ArgumentOutOfRangeException
se il denominatore è 0:
public static int Divide (numeratore int, denominatore int) if (denominatore == 0) lanciare una nuova ArgumentOutOfRangeException ("Denominatore non può essere 0."); numeratore / denominatore di ritorno; [TestMethod] [ExpectedException (typeof (ArgumentOutOfRangeException))] public void BadParameterTest () Divide (5, 0);
Tuttavia, verificare che un metodo attui test contrattuali è uno dei test unitari più deboli che si possano scrivere.
Un test unitario più forte implica verificare che il calcolo sia corretto. È utile classificare i tuoi metodi in una delle tre forme di calcolo:
Questi determinano i tipi di test unitari che potresti voler scrivere per un particolare metodo.
Il Dividere
il metodo nel campione precedente può essere considerato una forma di riduzione dei dati. Prende due valori e restituisce un valore. Illustrare:
[TestMethod] public void VerifyDivisionTest () Assert.IsTrue (Divide (6, 2) == 3, "6/2 dovrebbe essere uguale a 3!");
Questo è un esempio di test di un metodo che riduce gli input, in genere, a un output risultante. Questa è la forma più semplice di test unitari utili.
I test delle unità di trasformazione dei dati tendono a operare su insiemi di valori. Ad esempio, il seguente è un test per un metodo che converte le coordinate cartesiane in coordinate polari.
public static double [] ConvertToPolarCoordinates (double x, double y) double dist = Math.Sqrt (x * x + y * y); doppio angolo = Math.Atan2 (y, x); ritorna nuovo double [] dist, angle; [TestMethod] public void ConvertToPolarCoordinatesTest () double [] pcoord = ConvertToPolarCoordinates (3, 4); Assert.IsTrue (pcoord [0] == 5, "Distanza attesa uguale a 5"); Assert.IsTrue (pcoord [1] == 0.92729521800161219, "L'angolo previsto è 53.130 gradi");
Questo test verifica la correttezza della trasformazione matematica.
Le trasformazioni delle liste dovrebbero essere separate in due test:
Ad esempio, dal punto di vista del test delle unità, il seguente esempio è scritto male perché incorpora sia la riduzione dei dati che la trasformazione dei dati:
nome struttura pubblica stringa pubblica FirstName get; impostato; public string LastName get; impostato; elenco pubblicoConcatNames (Lista nomi) Lista concatenatedNames = new List (); foreach (Nome nome nei nomi) concatenatedNames.Add (name.LastName + "," + name.FirstName); return concatenatedNames; [TestMethod] public void NameConcatenationTest () List names = new List () new Name () FirstName = "John", LastName = "Travolta", nuovo nome () FirstName = "Allen", LastName = "Nancy"; Elenco newNames = ConcatNames (nomi); Assert.IsTrue (newNames [0] == "Travolta, John"); Assert.IsTrue (newNames [1] == "Nancy, Allen");
Questo codice è stato testato con unità migliori separando la riduzione dei dati dalla trasformazione dei dati:
public string Concat (Nome name) return name.LastName + "," + name.FirstName; [TestMethod] public void ContactNameTest () Nome name = new Name () FirstName = "John", LastName = "Travolta"; string concatenatedName = Concat (nome); Assert.IsTrue (concatenatedName == "Travolta, John");
La sintassi LINQ (Language-Integrated Query) è strettamente associata alle espressioni lambda, il che si traduce in una sintassi di facile lettura che rende la vita difficile per il test delle unità. Ad esempio, questo codice:
lista pubblicaConcatNamesWithLinq (Lista nomi) return names.Select (t => t.LastName + "," + t.FirstName) .ToList ();
è significativamente più elegante degli esempi precedenti, ma non si presta bene al test unitario della "unità" effettiva, ovvero la riduzione dei dati da una struttura del nome a una singola stringa delimitata da virgole espressa nella funzione lambda t => t.LastName + "," + t.FirstName
. Per separare l'unità dall'operazione di elenco è necessario:
lista pubblicaConcatNamesWithLinq (Lista nomi) return names.Select (t => Concat (t)). ToList ();
Possiamo vedere che i test unitari possono spesso richiedere il refactoring del codice per separare le unità da altre trasformazioni.
La maggior parte delle lingue sono "stateful" e le classi spesso gestiscono lo stato. Lo stato di una classe, rappresentato dalle sue proprietà, è spesso una cosa utile da testare. Considera questa classe che rappresenta il concetto di una connessione:
public class AlreadyConnectedToServiceException: ApplicationException public AlreadyConnectedToServiceException (string msg): base (msg) ServiceConnection di classe pubblica public bool Connected get; set protetto; public void Connect () if (Connected) throw new AlreadyConnectedToServiceException ("È consentita solo una connessione alla volta."); // Connetti al servizio. Connected = true; public void Disconnect () // Disconnettersi dal servizio. Connected = false;
Possiamo scrivere unit test per verificare i vari stati permessi e non autorizzati dell'oggetto:
[TestClass] public class ServiceConnectionFixture [TestMethod] public void TestInitialState () ServiceConnection conn = new ServiceConnection (); Assert.IsFalse (conn.Connected); [TestMethod] public void TestConnectedState () ServiceConnection conn = new ServiceConnection (); conn.Connect (); Assert.IsTrue (conn.Connected); [TestMethod] public void TestDisconnectedState () ServiceConnection conn = new ServiceConnection (); conn.Connect (); conn.Disconnect (); Assert.IsFalse (conn.Connected); [TestMethod] [ExpectedException (typeof (AlreadyConnectedToServiceException))] public void TestAlreadyConnectedException () ServiceConnection conn = new ServiceConnection (); conn.Connect (); conn.Connect ();
Qui, ogni test verifica la correttezza dello stato dell'oggetto:
La verifica dello stato spesso rivela bug nella gestione dello stato. Vedi anche le seguenti "Mocking Classes" per ulteriori miglioramenti al codice di esempio precedente.
La gestione e il recupero degli errori esterni sono spesso più importanti della verifica se il proprio codice genera eccezioni nei tempi corretti. Ci sono diverse ragioni per questo:
Questi tipi di eccezioni sono difficili da testare poiché richiedono la creazione di almeno qualche errore che verrebbe generato dal servizio che non si controlla. Un modo per farlo è "deridere" il servizio; tuttavia, ciò è possibile solo se l'oggetto esterno è implementato con un'interfaccia, una classe astratta o metodi virtuali.
Ad esempio, il codice precedente per la classe "ServiceConnection" non è mockable. Se vuoi testare la sua gestione dello stato, devi fisicamente creare una connessione al servizio (qualunque cosa sia) che può essere o non essere disponibile durante l'esecuzione dei test unitari. Un'implementazione migliore potrebbe assomigliare a questa:
public class MockableServiceConnection public bool Connected get; set protetto; virtual virtual protected ConnectToService () // Connetti al servizio. virtual virtual protected DisconnectFromService () // Disconnettersi dal servizio. public void Connect () if (Connected) throw new AlreadyConnectedToServiceException ("È consentita solo una connessione alla volta."); ConnectToService (); Connected = true; public void Disconnect () DisconnectFromService (); Connected = false;
Nota come questo refactoring minore ora ti permette di scrivere una classe di simulazione:
public class ServiceConnectionMock: MockableServiceConnection protected override void ConnectToService () // Non fare nulla. protected override void DisconnectFromService () // Non fare nulla.
che consente di scrivere un test unitario che verifica la gestione dello stato indipendentemente dalla disponibilità del servizio. Come illustrato, anche semplici modifiche architettoniche o di implementazione possono migliorare notevolmente la testabilità di una classe.
La tua prima linea di difesa nel dimostrare che il problema è stato corretto è, ironia della sorte, la dimostrazione che il problema esiste. In precedenza abbiamo visto un esempio di scrittura di un test che ha dimostrato che il metodo Divide verifica il valore di un denominatore di 0
. Diciamo che è stata archiviata una segnalazione di bug perché un utente ha bloccato il programma durante l'immissione 0
per il valore del denominatore.
Il primo ordine del giorno è quello di creare un test che eserciti questa condizione:
[TestMethod] [ExpectedException (typeof (DivideByZeroException))] public void BadParameterTest () Divide (5, 0);
Questo test passaggi perché stiamo dimostrando che l'errore esiste verificando che quando è il denominatore 0
, un DivideByZeroException
è sollevato Questi tipi di test sono considerati "test negativi" come loro passaggio quando si verifica un errore. Il test negativo è importante quanto il test positivo (discusso di seguito) perché verifica l'esistenza di un problema prima che venga corretto.
Ovviamente, vogliamo dimostrare che un bug è stato corretto. Questo è un test "positivo".
Ora possiamo introdurre un nuovo test, uno che verificherà che il codice stesso rilevi l'errore lanciando un ArgumentOutOfRangeException
.
[TestMethod] [ExpectedException (typeof (ArgumentOutOfRangeException))] public void BadParameterTest () Divide (5, 0);
Se possiamo scrivere questo test prima risolvendo il problema, vedremo che il test fallisce. Infine, dopo aver risolto il problema, il test positivo passa e il test negativo ora fallisce.
Mentre questo è un esempio banale, dimostra due concetti:
Infine, dimostrare che un bug esiste non è sempre facile. Tuttavia, come regola generale, i test unitari che richiedono troppa installazione e simulazione sono un indicatore del fatto che il codice testato non è abbastanza isolato dalle dipendenze esterne e potrebbe essere un candidato per il refactoring.
Dovrebbe essere ovvio che il test di regressione è un risultato misurabilmente utile del test unitario. Poiché il codice subisce delle modifiche, verranno introdotti dei bug che verranno rivelati se si dispone di una buona copertura del codice nei test delle unità. Ciò consente di risparmiare molto tempo nel debugging e, cosa più importante, fa risparmiare tempo e denaro quando il programmatore scopre il bug piuttosto che l'utente.
Lo sviluppo delle applicazioni inizia generalmente con un set di requisiti di alto livello, solitamente orientato all'interfaccia utente, al flusso di lavoro e ai calcoli. Idealmente, il team riduce il visibile serie di requisiti fino a un insieme di requisiti programmatici, che sono invisibile per l'utente, per sua natura.
La differenza si manifesta nel modo in cui il programma viene testato. Il test di integrazione è in genere al visibile livello, mentre il test unitario ha la grana più fine di invisibile, test di correttezza programmatica. È importante tenere presente che i test unitari non sono destinati a sostituire i test di integrazione; tuttavia, proprio come con i requisiti applicativi di alto livello, ci sono requisiti programmatici di basso livello che possono essere definiti. A causa di questi requisiti programmatici, è importante scrivere test unitari.
Prendiamo un metodo Round. Il metodo .NET Math.Round consente di arrotondare un numero il cui componente frazionario è maggiore di 0,5, ma arrotonderà verso il basso quando il componente frazionario è 0,5 o inferiore. Diciamo che non è il comportamento che desideriamo (per qualsiasi ragione), e vogliamo arrotondare quando il componente frazionario è 0,5 o superiore. Questo è un requisito computazionale che dovrebbe essere in grado di derivare da un requisito di integrazione di livello superiore, risultante nel seguente metodo e test:
public static int RoundUpHalf (double n) if (n < 0) throw new ArgumentOutOfRangeException("Value must be >= 0. "); int ret = (int) n; doppia frazione = n - ret; if (frazione = = 0.5) ++ ret; return ret; [TestMethod] public void RoundUpTest () int result1 = RoundUpHalf (1.5); int result2 = RoundUpHalf (1.499999); Assert.IsTrue (result1 == 2, "Previsto 2."); Assert.IsTrue (result2 == 1, "Previsto 1.");
Dovrebbe essere scritto anche un test separato per l'eccezione.
Prendere i requisiti a livello di applicazione che sono verificati con i test di integrazione e ridurli a requisiti di calcolo di livello inferiore è una parte importante della strategia di testing unitaria in quanto definisce chiari requisiti computazionali che l'applicazione deve soddisfare. Se si incontra una difficoltà con questo processo, provare a convertire i requisiti dell'applicazione in una delle tre categorie computazionali: riduzione dei dati, trasformazione dei dati e modifica dello stato.