TDD e unit testing in C#: da un test che fallisce alla CI

Il ciclo TDD, gli esempi con xUnit e i limiti delle fixture: come scegliere cosa testare e usare la CI per controllare le regressioni.

Prima di correggere un bug, scrivi un test che lo riproduca. Il test deve fallire sul comportamento sbagliato e passare dopo la correzione. Questo controllo ti aiuta a capire se hai risolto il caso segnalato e lascia una verifica ripetibile nel progetto.

Il Test-Driven Development ribalta l'ordine: prima scrivi il test (che fallisce), poi il codice che lo fa passare, poi rifattorizzi mantenendo i test verdi. Scrivere test dopo il codice è comunque utile, ma nel TDD l'ordine serve a guidare l'implementazione un comportamento alla volta.

Un test che fallisce, il codice, poi il refactoring

  • Scrivi un test per la funzionalità (o il bug) da implementare
  • Verifica che il test fallisca per il comportamento mancante, non per un errore nel setup
  • Scrivi il codice minimo per farlo passare
  • Refactoring, poi ripeti

Esempio pratico con xUnit, framework di testing per C#. Il pattern Arrange / Act / Assert divide ogni test in tre fasi: prepari l'input, esegui il codice, verifichi il risultato.

public class Calculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}

public class CalculatorTests
{
    [Fact]
    public void Add_ReturnsCorrectSum()
    {
        // Arrange
        var calculator = new Calculator();
        int a = 5, b = 3;

        // Act
        var result = calculator.Add(a, b);

        // Assert
        Assert.Equal(8, result);
    }
}

Una class fixture permette di condividere il setup tra i test della stessa classe. xUnit costruisce l'istanza e la passa ai costruttori dei test; il Dispose viene chiamato alla fine. I dati modificabili richiedono attenzione: un test non deve dipendere da ciò che un altro ha lasciato nella fixture.

// La fixture si costruisce una volta per tutta la classe, non per ogni test
public class DatabaseFixture : IDisposable
{
    public AppDbContext Db { get; }

    public DatabaseFixture()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase("TestDb")
            .Options;
        Db = new AppDbContext(options);
        Db.Database.EnsureCreated();
    }

    public void Dispose() => Db.Dispose();
}

public class OrderRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly AppDbContext _db;

    public OrderRepositoryTests(DatabaseFixture fixture)
    {
        _db = fixture.Db;
    }

    [Fact]
    public void Save_PersistsOrder()
    {
        var order = new Order { Amount = 150 };
        _db.Orders.Add(order);
        _db.SaveChanges();

        Assert.Equal(1, _db.Orders.Count());
    }
}

L'esempio illustra il ciclo di vita della fixture, non un test del database di produzione. Il provider InMemory di EF Core non riproduce il comportamento di un database relazionale; Microsoft ne sconsiglia l'uso come sostituto nei test. Per verificare query e vincoli, scegli una strategia che copra il database usato dall'applicazione.

Per condividere la stessa fixture fra più classi di test si usa ICollectionFixture<T> con l'attributo [Collection]. Stesso concetto, scope più largo.

La piramide dei test

Gli unit test verificano parti isolate della logica. I test di integrazione controllano la collaborazione tra componenti, per esempio l'accesso a un database. Gli end-to-end attraversano un percorso dell'applicazione dal punto di vista dell'utente. Ogni livello può rilevare problemi che gli altri non coprono.

La piramide suggerisce molti controlli rapidi e isolati, con un numero più contenuto di test sui flussi completi. Le proporzioni dipendono dall'applicazione. Se un E2E fallisce per una modifica innocua al layout, rivedi i selettori e le verifiche prima di concludere che quel livello di test sia inutile.

Per chi parte da una codebase legacy senza test, ha senso invertire temporaneamente l'ordine. Un E2E sul flusso principale si aggiunge senza toccare il codice esistente. Poi, man mano che rifattorizzi, costruisci integration e unit test sotto: la piramide si costruisce insieme alla conoscenza del sistema.

Quanto costa davvero

Scrivere e mantenere test richiede tempo. Per valutarlo, considera anche il lavoro di verifica manuale e di diagnosi dei problemi dopo una modifica.

Tempo di scrittura iniziale

Non c'è una percentuale valida per ogni progetto. Una funzione isolata può richiedere pochi casi; un flusso con molte dipendenze richiede anche dati e ambiente di prova. Misura il tempo sul tuo progetto e parti dalle regressioni più frequenti o costose.

Curva di apprendimento

Scrivere test buoni è un mestiere, non un riflesso automatico. La differenza fra un test utile e un test che fa solo finta di testare è l'esperienza. Serve tempo, qualche libro decente (Kent Beck su tutti) e qualche errore.

Codice non testabile

Le applicazioni grandi con molte dipendenze e codice spaghetti sono un incubo da testare. Per renderle testabili serve refactoring strutturale: dependency injection, separazione fra logica e I/O, rottura delle dipendenze cicliche. È la parte di TDD che spaventa di più, perché tocca il codice esistente.

Manutenzione dei test

I test invecchiano insieme al codice. Cambi una signature, devi sistemare 12 test. È un costo continuo che si riduce solo scrivendo test al livello giusto: troppo accoppiati all'implementazione e cambierai test ogni volta, troppo astratti e non testano niente.

La copertura al 100% non dimostra da sola che le verifiche siano utili. Concentrati sulle regole di business, sui calcoli e sui casi limite: una riga eseguita può comunque produrre un risultato sbagliato che il test non controlla.

Cosa testare e cosa no

Vale la pena testare sempre

Calcoli (importi, sconti, IVA), regole di business, parser e validatori, tutto ciò che ha edge case (date, fusi orari, valute). Il costo del test è basso e la regressione, quando arriva, costa caro. xUnit supporta i test parametrici con Theory e InlineData: un solo metodo per coprire il caso base, la soglia esatta e il caso limite.

[Theory]
[InlineData(99.99, 0)]   // sotto soglia: nessuno sconto
[InlineData(100,   10)]  // soglia esatta: sconto applicato
[InlineData(250,   10)]  // sopra soglia: stesso sconto
public void GetDiscount_AppliesThreshold(decimal amount, decimal expected)
{
    var discount = PricingRules.GetDiscount(amount);
    Assert.Equal(expected, discount);
}

Spesso non conviene testare

Codice glue (controller che chiamano servizi che chiamano repo), wrapper banali, codice che cambia ogni settimana per A/B test. Per questi, un integration test che copre il flusso vale più di dieci unit test sul singolo metodo.

Il bug report è un caso a parte

Quando trovi un bug, la mossa giusta è scrivere prima un test che lo riproduce. Poi correggilo e verifica che il test passi. Il controllo copre il caso riprodotto: valuta anche gli input vicini e conserva il test nella suite eseguita dalla CI.

Mocking, ma con misura

Il mocking serve a isolare l'unità sotto test dalle dipendenze esterne (database, API). Se ti ritrovi a mockare metà del file di test, di solito è il codice che ha troppe dipendenze, non il mock che è sbagliato.

// Quattro dipendenze sostituite: verifica quali servono al comportamento testato
var repo    = Substitute.For<IOrderRepository>();
var emailer = Substitute.For<IEmailer>();
var stock   = Substitute.For<IInventory>();
var pricing = Substitute.For<IPricingEngine>();

var svc = new OrderService(repo, emailer, stock, pricing);
svc.Process(order);

Nell'esempio ci sono quattro dipendenze sostituite. Chiediti se sono tutte necessarie per il comportamento verificato e se la classe sta coordinando troppe responsabilità.

CI: i test che girano da soli

La CI esegue la suite agli eventi configurati, senza affidarsi a una verifica manuale. Questo esempio con GitHub Actions parte a ogni push su main; sostituisci il nome della soluzione e aggiungi l'evento pull_request se vuoi controllare anche le proposte di modifica.

name: .NET

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
      - name: Build with dotnet
        run: dotnet build YourSolution.sln --configuration Release
      - name: Run tests
        run: dotnet test YourSolution.sln

Il workflow esegue i test agli eventi configurati. Per impedire un merge quando falliscono, imposta il controllo come obbligatorio nelle regole di protezione del branch.

Per iniziare, scegli una regola di business o un bug riproducibile. Scrivi una verifica che fallisca per il motivo atteso, correggi il codice e falla eseguire dalla CI. Poi estendi la suite dove una regressione avrebbe conseguenze concrete.

Per approfondire: il ciclo TDD descritto da Martin Fowler e la documentazione xUnit sulle fixture.