Condividi tramite


Procedure consigliate per unit test per .NET

La scrittura di unit test offre numerosi vantaggi. Aiutano con la regressione, forniscono documentazione e facilitano una buona progettazione. Tuttavia, quando gli unit test sono difficili da leggere e fragili, possono causare problemi nella codebase. Questo articolo descrive alcune procedure consigliate per la progettazione di unit test per supportare i progetti .NET Core e .NET Standard. Si apprenderà le tecniche per mantenere i test resilienti e facili da comprendere.

Di John Reese con un ringraziamento speciale a Roy Osherove

Vantaggi dei test unitari

Le sezioni seguenti descrivono diversi motivi per scrivere unit test per i progetti .NET Core e .NET Standard.

Minore tempo per l'esecuzione di test funzionali

I test funzionali sono costosi. In genere comportano l'apertura dell'applicazione e l'esecuzione di una serie di passaggi che tu (o qualcun altro) deve seguire per convalidare il comportamento previsto. Questi passaggi potrebbero non essere sempre noti al tester. Devono contattare qualcuno più esperto nella zona per eseguire il test. Il test stesso può richiedere secondi per modifiche semplici o minuti per modifiche più grandi. Infine, questo processo deve essere ripetuto per ogni modifica apportata nel sistema. Gli unit test, d'altra parte, richiedono millisecondi, possono essere eseguiti con la pressione di un pulsante e non richiedono necessariamente alcuna conoscenza del sistema in generale. È il test runner che determina se il test è superato o fallisce, non l'individuo.

Protezione dalla regressione

I difetti di regressione sono errori introdotti quando viene apportata una modifica all'applicazione. È comune per i tester non solo testare la nuova funzionalità, ma anche testare le funzionalità esistenti in anticipo per verificare che le funzionalità esistenti funzionino ancora come previsto. Con gli unit test, è possibile rieseguire l'intero gruppo di test dopo ogni compilazione o anche dopo aver modificato una riga di codice. Questo approccio consente di aumentare la probabilità che il nuovo codice non interrompa le funzionalità esistenti.

Documentazione eseguibile

Potrebbe non essere sempre ovvio cosa fa un particolare metodo o come si comporta in base a un determinato input. È possibile chiedersi: come si comporta questo metodo se si passa una stringa vuota o null? Quando si dispone di un gruppo di unit test ben denominati, ogni test deve spiegare chiaramente l'output previsto per un determinato input. Inoltre, il test deve essere in grado di verificare che funzioni effettivamente.

Codice meno accoppiato

Quando il codice è strettamente associato, può essere difficile eseguire unit test. Senza creare unit test per il codice che si sta scrivendo, l'accoppiamento potrebbe risultare meno evidente. La scrittura di test per il codice separa naturalmente il codice perché è più difficile da testare in caso contrario.

Caratteristiche degli unit test validi

Esistono diverse caratteristiche importanti che definiscono un buon unit test:

  • Veloce: non è insolito che i progetti maturi abbiano migliaia di unit test. L'esecuzione degli unit test dovrebbe richiedere poco tempo. Millisecondi.
  • Isolato: gli unit test sono autonomi, possono essere eseguiti in isolamento e non hanno dipendenze da fattori esterni, ad esempio un file system o un database.
  • Ripetibile: l'esecuzione di uno unit test deve essere coerente con i risultati. Il test restituisce sempre lo stesso risultato se non si cambia nulla tra le esecuzioni.
  • Autocontrollo: il test deve rilevare automaticamente se è stato superato o non riuscito senza alcuna interazione umana.
  • Tempestivo: uno unit test non deve richiedere tempi di scrittura sproporzionatamente lunghi rispetto al codice sottoposto a test. Se si scopre che il test del codice richiede molto tempo rispetto alla scrittura del codice, prendere in considerazione una progettazione più testabile.

Copertura e qualità del codice

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ò essere controproducente. Si consideri un progetto complesso con migliaia di rami condizionali e si supponga di impostare un obiettivo di 95% di copertura del codice%. Attualmente, il progetto gestisce 90% code coverage. La quantità di tempo necessaria per gestire tutti i casi limite nei rimanenti 5% può essere un'impresa enorme, e il valore aggiunto diminuisce rapidamente.

Una percentuale di code coverage elevata non è un indicatore del successo e non implica un'elevata qualità del codice. Rappresenta solo la quantità di codice coperta dagli unit test. Per ulteriori informazioni, vedere test unitari e copertura del codice.

Terminologia di unit test

Diversi termini vengono usati spesso nel contesto di unit test: falso, fittizio e stub. Sfortunatamente, questi termini possono essere malapplicati, quindi è importante comprendere l'utilizzo corretto.

  • Fake: un falso è un termine generico che può essere usato per descrivere uno stub o un oggetto fittizio. Se l'oggetto è uno stub o una simulazione dipende dal contesto in cui viene utilizzato l'oggetto. In altre parole, un falso può essere uno stub o una simulazione.

  • Oggetto fittizio: un oggetto fittizio è un falso oggetto nel sistema che determina se un test unitario viene superato o fallisce. Un simulacro inizia come un falso e rimane tale fino a quando non entra in un'operazione Assert.

  • Stub: uno stub è un sostituto controllabile per una dipendenza già esistente (o collaboratore) nel sistema. Usando uno stub, è possibile testare il codice senza gestire direttamente la dipendenza. Per impostazione predefinita, uno stub inizia come falso.

Osservare il codice seguente:

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

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Questo codice mostra uno stub denominato mock. Ma in questo scenario, lo stub è davvero uno stub. Lo scopo del codice è passare l'ordine come mezzo per istanziare l'oggetto Purchase (il sistema sottoposto a test). Il nome MockOrder della classe è fuorviante perché l'ordine è uno stub e non un fittizio.

Il codice seguente illustra una progettazione più accurata:

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

purchase.ValidateOrders();

Assert.True(purchase.CanBeShipped);

Quando la classe viene rinominata in FakeOrder, la classe è più generica. La classe può essere utilizzata come mock o stub, a seconda dei requisiti del caso di test. Nel primo esempio la FakeOrder classe viene usata come stub e non viene usata durante l'operazione Assert . Il codice passa la FakeOrder classe alla Purchase classe solo per soddisfare i requisiti del costruttore.

Per usare la classe come fittizia, è possibile aggiornare il codice:

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

purchase.ValidateOrders();

Assert.True(mockOrder.Validated);

In questa progettazione, il codice controlla una proprietà sul falso (asserendo contro di esso) e pertanto la mockOrder classe è una simulazione.

Importante

È importante implementare correttamente la terminologia. Se chiami i tuoi stub "mocks", altri sviluppatori faranno ipotesi false sulla tua finalità.

La cosa principale da ricordare sui mock e sugli stub è che i mock, ad eccezione del processo Assert, sono come gli stub. Si eseguono Assert operazioni su un oggetto fittizio, ma non su uno stub.

Procedure consigliate

Esistono diverse procedure consigliate importanti da seguire durante la scrittura di unit test. Le sezioni seguenti forniscono esempi che illustrano come applicare le procedure consigliate al codice.

Evitare dipendenze dell'infrastruttura

Provare a non introdurre dipendenze dall'infrastruttura durante la scrittura di unit test. Le dipendenze rendono i test lenti e fragili e devono essere riservati ai test di integrazione. È possibile evitare queste dipendenze nell'applicazione seguendo il principio delle dipendenze esplicite e usando l'inserimento delle dipendenze .NET. È anche possibile mantenere gli unit test in un progetto separato dai test di integrazione. Questo approccio garantisce che il progetto di unit test non abbia riferimenti o dipendenze dai pacchetti dell'infrastruttura.

Seguire gli standard di denominazione dei test

Il nome del test deve essere costituito da tre parti:

  • Nome del metodo sottoposto a test
  • Scenario in cui viene testato il metodo
  • Comportamento previsto quando viene richiamato lo scenario

Gli standard di denominazione sono importanti perché consentono di esprimere lo scopo e l'applicazione dei test. I test non sono solo per assicurarsi che il codice funzioni. Forniscono anche la documentazione. Esaminando semplicemente la suite di unit test, dovresti essere in grado di dedurre il comportamento del tuo codice senza dover consultare il codice stesso. Inoltre, quando i test hanno esito negativo, è possibile vedere esattamente quali scenari non soddisfano le aspettative.

codice originale

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

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

    Assert.Equal(0, actual);
}

Applicare la procedura consigliata

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

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

    Assert.Equal(0, actual);
}

Organizza i test

Il modello "Arrange, Act, Assert" è un approccio comune per la scrittura di unit test. Come suggerisce il nome, il modello è costituito da tre attività principali:

  • Disporre gli oggetti, crearli e configurarli in base alle esigenze
  • Agire su un oggetto
  • Asserire che qualcosa è come previsto

Quando si segue il modello, è possibile separare chiaramente ciò che viene testato dalle attività Arrange e Assert. Il modello consente anche di ridurre l'opportunità per le asserzioni di mescolarsi con il codice nell'attività Act.

La leggibilità è uno degli aspetti più importanti durante la scrittura di uno unit test. La separazione di ogni azione del modello all'interno del test evidenzia chiaramente le dipendenze necessarie per chiamare il codice, il modo in cui viene chiamato il codice e ciò che si sta tentando di asserire. Sebbene sia possibile combinare alcuni passaggi e ridurre le dimensioni del test, l'obiettivo complessivo è rendere il test il più leggibile possibile.

codice originale

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

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

Applicare la procedura consigliata

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

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

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

Scrivere test con un minimo di superamento

L'input per uno unit test deve essere rappresentato dalle informazioni più semplici necessarie per verificare il comportamento attualmente testato. L'approccio minimalista aiuta i test a diventare più resilienti alle modifiche future nella codebase e concentrarsi sulla verifica del comportamento sull'implementazione.

I test che includono più informazioni del necessario per superare il test corrente hanno una maggiore probabilità di introdurre errori nel test e possono rendere meno chiara la finalità del test. Quando si scrivono test, si vuole concentrarsi sul comportamento. L'impostazione di proprietà aggiuntive sui modelli o l'uso di valori diversi da zero quando non è necessario, sottrae solo ciò che si sta provando a confermare.

codice originale

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

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

    Assert.Equal(42, actual);
}

Applicare la procedura consigliata

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

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

    Assert.Equal(0, actual);
}

Evitare stringhe magiche

Le stringhe magiche sono valori stringa codificati direttamente negli unit test senza nessun commento o contesto aggiuntivo nel codice. Questi valori rendono il codice meno leggibile e più difficile da gestire. Le stringhe magiche possono causare confusione al lettore dei test. Se una stringa sembra fuori dall'ordinario, potrebbe chiedersi perché è stato scelto un determinato valore per un parametro o un valore restituito. Questo tipo di valore stringa potrebbe portare a esaminare in modo più approfondito i dettagli dell'implementazione, anziché concentrarsi sul test.

Suggerimento

Rendere l'obiettivo di esprimere il maggior numero possibile di finalità nel codice di unit test. Anziché usare stringhe magiche, assegnare qualsiasi eventuale valore hard-coded alle costanti.

codice originale

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

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

    Assert.Throws<OverflowException>(actual);
}

Applicare la procedura consigliata

[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 di codifica negli unit test

Quando si scrivono unit test, evitare la concatenazione manuale delle stringhe e le condizioni logiche, ad esempio if, while, for, e switch e altre condizioni. Se si include la logica nella suite di test, la possibilità di introdurre bug aumenta notevolmente. L'ultimo posto in cui si vuole trovare un bug è all'interno della suite di test. È necessario avere un livello elevato di attendibilità che i test funzionino, altrimenti non è possibile considerarli attendibili. I test non attendibili non forniscono alcun valore. Quando un test ha esito negativo, si vuole avere un senso che si è verificato un errore nel codice e che non può essere ignorato.

Suggerimento

Se l'aggiunta di logica nel test sembra inevitabile, è consigliabile suddividere il test in due o più test diversi per limitare i requisiti della logica.

codice originale

[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;
    }
}

Applicare la procedura consigliata

[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);
}

Usare metodi di supporto invece di Configurazione e Smontaggio

Se è necessario un oggetto o uno stato simile per i test, utilizzare un metodo helper piuttosto che gli attributi Setup e Teardown, se esistenti. I metodi helper sono preferiti rispetto a questi attributi per diversi motivi:

  • Meno confusione durante la lettura dei test perché tutto il codice è visibile dall'interno di ogni test
  • Meno probabilità di configurare troppo o troppo poco per il test specificato
  • Minore probabilità di condivisione dello stato tra i test, che crea dipendenze indesiderate tra di esse

Nei framework di test unitari, l'attributo Setup viene chiamato prima di ogni test unitario all'interno della suite di test. Alcuni programmatori vedono questo comportamento come utile, ma spesso comporta test gonfi e di difficile lettura. Ogni test ha in genere requisiti diversi per l'installazione e l'esecuzione. Sfortunatamente, l'attributo Setup impone di utilizzare esattamente gli stessi requisiti per ogni test.

Nota

Gli SetUp attributi e TearDown vengono rimossi in xUnit versione 2.x e successive.

codice originale

Applicare la procedura consigliata

private readonly StringCalculator stringCalculator;
public StringCalculatorTests()
{
    stringCalculator = new StringCalculator();
}
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var stringCalculator = CreateDefaultStringCalculator();

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

    Assert.Equal(1, actual);
}
// More tests...
// More tests...
[Fact]
public void Add_TwoNumbers_ReturnsSumOfNumbers()
{
    var result = stringCalculator.Add("0,1");

    Assert.Equal(1, result);
}
private StringCalculator CreateDefaultStringCalculator()
{
    return new StringCalculator();
}

Evitare attività multiple di compiti

Quando si scrivono i test, cercare di includere una sola attività Act per test. Alcuni approcci comuni per l'implementazione di una singola attività act includono la creazione di un test separato per ogni atto o l'uso di test con parametri. L'uso di un'unica attività act per ogni test offre diversi vantaggi:

  • Si può facilmente discernere quale attività Act fallisce se il test non riesce.
  • È possibile assicurarsi che il test sia incentrato solo su un singolo caso.
  • Si ottiene un'immagine chiara del motivo per cui i test hanno esito negativo.

Le attività Act multiple devono essere asserite singolarmente e non si può garantire l'esecuzione di tutte le attività di Assert. Nella maggior parte dei framework di unit test, dopo che un'attività Assert ha esito negativo in uno unit test, tutti i test successivi vengono considerati automaticamente non riusciti. Il processo può generare confusione perché alcune funzionalità funzionanti potrebbero essere interpretate come non riuscite.

codice originale

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

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

Applicare la procedura consigliata

[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 metodi privati con metodi pubblici

Nella maggior parte dei casi, non è necessario testare un metodo privato nel codice. I metodi privati sono un dettaglio di implementazione e non esistono mai in isolamento. A un certo punto del processo di sviluppo, viene introdotto un metodo pubblico per chiamare il metodo privato come parte della sua implementazione. Quando scrivi i tuoi test unitari, ciò che ti interessa è il risultato finale del metodo pubblico che chiama il metodo privato.

Si consideri lo scenario di codice seguente:

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

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

In termini di test, la prima reazione potrebbe essere scrivere un test per il TrimInput metodo per assicurarsi che funzioni come previsto. Tuttavia, è possibile che il ParseLogLine metodo manipola l'oggetto sanitizedInput in modo non previsto. Il comportamento sconosciuto potrebbe rendere inutile il test del metodo TrimInput.

Un test migliore in questo scenario consiste nel verificare il metodo pubblico ParseLogLine :

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

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

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

Quando si incontra un metodo privato, individuare il metodo pubblico che chiama il metodo privato ed eseguire i test contro il metodo pubblico. Solo perché un metodo privato restituisce un risultato previsto, non significa che il sistema che alla fine chiama il metodo privato usa correttamente il risultato.

Gestire riferimenti statici stub con sezioni

Un principio di uno unit test è che deve avere il controllo completo del sistema sottoposto a test. Tuttavia, questo principio può essere problematico quando il codice di produzione include chiamate a riferimenti statici , ad esempio DateTime.Now.

Esaminare lo scenario di codice seguente:

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

È possibile scrivere uno unit test per questo codice? È possibile provare a eseguire un'attività Assert in price:

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 rende conto rapidamente che ci sono alcuni problemi con il test:

  • Se il gruppo di test viene eseguito martedì, il secondo test viene superato, ma il primo test ha esito negativo.
  • Se il gruppo di test viene eseguito in qualsiasi altro giorno, il primo test viene superato, ma il secondo test ha esito negativo.

Per risolvere questi problemi, è necessario introdurre un seam nel codice di produzione. Un approccio consiste nel eseguire il wrapping del codice che è necessario controllare in un'interfaccia e il codice di produzione dipende 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;
    }
}

È anche necessario scrivere una nuova versione del gruppo di test:

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 il controllo completo sul valore DateTime.Now e può simulare qualsiasi valore quando si chiama il metodo.