Property-Based Testing
Code

Property-Based Testing: un approccio rivoluzionario al testing del software

6 Marzo 2025 - 5 minuti di lettura

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:

  1. Proprietà: regole generali che il sistema deve rispettare.
  2. Generazione di input casuali: gli strumenti di PBT generano automaticamente una vasta gamma di input casuali per testare le proprietà.
  3. 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

  1. Definisci le proprietà da verificare.
  2. Usa uno strumento di PBT per generare input casuali.
  3. 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.

Articolo scritto da