Test unitario in breve dimostrazione della correttezza

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:

  • Verifica che gli input per un calcolo siano corretti (contratto metodo).
  • Verificare che una chiamata al metodo comporti il ​​risultato computazionale desiderato (chiamato aspetto computazionale), suddiviso in quattro processi tipici:
    • Trasformazione dei dati
    • Riduzione dei dati
    • Cambiamento di stato
    • Correttezza dello stato
  • Gestione e recupero degli errori esterni.

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.


Come i test unitari dimostrano la correttezza

Dimostrare la correttezza implica:

  • Verifica del contratto.
  • Verifica dei risultati computazionali.
  • Verifica dei risultati della trasformazione dei dati.
  • La verifica degli errori esterni viene gestita correttamente.

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.

Il contratto di prova è implementato

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.

Dimostra risultati computazionali

Un test unitario più forte implica verificare che il calcolo sia corretto. È utile classificare i tuoi metodi in una delle tre forme di calcolo:

  • Riduzione dei dati
  • Trasformazione dei dati
  • Cambiamento di stato

Questi determinano i tipi di test unitari che potresti voler scrivere per un particolare metodo.

Riduzione dei dati

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.

Trasformazione dei dati

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.

Elenca le trasformazioni

Le trasformazioni delle liste dovrebbero essere separate in due test:

  • Verifica che la trasformazione principale sia corretta.
  • Verificare che l'operazione di elenco sia corretta.

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 pubblico ConcatNames (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"); 

Espressioni Lambda e test unitari

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 pubblica ConcatNamesWithLinq (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 pubblica ConcatNamesWithLinq (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.

Cambio di stato

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:

  • Quando è inizializzato.
  • Quando viene richiesto di connettersi al servizio.
  • Quando viene richiesto di disconnettersi dal servizio.
  • Quando si tenta più di una connessione simultanea.

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.

Dimostrare un metodo gestisce correttamente un'eccezione esterna

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:

  • Non hai il controllo su una dipendenza fisicamente separata, che si tratti di un servizio web, di un database o di un altro server separato.
  • Non hai alcuna prova della correttezza del codice di qualcun altro, tipicamente una libreria di terze parti.
  • I servizi e il software di terze parti possono generare un'eccezione a causa di un problema che il codice sta creando ma che non rileva (e che non sarebbe necessariamente facile da rilevare). Un esempio di ciò è che quando si eliminano i record in un database, il database genera un'eccezione a causa dei record in altre tabelle che fanno riferimento ai record che il programma sta eliminando, violando così un vincolo di chiave esterna.

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.

Classi di derisione

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.

Dimostra che un bug è ri-creabile

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.

Test negativi

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.

Dimostrare che un bug è corretto

Ovviamente, vogliamo dimostrare che un bug è stato corretto. Questo è un test "positivo".

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:

  • I test negativi, che dimostrano che qualcosa non funziona ripetutamente, sono importanti per capire il problema e la soluzione.
  • Test positivi, che dimostrano che il problema è stato risolto, sono importanti non solo per verificare la soluzione, ma anche per ripetere il test ogni volta che viene apportata una modifica. I test unitari svolgono un ruolo importante quando si tratta di test di regressione.

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.

Dimostrare che non si è rotto nulla durante la modifica del codice

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.

I requisiti di prova sono soddisfatti

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.