Procedure consigliate di testing unità con .NET Core e .NET Standard

La scrittura di unit test offre numerosi vantaggi: facilitano la regressione, rappresentano fonti di documentazione e semplificano la progettazione ottimale. Tuttavia, unit test deboli e di difficile lettura possono causare seri problemi nella base codice. Questo articolo descrive alcune procedure consigliate per la progettazione di unit test per i progetti .NET Core e .NET Standard.

In questa guida verranno descritte alcune procedure consigliate per scrivere unit test flessibili e di facile comprensione.

A cura di John Reese con ringraziamenti speciali a Roy Osherove

L'utilità degli unit test

Esistono diversi motivi per utilizzare gli unit test.

Meno tempo da dedicare all'esecuzione dei test funzionali

I test funzionali sono costosi. Comportano, in genere, l'apertura dell'applicazione e l'esecuzione di una serie di passaggi che l'utente, o chi per lui, deve eseguire per convalidare il comportamento previsto. Questi passaggi potrebbero non essere sempre noti al tester. Per eseguire il test, dovranno contattare qualcuno più esperto in quel campo. Il test di semplici modifiche può richiedere solo alcuni secondi, che possono diventare minuti per modifiche più significative. Infine, questo processo deve essere ripetuto per ogni modifica apportata nel sistema.

Gli unit test, invece, durano millisecondi, possono essere eseguiti facendo clic su un pulsante e non richiedono necessariamente una conoscenza approfondita del sistema. L'esito positivo o negativo del test è determinato dal test runner, non dai singoli utenti.

Protezione contro la regressione

I difetti di regressione sono i difetti che vengono introdotti quando si apporta una modifica all'applicazione. È pratica comune per i tester testare non solo le nuove funzionalità, ma anche quelle già esistenti per verificare che le funzionalità implementate in precedenza continuino a funzionare come previsto.

Con gli unit test, è possibile eseguire nuovamente l'intero gruppo di test dopo ogni compilazione o persino dopo la modifica di una sola riga di codice. In tal modo, si offre la certezza che il nuovo codice non interferisca con le funzionalità esistenti.

Documentazione eseguibile

Potrebbe non essere sempre facile conoscere lo scopo di un particolare metodo o il relativo comportamento con un dato input. Ci si potrebbe chiedere: che comportamento avrebbe questo metodo se fosse associato a una stringa vuota? Restituirebbe un valore null?

Quando si dispone di un gruppo di unit test ben identificabili, per ogni test è possibile capire chiaramente l'output previsto per un dato input. Inoltre, è possibile verificarne l'effettivo funzionamento.

Meno codice accoppiato

Quando il codice è strettamente accoppiato, può risultare difficile eseguire unit test. Senza la creazione di unit test per il codice che si sta scrivendo, l'accoppiamento potrebbe risultare meno evidente.

La scrittura di test per il codice ha l'effetto di disaccoppiare naturalmente il codice perché le verifiche sarebbero, in caso contrario, più difficili.

Caratteristiche di un buon unit test

  • Rapido: non è insolito per i progetti maturi comprendere migliaia di unit test. La durata dell'esecuzione degli unit test dovrebbe essere breve. Millisecondi.
  • Isolati: gli unit test sono autonomi, possono essere eseguiti in modo isolato e non hanno dipendenze verso fattori esterni, ad esempio file system o database.
  • Ripetibile: un unit test deve generare risultati costanti, vale a dire, deve restituire sempre lo stesso risultato, se tra le esecuzioni non si modifica alcun elemento.
  • Autonomo: il test deve essere in grado di rilevare automaticamente se ha avuto esito positivo o meno, senza alcun intervento.
  • Tempestivo: il tempo richiesto per la scrittura di un unit test deve essere proporzionale al tempo richiesto per la scrittura del codice sottoposto a test. Se il test del codice richiede una quantità di tempo molto maggiore rispetto alla scrittura del codice, prendere in considerazione una struttura che risulti più testabile.

Code coverage

Una percentuale di code coverage elevata è spesso associata a una qualità superiore del codice. Tuttavia, la misurazione stessa non può determinare la qualità del codice. L'impostazione di un obiettivo di percentuale di code coverage eccessivamente ambizioso può risultare controproducente. Pensiamo a un progetto complesso con migliaia di rami condizionali e poniamoi di impostare un obiettivo del 95% di code coverage. Attualmente il progetto mantiene il 90% di code coverage. La quantità di tempo necessario per tenere conto di tutti i casi limite nel rimanente 5% potrebbe essere un'impresa enorme, che porterebbe a una rapida diminuzione della proposta di valore.

Una percentuale di code coverage elevata non è un indicatore di successo, né implica un'elevata qualità del codice. Rappresenta solo la quantità di codice coperta dagli unit test. Per altre informazioni, vedere Code coverage di testing unità.

Terminologia

Quando si parla di testing, il termine mock è purtroppo usato in modo improprio. I punti seguenti definiscono i tipi più comuni di fake durante la scrittura degli unit test:

Fake: è un termine generico che può essere usato per descrivere uno stub o un mock. Il contesto di utilizzo determina se si tratta di uno stub o di un mock. Quindi, in altre parole, un fake può essere uno stub o un mock.

Mock: un mock è un oggetto del sistema che decide se uno unit test ha avuto esito positivo o meno. Un mock inizia come fake fino a quando non diventa l'oggetto di un'asserzione.

Stub: è la sostituzione controllabile nel sistema di una dipendenza o di un collaboratore. Utilizzando uno stub è possibile testare il codice senza prendere direttamente in considerazione la dipendenza. Per impostazione predefinita, uno stub inizia come fake.

Si consideri il frammento di codice seguente:

var mockOrder = new MockOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

L'esempio precedente sarebbe uno stub definito mock. In questo caso, si tratta di stub. Si sta passando Order solo come mezzo per creare un'istanza di Purchase (il sistema sottoposto a test). Il nome MockOrder è anche estremamente fuorviante perché, anche in questo caso, l'ordine non è un mock.

Un approccio migliore sarebbe:

var stubOrder = new FakeOrder();
var purchase = new Purchase(stubOrder);

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Rinominando la classe in FakeOrder, la classe è stata resa molto più generica. La classe può essere usata come mock o stub, qualsiasi scopo risulti migliore per il test case. Nell'esempio precedente, FakeOrder viene usato come stub. FakeOrder non viene usato in alcun modo durante l'asserzione. FakeOrder è stato passato alla classe Purchase unicamente per soddisfare i requisiti del costruttore.

Per usarlo come mock, è possibile eseguire operazioni simili al codice seguente:

var mockOrder = new FakeOrder();
var purchase = new Purchase(mockOrder);

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

In questo caso, si sta verificando una proprietà del fake, con un'asserzione su di essa, pertanto nel frammento di codice precedente, mockOrder è un mock.

Importante

È importante usare questa terminologia in modo corretto. Se si chiamano gli stub "mock", altri sviluppatori faranno ipotesi errate sulle intenzioni espresse.

La cosa principale da ricordare riguardo a mock e stub è che i mock sono analoghi agli stub, ma che si può rivolgere un'asserzione a un mock, ma non a uno stub.

Procedure consigliate

Ecco alcune delle procedure consigliate più importanti per la scrittura di unit test.

Evitare le dipendenze dell'infrastruttura

Quando si scrivono unit test, è consigliabile non introdurre dipendenze dall'infrastruttura. Le dipendenze rendono i test lenti e instabili. È consigliabile usarle solo con test di integrazione. È possibile evitare queste dipendenze nell'applicazione seguendo il principio delle dipendenze esplicite e usando la tecnica di inserimento delle dipendenze. È anche possibile mantenere gli unit test in un progetto separato rispetto ai test di integrazione. Questo approccio garantisce che il progetto di unit test non abbia riferimenti o dipendenze dai pacchetti dell'infrastruttura.

Denominare i test

Il nome del test deve essere costituito da tre parti:

  • Nome del metodo testato.
  • Scenario in cui si sta testando.
  • Comportamento previsto quando viene richiamato lo scenario.

Perché?

Gli standard di denominazione sono importanti perché esprimono in modo esplicito l'intento del test. I test non si limitano a verificare che il codice funzioni, ma documentano anche il risultato. Osservando semplicemente il gruppo di unit test, sarà possibile dedurre il comportamento del codice senza neanche guardare il codice. Inoltre, quando i test hanno esito negativo, è possibile vedere esattamente quali scenari non soddisfano le aspettative.

Scadente:

[Fact]
public void Test_Single()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Migliore:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Disposizione dei test

Disporre, agire, asserire è uno schema comune per gli unit test. Come suggerisce il nome, è costituito da tre azioni principali:

  • Disporre gli oggetti, crearli e impostarli in base alle esigenze.
  • Agire su un oggetto.
  • Asserire che un dato comportamento è come previsto.

Perché?

  • Separare nettamente l'oggetto del test dai passaggi disporre e asserire.
  • Il rischio di mescolare le asserzioni con il codice "agire" è minore.

La leggibilità è uno degli aspetti più importanti da considerare durante la scrittura di un test. La separazione di ognuna di queste azioni all'interno del test evidenzia chiaramente le dipendenze necessarie per chiamare il codice, come viene chiamato il codice e ciò che si sta tentando di asserire. Sebbene potrebbe essere possibile combinare alcuni passaggi e ridurre le dimensioni del test, l'obiettivo principale rimane rendere il test più leggibile possibile.

Scadente:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Assert
    Assert.Equal(0, stringCalculator.Add(""));
}

Migliore:

[Fact]
public void Add_EmptyString_ReturnsZero()
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add("");

    // Assert
    Assert.Equal(0, actual);
}

Scrivere test più semplici possibile

L'input da usare in un unit test deve essere il più semplice possibile, per verificare il comportamento che si sta testando.

Perché?

  • I test diventano più adattabili alle modifiche future nella base codice.
  • I test hanno un comportamento più simile a quello dell'implementazione.

Test che includono più informazioni rispetto a quelle necessarie per la verifica hanno maggiori probabilità di contenere errori e il loro intento risulta meno chiaro. Durante la scrittura dei test, è necessario concentrare l'attenzione sul comportamento. L'impostazione di proprietà aggiuntive per i modelli o l'uso di valori diversi da zero quando non sono necessari distoglie l'attenzione da ciò che si sta tentando di provare.

Scadente:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("42");

    Assert.Equal(42, actual);
}

Migliore:

[Fact]
public void Add_SingleNumber_ReturnsSameNumber()
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add("0");

    Assert.Equal(0, actual);
}

Evitare le "stringhe magiche"

La denominazione delle variabili negli unit test è importante, se non di più, rispetto alla denominazione delle variabili nel codice di produzione. Gli unit test non devono contenere stringhe magiche.

Perché?

  • Impediscono a chi legge il test di controllare il codice di produzione per scoprire ciò che rende il valore speciale.
  • Illustrano in modo esplicito ciò che si sta tentando di dimostrare anziché ciò che si tenta di compiere.

Le "stringhe magiche" possono causare confusione a chi legge il test. Se una stringa appare insolita, ci si potrebbe domandare perché è stato scelto un determinato valore per un parametro o valore restituito. Questo tipo di valore stringa potrebbe portare a esaminare in modo più approfondito i dettagli dell'implementazione, anziché concentrarsi sul test.

Suggerimento

Quando si scrivono i test, bisogna porsi l'obiettivo di esprimere nel modo più chiaro possibile le loro finalità. Nel caso delle "stringhe magiche", un buon espediente consiste nell'assegnare questi valori a costanti.

Scadente:

[Fact]
public void Add_BigNumber_ThrowsException()
{
    var stringCalculator = new StringCalculator();

    Action actual = () => stringCalculator.Add("1001");

    Assert.Throws<OverflowException>(actual);
}

Migliore:

[Fact]
void Add_MaximumSumResult_ThrowsOverflowException()
{
    var stringCalculator = new StringCalculator();
    const string MAXIMUM_RESULT = "1001";

    Action actual = () => stringCalculator.Add(MAXIMUM_RESULT);

    Assert.Throws<OverflowException>(actual);
}

Evitare la logica nei test

Quando si scrivono unit test, evitare la concatenazione manuale delle stringhe, le condizioni logiche, ad esempio if, while, for, switch e altre condizioni.

Perché?

  • Minore possibilità di introdurre un bug nei test.
  • Per concentrarsi sul risultato finale anziché sui dettagli di implementazione.

Se si introduce la logica nel gruppo di test, la possibilità di introdurre un bug al suo interno aumenta notevolmente. L'ultimo posto in cui deve esserci un bug è il gruppo di test. Si deve avere un elevato livello di fiducia riguardo al funzionamento dei test, altrimenti non sarà possibile considerarli attendibili. I test non attendibili non forniscono alcun valore. Quando un test ha esito negativo, è necessario avere la percezione che ci sia un problema nel codice che non può essere ignorato.

Suggerimento

Se risulta inevitabile inserire la logica nel test, è consigliabile suddividere il test in due o più test diversi.

Scadente:

[Fact]
public void Add_MultipleNumbers_ReturnsCorrectResults()
{
    var stringCalculator = new StringCalculator();
    var expected = 0;
    var testCases = new[]
    {
        "0,0,0",
        "0,1,2",
        "1,2,3"
    };

    foreach (var test in testCases)
    {
        Assert.Equal(expected, stringCalculator.Add(test));
        expected += 3;
    }
}

Migliore:

[Theory]
[InlineData("0,0,0", 0)]
[InlineData("0,1,2", 3)]
[InlineData("1,2,3", 6)]
public void Add_MultipleNumbers_ReturnsSumOfNumbers(string input, int expected)
{
    var stringCalculator = new StringCalculator();

    var actual = stringCalculator.Add(input);

    Assert.Equal(expected, actual);
}

Prediligere i metodi helper agli attributi Setup e Teardown

Se è necessario un oggetto o uno stato simile per i test, prediligere un metodo helper rispetto all'uso degli attributi Setup e Teardown, se presenti.

Perché?

  • La lettura dei test è più agevole dal momento che la totalità del codice è visibile nel test.
  • Minore rischio di configurare troppo o troppo poco per il test specificato.
  • Così vi sarà meno probabilità di condividere lo stato tra i test, il che crea dipendenze indesiderate tra di essi.

Nel framework di testing unità, Setup viene chiamato prima di ogni unit test all'interno del gruppo di test. Mentre ad alcuni potrebbe sembrare uno strumento utile, in realtà genera test eccessivamente grandi e difficili da leggere. I requisiti per rendere ogni test operativo variano da test a test. Sfortunatamente, Setup obbliga a usare esattamente gli stessi requisiti per ogni test.

Nota

SetUp e TearDown sono stati rimossi a partire dalla versione 2.x di xUnit

Scadente:

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
// more tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}

Migliore:

[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

    var actual = stringCalculator.Add("0,1");

    Assert.Equal(1, actual);
}
// more tests...
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

Evitare le azioni multiple

Quando si scrivono i test, puntare a includere una sola azione per ogni test. Le strategie comuni per usare una sola azione includono:

  • Creare un test separato per ogni azione.
  • Usare test con parametri.

Perché?

  • Quando il test ha esito negativo, è chiaro quale azione non sta riuscendo.
  • Assicura che il test sia incentrato solo su un singolo caso.
  • Offre il quadro completo del motivo per cui i test non sono riusciti.

Più azioni devono essere asserite singolarmente e non è garantito che tutte le asserzioni verranno eseguite. Nella maggior parte dei framework di testing unità, una volta che un'asserzione ha esito negativo in un unit test, i test successivi sono automaticamente considerati come non riusciti. Questo tipo di processo può confondere come funzionalità che funziona effettivamente, visto che verrà visualizzato come esito negativo.

Scadente:

[Fact]
public void Add_EmptyEntries_ShouldBeTreatedAsZero()
{
    // Act
    var actual1 = stringCalculator.Add("");
    var actual2 = stringCalculator.Add(",");

    // Assert
    Assert.Equal(0, actual1);
    Assert.Equal(0, actual2);
}

Migliore:

[Theory]
[InlineData("", 0)]
[InlineData(",", 0)]
public void Add_EmptyEntries_ShouldBeTreatedAsZero(string input, int expected)
{
    // Arrange
    var stringCalculator = new StringCalculator();

    // Act
    var actual = stringCalculator.Add(input);

    // Assert
    Assert.Equal(expected, actual);
}

Convalidare i metodi privati tramite il testing unità dei metodi pubblici

Nella maggior parte dei casi, non è necessario testare un metodo privato. I metodi privati rappresentano un dettaglio di implementazione e non esistono mai in isolamento. A un certo punto, ci sarà un metodo pubblico che chiama il metodo privato come parte della sua implementazione. Quello che è opportuno prendere in considerazione è il risultato finale del metodo pubblico che chiama quello privato.

Si consideri il caso seguente:

public string ParseLogLine(string input)
{
    var sanitizedInput = TrimInput(input);
    return sanitizedInput;
}

private string TrimInput(string input)
{
    return input.Trim();
}

La prima reazione dell'utente potrebbe essere di iniziare a scrivere un test per TrimInput, in modo da assicurare che il metodo funzioni come previsto. Tuttavia, è probabile che ParseLogLine manipoli sanitizedInput in un modo imprevisto, rendendo inutile il test di TrimInput.

Il test reale deve essere eseguito con il metodo pubblico ParseLogLine perché questo è ciò che è necessario considerare.

public void ParseLogLine_StartsAndEndsWithSpace_ReturnsTrimmedResult()
{
    var parser = new Parser();

    var result = parser.ParseLogLine(" a ");

    Assert.Equals("a", result);
}

Tenendo presente questo aspetto, quando si vede un metodo privato, trovare il metodo pubblico e scrivere i test su tale metodo. Unicamente perché un metodo privato restituisce il risultato previsto non implica che il sistema che chiama il metodo privato usi il risultato in modo corretto.

Riferimenti statici negli stub

Uno dei principi a cui uno unit test si deve attenere è che deve avere controllo completo del sistema sottoposto a test. Questo principio può essere problematico quando il codice di produzione include chiamate a riferimenti statici (ad esempio, DateTime.Now). Osservare il codice seguente:

public int GetDiscountedPrice(int price)
{
    if (DateTime.Now.DayOfWeek == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

In che modo questo codice può essere sottoposta a testing unità? È possibile provare un approccio come:

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(2, actual)
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();

    var actual = priceCalculator.GetDiscountedPrice(2);

    Assert.Equals(1, actual);
}

Sfortunatamente, ci si renderà rapidamente conto che nel test sono presenti un paio di problemi.

  • Se il gruppo di test viene eseguito di martedì, il secondo test avrà esito positivo, ma il primo test avrà esito negativo.
  • Se il gruppo di test viene eseguito un qualsiasi altro giorno, il primo test avrà esito positivo, ma il secondo test avrà esito negativo.

Per risolvere questi problemi, sarà necessario introdurre un seam nel codice di produzione. Una strategia consiste nell'inserire il codice da controllare in un'interfaccia e far dipendere il codice di produzione da tale interfaccia.

public interface IDateTimeProvider
{
    DayOfWeek DayOfWeek();
}

public int GetDiscountedPrice(int price, IDateTimeProvider dateTimeProvider)
{
    if (dateTimeProvider.DayOfWeek() == DayOfWeek.Tuesday)
    {
        return price / 2;
    }
    else
    {
        return price;
    }
}

Il gruppo di test diventa ora il seguente:

public void GetDiscountedPrice_NotTuesday_ReturnsFullPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Monday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(2, actual);
}

public void GetDiscountedPrice_OnTuesday_ReturnsHalfPrice()
{
    var priceCalculator = new PriceCalculator();
    var dateTimeProviderStub = new Mock<IDateTimeProvider>();
    dateTimeProviderStub.Setup(dtp => dtp.DayOfWeek()).Returns(DayOfWeek.Tuesday);

    var actual = priceCalculator.GetDiscountedPrice(2, dateTimeProviderStub);

    Assert.Equals(1, actual);
}

Ora il gruppo di test ha pieno controllo su DateTime.Now ed è possibile sottoporre a stub qualsiasi valore durante la chiamata al metodo.