A cosa servono i test unitari
Un test unitario testa un'unità di codice in isolamento: nessun database, nessuna rete, nessun file system. Il test parte, esegue, termina in millisecondi. Non c'è nulla da preparare e nulla da smontare.
Questo li rende diversi dai test di integrazione per scopo, non solo per velocità. Servono a tre cose distinte.
1. Testare logica pura senza dipendenze
La business logic che non tocca infrastruttura — entity di dominio, value object, algoritmi, regole di validazione — si testa in isolamento. Non serve un database per verificare che un ordine confermato non possa essere annullato.
[TestFixture]
public class OrdineTests
{
[Test]
public void Ordine_confermato_non_puo_essere_annullato()
{
var ordine = new Ordine(clienteId: 1, importo: 100m);
ordine.Conferma();
var act = () => ordine.Annulla("ripensamento");
act.Should().Throw<InvalidOperationException>()
.WithMessage("*confermato*");
}
[Test]
public void Importo_negativo_non_e_ammesso()
{
var act = () => new Ordine(clienteId: 1, importo: -1m);
act.Should().Throw<ArgumentException>();
}
[Test]
public void Sconto_non_puo_superare_il_totale()
{
var ordine = new Ordine(clienteId: 1, importo: 50m);
var act = () => ordine.ApplicaSconto(60m);
act.Should().Throw<InvalidOperationException>();
}
}
Questi test sono anche la documentazione più precisa delle regole di dominio: chiunque legga i nomi dei metodi capisce cosa il sistema permette e cosa vieta, senza aprire un manuale.
2. Monitorare le librerie di terze parti
Una libreria esterna ha un ciclo di vita indipendente dal progetto. Quando viene aggiornata — intenzionalmente o perché un tool di aggiornamento automatico lo fa — il suo comportamento può cambiare in modo sottile: una edge case gestita diversamente, un default modificato, una funzione deprecata che ora si comporta in modo diverso.
I test che documentano il comportamento atteso di una libreria diventano il sensore che rileva questi cambiamenti.
[TestFixture]
public class FluentValidationBehaviorTests
{
// Documenta: NotEmpty() tratta stringa vuota e null allo stesso modo
[TestCase("")]
[TestCase(null)]
public void NotEmpty_rifiuta_stringa_vuota_e_null(string? valore)
{
var validator = new InlineValidator<string?>();
validator.RuleFor(x => x).NotEmpty();
validator.Validate(valore).IsValid.Should().BeFalse();
}
// Documenta: il messaggio di errore predefinito contiene il nome del campo
[Test]
public void NotEmpty_include_il_nome_del_campo_nel_messaggio()
{
var validator = new InlineValidator<CreaOrdineRequest>();
validator.RuleFor(x => x.ClienteId).NotEmpty();
var result = validator.Validate(new CreaOrdineRequest());
result.Errors.Single().ErrorMessage.Should().Contain("Cliente Id");
}
// Documenta: When() non esegue la regola se la condizione è falsa
[Test]
public void When_salta_la_validazione_se_condizione_falsa()
{
var validator = new InlineValidator<CreaOrdineRequest>();
validator.RuleFor(x => x.DataConsegna)
.GreaterThan(DateTime.Today)
.When(x => x.DataConsegna.HasValue);
var request = new CreaOrdineRequest { DataConsegna = null };
validator.Validate(request).IsValid.Should().BeTrue();
}
}
Se dopo un aggiornamento di FluentValidation uno di questi test fallisce, il fallimento è intenzionale: segnala che il comportamento è cambiato e che va rivalutato prima di procedere con l'aggiornamento.
3. Coltellino svizzero per dubbi puntuali
Non tutto nasce da un requisito. A volte si vuole capire come funziona qualcosa di preciso: come DateTimeOffset gestisce le conversioni tra fusi orari, come string.Split tratta i separatori multipli, come EF serializza un tipo custom senza andare a leggere la documentazione per venti minuti.
Il test è il posto più rapido e preciso dove chiarirsi le idee. Una volta scritto, rimane come documentazione eseguibile per chiunque abbia lo stesso dubbio in futuro.
[TestFixture]
public class DateTimeOffsetBehaviorTests
{
[Test]
public void ToUniversalTime_converte_correttamente_da_fuso_europeo()
{
// UTC+2 in estate (ora legale italiana)
var offset = new DateTimeOffset(2024, 6, 15, 10, 0, 0, TimeSpan.FromHours(2));
offset.ToUniversalTime().Hour.Should().Be(8);
}
[Test]
public void Due_DateTimeOffset_con_stesso_istante_e_fusi_diversi_sono_uguali()
{
var utc = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
var rome = new DateTimeOffset(2024, 1, 1, 13, 0, 0, TimeSpan.FromHours(1));
utc.Should().Be(rome); // stesso istante, fuso diverso
}
}
[TestFixture]
public class StringSplitBehaviorTests
{
[Test]
public void Split_con_separatore_multiplo_non_produce_elementi_vuoti_con_RemoveEmptyEntries()
{
var risultato = "a,,b,,c".Split(',', StringSplitOptions.RemoveEmptyEntries);
risultato.Should().BeEquivalentTo(["a", "b", "c"]);
}
[Test]
public void Split_senza_opzioni_include_gli_elementi_vuoti()
{
var risultato = "a,,b".Split(',');
risultato.Should().HaveCount(3);
risultato[1].Should().BeEmpty();
}
}
Questi test non nascono da un bug o da un requisito: nascono da una domanda. Risponderla con un test invece che con un breakpoint o una lettura veloce di Stack Overflow produce qualcosa di permanente e condivisibile.