Il testing automatico con xUnit in C#

Il testing automatico con xUnit in C#

Concludiamo la serie avanzata, e con essa l'intero percorso, con un argomento che distingue il codice professionale da quello improvvisato: i test automatici. Un buon insieme di test permette di modificare il software con fiducia, sapendo che eventuali regressioni verranno segnalate immediatamente. Nell'ecosistema .NET, xUnit è uno dei framework di test più diffusi. In questo articolo impariamo a scrivere test unitari, a strutturarli correttamente e a isolare il codice dalle sue dipendenze, mettendo a frutto la dependency injection studiata in precedenza.

Perché scrivere test

I test automatici verificano che il codice si comporti come previsto, eseguendo automaticamente scenari definiti dallo sviluppatore. I loro benefici sono concreti: individuano i difetti presto, documentano il comportamento atteso, e soprattutto fungono da rete di sicurezza quando si modifica o si estende il codice. Senza test, ogni modifica è un salto nel buio; con i test, è un'operazione controllata.

Impostare un progetto di test

I test vivono tipicamente in un progetto separato, dedicato. Si crea con l'apposito template e si collega al progetto da testare, così da poterne usare le classi.

dotnet new xunit -o MyApp.Tests
cd MyApp.Tests
dotnet add reference ../MyApp/MyApp.csproj

Il primo test

In xUnit, un test è un metodo marcato con l'attributo Fact. Al suo interno si esegue il codice da verificare e si controlla il risultato con le asserzioni della classe statica Assert. Se un'asserzione fallisce, il test viene segnalato come fallito.

using Xunit;

public class CalculatorTests
{
    [Fact]
    public void Add_ShouldReturnSumOfTwoNumbers()
    {
        // Arrange: prepara l'oggetto e i dati
        var calculator = new Calculator();

        // Act: esegue l'operazione da testare
        int result = calculator.Add(2, 3);

        // Assert: verifica il risultato atteso
        Assert.Equal(5, result);
    }
}

La struttura in tre fasi, nota come Arrange-Act-Assert, è una convenzione preziosa: separa la preparazione, l'esecuzione e la verifica, rendendo ogni test chiaro e facile da leggere. Anche i nomi dei test seguono spesso uno schema descrittivo che comunica cosa si sta verificando e in quali condizioni.

Le asserzioni

La classe Assert offre numerosi metodi per verificare condizioni diverse. Conoscere i principali permette di esprimere le aspettative in modo preciso.

// Uguaglianza tra valori
Assert.Equal(expected, actual);

// Verifica di un booleano
Assert.True(condition);
Assert.False(condition);

// Verifica dei nulli
Assert.Null(value);
Assert.NotNull(value);

// Verifica sulle collezioni
Assert.Contains(item, collection);
Assert.Empty(collection);

// Verifica che venga sollevata un'eccezione
Assert.Throws<ArgumentException>(() => service.Process(null));

Test parametrici con Theory

Spesso si vuole verificare la stessa logica con più set di dati. Ripetere il test sarebbe ridondante: xUnit offre l'attributo Theory, che insieme a InlineData esegue lo stesso metodo con diversi valori, ciascuno considerato un caso a sé.

public class NumberTests
{
    [Theory]
    [InlineData(2, true)]
    [InlineData(3, false)]
    [InlineData(4, true)]
    [InlineData(0, true)]
    public void IsEven_ShouldDetectEvenNumbers(int number, bool expected)
    {
        var checker = new NumberChecker();

        bool result = checker.IsEven(number);

        Assert.Equal(expected, result);
    }
}

Testare codice con dipendenze

Qui torna utile la dependency injection. Una classe che dipende da un'astrazione può essere testata in isolamento fornendole un'implementazione finta, che simula il comportamento desiderato senza effetti collaterali reali. Questo consente di testare la logica senza toccare database, rete o file.

// La classe da testare dipende da un'interfaccia
class OrderProcessor
{
    private readonly IPaymentGateway _gateway;

    public OrderProcessor(IPaymentGateway gateway)
    {
        _gateway = gateway;
    }

    public bool Process(decimal amount)
    {
        return _gateway.Charge(amount);
    }
}

Un modo semplice per fornire un'implementazione finta è scrivere una classe di test che implementi l'interfaccia con un comportamento prevedibile, chiamata stub o fake.

// Implementazione finta che restituisce sempre successo
class FakePaymentGateway : IPaymentGateway
{
    public bool Charge(decimal amount) => true;
}

public class OrderProcessorTests
{
    [Fact]
    public void Process_ShouldSucceed_WhenGatewayApproves()
    {
        var processor = new OrderProcessor(new FakePaymentGateway());

        bool result = processor.Process(100m);

        Assert.True(result);
    }
}

I mock con una libreria dedicata

Scrivere a mano un'implementazione finta per ogni scenario diventa presto oneroso. Le librerie di mocking, come Moq, permettono di creare oggetti finti al volo e di configurarne il comportamento in modo dichiarativo, oltre a verificare che determinati metodi siano stati chiamati.

using Moq;

[Fact]
public void Process_ShouldCallGatewayOnce()
{
    // Crea un mock dell'interfaccia
    var gatewayMock = new Mock<IPaymentGateway>();

    // Configura il comportamento del metodo
    gatewayMock.Setup(g => g.Charge(It.IsAny<decimal>())).Returns(true);

    var processor = new OrderProcessor(gatewayMock.Object);
    processor.Process(50m);

    // Verifica che il metodo sia stato chiamato esattamente una volta
    gatewayMock.Verify(g => g.Charge(50m), Times.Once);
}

Eseguire i test

I test si eseguono da riga di comando, e lo strumento riporta quanti test sono passati e quali sono falliti. Questo comando viene tipicamente integrato nelle pipeline di integrazione continua, così da eseguire l'intera suite a ogni modifica del codice.

dotnet test

Buone pratiche

Alcuni principi aiutano a scrivere test di valore:

  • Ogni test dovrebbe verificare una sola cosa, così che un fallimento indichi con precisione cosa è andato storto.
  • I test devono essere indipendenti tra loro e ripetibili, producendo sempre lo stesso esito a prescindere dall'ordine di esecuzione.
  • Vanno testati sia i casi normali sia quelli limite e di errore, dove spesso si annidano i difetti.
  • Un test deve essere veloce: una suite lenta viene eseguita di rado, perdendo gran parte della sua utilità.

Conclusione

Abbiamo imparato a scrivere test automatici con xUnit, strutturandoli secondo lo schema Arrange-Act-Assert, verificando i risultati con le asserzioni, coprendo più casi con i test parametrici Theory e isolando il codice dalle dipendenze tramite fake e mock, resi possibili dalla dependency injection. Con questo si chiude non solo la serie avanzata, ma l'intero percorso su C#.

Dalle basi del linguaggio e dell'ecosistema .NET siamo arrivati a costruire, persistere e testare un'applicazione completa, passando per la programmazione orientata agli oggetti, i generics, LINQ, l'asincronia, i principi di progettazione e i framework applicativi. Ventuno articoli che costituiscono una fondazione solida. Da qui, l'esplorazione può proseguire verso la containerizzazione, l'architettura a microservizi, le prestazioni avanzate e i molti domini in cui C# eccelle: le fondamenta per affrontarli sono ormai al loro posto.