Quasi tutti gli sviluppatori dicono che l'unit testing è importante. Farlo davvero, in modo sistematico, è un'altra storia. Scrivere test prima del codice sembra una perdita di tempo finché non hai pagato il conto di non averlo fatto.
Il Test-Driven Development ribalta l'ordine: prima scrivi il test (che fallisce), poi il codice che lo fa passare, poi rifattorizzi. Niente magia, solo un ciclo stretto fra cosa vuoi che faccia e cosa fa davvero. Nella pratica il test può arrivare anche dopo: quello che conta è mantenere quel ciclo stretto, non l'ordine. Dibattere se viene prima il test o il codice è il modo più rapido per perdere chiunque voglia cominciare.
Il loop del TDD
- Scrivi un test per la funzionalità (o il bug) da implementare
- Verifica che il test fallisca: se passa subito, non stai testando niente
- 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);
}
} Quando il setup è costoso (avviare un database in-memory, costruire un client HTTP, caricare dati di test), riscriverlo per ogni metodo è uno spreco. Le fixture risolvono questo: xUnit costruisce l'istanza una volta per tutta la classe e l'inietta nel costruttore. Il Dispose gira alla fine, dopo l'ultimo test.
// 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());
}
} 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
Non tutti i test sono uguali. Gli unit test girano in millisecondi, senza dipendenze esterne, e ne scrivi molti. Gli integration test mettono insieme due o più moduli con dipendenze reali o mockate, più lenti, ma intercettano problemi che gli unit test non vedono mai. In cima ci sono gli end-to-end, che simulano il percorso di un utente reale: lenti, fragili, ne bastano pochi per coprire i flussi critici.
La forma a piramide è intenzionale: molti unit test alla base perché costano poco da scrivere e girare, pochi E2E in cima perché si rompono per ogni cambiamento di layout. Un E2E che fallisce ogni volta che sposti un pulsante non ti protegge. Ti rallenta soltanto.
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 raddrizza da sola.
Quanto costa davvero
Non vendiamoci favole. Scrivere test costa tempo, e all'inizio sembra tempo "perso" rispetto a far partire la nuova feature.
Tempo di scrittura iniziale
Scrivere test che coprano una funzionalità decentemente richiede 30-60% di tempo in più sulla feature stessa. Per un'azienda che misura sulla velocità di consegna, è una pillola difficile da ingoiare. Il payback è dopo 3-6 mesi, quando il primo bug grosso che avresti avuto in produzione lo prendi sul branch in 5 secondi.
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% è un trofeo da esporre, non un obiettivo da inseguire. Concentrati sulla business logic, sui calcoli, sulle aree dove un bug costa soldi veri. Il resto può aspettare.
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 fixarlo. Così hai la certezza di aver risolto il problema reale e non solo i sintomi visibili. E il test resta nella codebase: chiunque tocchi quel codice dopo, incluso te tra sei mesi, non può riprodurre lo stesso bug senza che la CI se ne accorga.
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.
// cinque mock in un test: il problema è nel design, non nel test
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);Cinque mock per testare un metodo è un odore di design: quella classe sta facendo troppo.
CI: i test che girano da soli
Il problema non è scrivere i test, è ricordarsi di eseguirli. La CI risolve questo: gira la suite a ogni push senza che nessuno debba pensarci. Esempio con GitHub Actions:
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.slnCon questa CI, ogni commit passa la suite prima di poter essere mergiato. È quello che trasforma "test scritti" in "test che proteggono davvero la codebase".
I test non eliminano i bug. Ti danno il coraggio di cambiare il codice senza paura di rompere qualcosa che funzionava. Questo, alla lunga, vale il tempo che ci vuole per scriverli.
"Write tests until fear is transformed into boredom" - Kent Beck


