Property-Based Testing: un approccio rivoluzionario al testing del software
Nel mondo dello sviluppo software, il testing è una delle attività più critiche per garantire la qualità del prodotto. Tuttavia, i test tradizionali basati su esempi specifici spesso lasciano scoperte molte situazioni impreviste.
Ad esempio, per testare la proprietà commutativa dell’addizione, potremmo scrivere test basati su casi specifici:
[Theory]
[InlineData(4, 5)]
[InlineData(3, 2)]
public void Can_Add_Respect_The_Commutative_Property(int number1, int number2)
{
var result1 = Calculator.Calculator.Add(number1, number2);
var result2 = Calculator.Calculator.Add(number2, number1);
Assert.Equal(result1, result2);
}
Questo test verifica solo alcuni casi specifici (4+5 e 3+2), non garantisce che la funzione, o il metodo, valga per tutti i numeri possibili. Questa limitazione porta a una domanda fondamentale: è possibile testare in modo più efficace senza scrivere infiniti casi di test manualmente?
Qui entra in gioco il Property-Based Testing (PBT), un approccio innovativo che cambia radicalmente il modo in cui pensiamo al testing. In questo articolo esploreremo i concetti fondamentali del PBT, i suoi vantaggi e come applicarlo con strumenti pratici.
Il contesto: perché PBT?
I test tradizionali (Example-Based Testing) si basano su un insieme finito di input definiti dallo sviluppatore. Questo approccio presenta alcune limitazioni:
- Copertura limitata: i test coprono solo i casi specifici definiti.
- Mancata scoperta di edge case: situazioni limite o non previste possono facilmente sfuggire.
Il Property-Based Testing adotta un approccio diverso: invece di concentrarsi su input specifici, si definiscono proprietà generali che devono valere per un’ampia gamma di input.
Esempio pratico: consideriamo una funzione di ordinamento. Una proprietà generale potrebbe essere: “Se ordino una lista, il risultato deve essere ordinato e contenere gli stessi elementi della lista originale”. Questa regola è valida indipendentemente dagli input specifici.
Come funziona il Property-Based Testing?
Il PBT si basa su tre concetti fondamentali:
- Proprietà: regole generali che il sistema deve rispettare.
- Generazione di input casuali: gli strumenti di PBT generano automaticamente una vasta gamma di input casuali per testare le proprietà.
- Shrinkage: in caso di fallimento, gli strumenti riducono l’input problematico al caso più semplice che causa l’errore, facilitando il debugging.
Flusso di lavoro tipico
- Definisci le proprietà da verificare.
- Usa uno strumento di PBT per generare input casuali.
- Analizza i risultati dei test e risolvi eventuali problemi.
Example-Based Testing vs. Property-Based Testing
| Example-Based Testing | Property-Based Testing |
|---|---|
| Si testano casi specifici definiti manualmente. | Si testano proprietà generali applicabili a qualsiasi input. |
| Copertura limitata. | Copertura più ampia grazie alla generazione automatica di input. |
| Difficile scoprire edge case. | Edge case spesso scoperti automaticamente. |
| Necessita di molti test specifici. | Un singolo test può coprire molti casi. |
Vantaggi del Property-Based Testing
- Maggiore copertura: testa automaticamente un numero elevato di casi, spesso includendo edge case non previsti.
- Individuazione di bug nascosti: scopre errori che potrebbero sfuggire ai test tradizionali.
- Manutenzione ridotta: una singola proprietà ben definita può coprire decine di casi di test manuali.
- Adatto a sistemi complessi: ideale per testare algoritmi, librerie matematiche o protocolli.
Esempi pratici in C# con FsCheck
Di seguito vengono riportati due esempi pratici in C# di Property-Based Testing utilizzando [FsCheck (https://fscheck.github.io/FsCheck/), libreria per il Property-Based Testing in .NET.
- Addizione: una semplice funzione di addizione.
- FizzBuzz: un esercizio comune per testare la logica condizionale.
Addizione
Le proprietà fondamentali dell’addizione sono:
- Commutativa: a + b = b + a
- Associativa: a + (b + c) = (a + b) + c
- Esistenza dell’elemento neutro: a + 0 = a
Codice:
namespace Calculator;
public static class Calculator
{
public static int Add(int a, int b)
{
return a + b;
}
}
Unit test:
using FsCheck;
using FsCheck.Xunit;
namespace CalculatorUnitTests;
public class PropertyBasedTestCalculator
{
[Property(Arbitrary = [typeof(RandomNumberGenerator)])]
public Property AddRespectsCommutativityProperty(int a, int b)
{
var result1 = Calculator.Calculator.Add(a, b);
var result2 = Calculator.Calculator.Add(b, a);
return (result1 == result2).ToProperty();
}
[Property(Arbitrary = [typeof(RandomNumberGenerator)])]
public Property AddRespectsAssociativityProperty(int a, int b, int c)
{
var result1 = Calculator.Calculator.Add(a, Calculator.Calculator.Add(b, c));
var result2 = Calculator.Calculator.Add(Calculator.Calculator.Add(a, b), c);
return (result1 == result2).ToProperty();
}
[Property(Arbitrary = [typeof(RandomNumberGenerator)])]
public Property AddRespectsIdentityProperty(int a)
{
var result = Calculator.Calculator.Add(a, 0);
return (result == a).ToProperty();
}
}
Generatori:
using FsCheck;
namespace CalculatorUnitTests;
public static class RandomNumberGenerator
{
public static Arbitrary<int> Generate()
{
return Arb.Default.Int32().Filter(n => n > 0);
}
}
FizzBuzz
Le proprietà fondamentali per FizzBuzz sono:
- I multipli di tre restituiscono `Fizz`
- I multipli di cinque restituiscono `Buzz`
- I multipli di tre e cinque restituiscono `FizzBuzz`
- Gli altri numeri restituiscono il numero stesso
Codice:
namespace FizzBuzz;
public static class FizzBuzz
{
public static string CheckNumber(int number)
{
if (number % 3 == 0 && number % 5 == 0)
return "FizzBuzz";
if (number % 5 == 0)
return "Fizz";
return number % 3 == 0
? "Buzz"
: number.ToString();
}
}
Unit test:
using FsCheck;
using FsCheck.Xunit;
namespace FizzBuzzUnitTests;
public class FizzBuzzPropertyBasedTests
{
[Property(Arbitrary = [typeof(RandomMultipleOfThreeGenerator)])]
public Property MultiplesOfThreeShouldBeReplacedWithFizz(int numberToCheck)
{
return FizzBuzz.FizzBuzz.ChkNumber(numberToCheck).Contains("Fizz").ToProperty();
}
[Property(Arbitrary = [typeof(RandomMultipleOfFiveGenerator)])]
public Property MultiplesOfFiveShouldBeReplacedWithBuzz(int numberToCheck)
{
return FizzBuzz.FizzBuzz.ChkNumber(numberToCheck).Contains("Buzz").ToProperty();
}
[Property(Arbitrary = [typeof(RandomMultipleOfThreeAndFiveGenerator)])]
public Property MultiplesOfThreeAndFiveShouldBeReplacedWithFizzBuzz(int numberToCheck)
{
return FizzBuzz.FizzBuzz.ChkNumber(numberToCheck).Contains("FizzBuzz").ToProperty();
}
[Property(Arbitrary = [typeof(RandomNonMultipleOfThreeOrFiveGenerator)])]
public Property NonMultiplesOfThreeAndFiveShouldBeReplacedWithTheNumberItself(int numberToCheck)
{
return FizzBuzz.FizzBuzz.ChkNumber(numberToCheck).Equals(numberToCheck.ToString()).ToProperty();
}
}
Generatori:
using FsCheck;
namespace FizzBuzzUnitTests;
public static class RandomMultipleOfThreeGenerator
{
public static Arbitrary<int> Generate()
{
return Arb.Default.Int32().Filter(n => n is >= 1 and <= 100 && n % 3 == 0);
}
}
public static class RandomMultipleOfFiveGenerator
{
public static Arbitrary<int> Generate()
{
return Arb.Default.Int32().Filter(n => n is >= 1 and <= 100 && n % 5 == 0);
}
}
public static class RandomMultipleOfThreeAndFiveGenerator
{
public static Arbitrary<int> Generate()
{
return Arb.Default.Int32().Filter(n => n is >= 1 and <= 100 && n % 15 == 0);
}
}
public static class RandomNonMultipleOfThreeOrFiveGenerator
{
public static Arbitrary<int> Generate()
{
return Arb.Default.Int32().Filter(n => n is >= 1 and <= 100 && n % 3 != 0 && n % 5 != 0);
}
}
Sfide e limiti del Property-Based Testing
- Definizione delle proprietà: identificare proprietà utili e significative può essere complesso.
- Complessità iniziale: richiede una curva di apprendimento per comprendere e utilizzare gli strumenti.
- Performance: l’esecuzione di test su input casuali può essere più lenta rispetto ai test tradizionali.
Come superare le sfide?
- Inizia con proprietà semplici: prova a formalizzare le regole di base del sistema.
- Combina Example-Based e Property-Based Testing: usa entrambi per un testing più efficace.
- Affidati agli strumenti di shrinkage: aiutano a isolare gli errori rapidamente.
Quando (e quando non) usare PBT
Ideale per:
- Algoritmi complessi.
- Sistemi con regole ben definite.
- Librerie matematiche o funzioni pure.
Meno indicato per:
- Sistemi con interfacce grafiche.
- Applicazioni in cui è difficile formalizzare le proprietà.
Conclusione
Il Property-Based Testing rappresenta un approccio potente per migliorare la qualità del software, superando i limiti dei test tradizionali. Grazie alla sua capacità di generare automaticamente una vasta gamma di input e di identificare edge case, il PBT è uno strumento essenziale per gli sviluppatori che lavorano su sistemi complessi.