
Nel mio precedente articolo, abbiamo visto come testare e rifattorizzare un progetto legacy utilizzando il Golden Master Pattern. Stavolta, continueremo a parlare di testing, introducendo un nuovo paradigma attraverso un semplice esempio.
Siamo abituati a scrivere test basandoli su esempi. In letteratura si parla di “Example test“: dato un input abbiamo un output atteso.
La diffusione di tecniche come il TDD e la crescente importanza assunta in generale dai test nella realizzazione di un prodotto software rischiano di creare un grosso fraintendimento, ossia che una buona suite di test è tale se massimizza la coverage. Scrivere test per ogni statement non rende la nostra codebase esente da errori. Anzi, il tempo di sviluppo dei test diventa troppo lungo, rendendo difficile la loro manutenzione quando si aggiunga o modifichi una feature.
Sull’argomento è intervenuto Kent Beck su Stack Overflow.
Prendiamo un piccolo caso di studio. La nostra azienda sta svolgendo molti colloqui per una figura di programmatore e, per effettuare una prima scrematura, assegniamo ai candidati un kata.
Il controllo manuale dei risultati comporta un grosso sforzo e, quindi, sviluppiamo una suite di test che controlli in automatico le soluzioni proposte, in modo da ridurre quanto più possibile il nostro lavoro. Si sa che la pigrizia è una delle virtù dello sviluppatore: Laziness is a virtue!
Il kata che assegniamo ai nostri candidati è un classico: il FizzBuzz. Si tratta di scrivere un algoritmo che, assegnato un numero, stampi in output:
- “Fizz” per i multipli di tre
- “Buzz” per i multipli di cinque
- “FizzBuzz” per i multipli di tre e cinque
La semplicità di questo esercizio ci consente di focalizzarci sui concetti, senza preoccuparci troppo dei problemi di implementazione. Nel branch fsharp della repository trovate una possibile soluzione scritta in F#.
Scriviamo una suite di test che abbia il 100% di coverage usando TDD con test basati su esempi.
[<Tests>]
let tests =
testList "Example tests" [
testCase "Three should be Fizz" <| fun _ ->
Expect.equal (FizzBuzz.fizzbuzz 3) "Fizz" "Is not Fizz."
]
L’implementazione semplice:
module FizzBuzz =
let fizzbuzz (x:int) = "Fizz"
Andiamo ad aggiungere altri test:
testCase "Five should be Buzz" <| fun _ ->
Expect.equal (FizzBuzz.fizzbuzz 5) "Buzz" "Is not Buzz."
E l’implementazione diventa:
let fizzbuzz (x:int) =
match x with
| 3 -> "Fizz"
| 5 -> "Buzz"
| _ -> string x // Complete pattern matching
Procediamo più velocemente ed andiamo ad aggiungere i restanti test che coprono tutti i possibili casi.
testCase "Nine should be Fizz" <| fun _ ->
Expect.equal (FizzBuzz.fizzbuzz 9) "Fizz" "Is not Fizz."
testCase "Twentyi-Five should be Buzz" <| fun _ ->
Expect.equal (FizzBuzz.fizzbuzz 25) "Buzz" "Is not Buzz."
testCase "Fifteen should be FizzBuzz" <| fun _ ->
Expect.equal (FizzBuzz.fizzbuzz 15) "FizzBuzz" "Is not FizzBuzz."
In questo modo abbiamo coperto il 100% degli statement!
Consegniamo gli esercizi ai candidati e, al termine, lanciamo la suite sugli elaborati ricevuti. Tra le varie soluzioni proposte, ci balza all’occhio la seguente:
module FizzBuzz =
let fizzbuzz (x:int) =
match x with
| 3 -> "Fizz"
| 5 -> "Buzz"
| 9 -> "Fizz"
| 15 -> "FizzBuzz"
| 25 -> "Buzz"
| _ -> string x
La soluzione è evidentemente errata, eppure tutti i test sono superati.
Dobbiamo quindi rendere i nostri test più robusti. Aggiungiamo un test con numeri casuali generati a runtime. Ecco una possibile implementazione:
let actualList =
randomList |> List.map (FizzBuzz.fizzbuzz)
let expectedList =
randomList
|> List.map (fun i ->
match i with
| n when i % 3 = 0 && i % 5 = 0 -> "FizzBuzz"
| n when i % 3 = 0 -> "Fizz"
| n when i % 5 = 0 -> "Buzz"
| _ -> string i)
testCase "Random number test"
<| fun _ -> Expect.sequenceEqual actualList expectedList "Not equal"
Il problema di utilizzare numeri casuali nei test si presenta quando dobbiamo generare la lista dei risultati attesi. Per riuscirci, abbiamo introdotto l’implementazione dell’algoritmo nei test.
Introduciamo il concetto di Property Based Testing, divenuto famoso nell’ambito della programmazione funzionale con QuickCheck per Haskell. L’idea di base è semplice: anziché utilizzare test basati su un esempio, andiamo a testare le proprietà dell’algoritmo.
Una proprietà può essere testata con input random andando a coprire anche i casi limite. Se la risoluzione è corretta, rimarrà sempre valida; in questo modo non abbiamo bisogno di generare dei risultati attesi.
Per implementare i test delle proprietà, utilizziamo FsCheck, il porting in F# di QuickCheck. In FsCheck i dati per i test vengono generati da un modulo Generator, in grado di generare dati random. Come vedremo, è possibile definire un Generator personalizzato e utilizzarlo nei test.
Oltre ai Generator, un’altra feature di FsCheck è lo Shrinker che entra in gioco quando un test fallisce. Lo Shrinker è in grado di restituire il minor input possibile per cui il test potrebbe fallire.
Per implementare la suite di test, il primo passo è definire le proprietà dell’algoritmo:
- I multipli di 3 devono contenere Fizz.
- I multipli di 5 devono contenere Buzz.
- I multipli di entrambi devono essere FizzBuzz.
- I non multipli di 3 e 5 devono rimanere uguali.
Definite le proprietà, possiamo sviluppare i nostri Generator. Vediamo come definire un generatore di multipli di tre.
let multipleOfThree n = n * 3
let gen3 =
Arb.generate<NonNegativeInt>
|< Gen.map (fun (NonNegativeInt n) -> multipleOfThree n)
|< Arb.fromGen
Il modulo Arb consente di generare automaticamente una sequenza di numeri casuali: specificando il tipo NonNegativeInt il modulo genera numeri non negativi.
Con il modulo Gen, possiamo eseguire operazioni sui generatori: inizializzazioni, filtraggi, conversioni, etc.
Abbiamo utilizzato la funzione Gen.map per mappare i numeri casuali generati da Arb per ottenere multipli di tre.
A questo punto, il nostro generatore deve essere registrato prima di poterlo utilizzare nei test. Per farlo, dobbiamo definire un nuovo tipo:
type ThreeGenerator =
static member ThreeMultiple() =
Arb.generate<NonNegativeInt>
|> Gen.map (fun (NonNegativeInt n) -> multipleOfThree n)
|> Gen.filter(fun n-> n> 0)
|> Arb.fromGen
e registrarlo usando ad esempio la libreria Expecto (usata spesso assieme a FsCheck)
let multipleOfThreeConfig =
{ FsCheckConfig.defaultConfig with
arbitrary = [ typeof<ThreeGenerator> ] }
A questo punto possiamo scrivere il test utilizzando il nostro Generator
let tests =
testList "Property based tests"
[ testPropertyWithConfig multipleOfThreeConfig "Multiple of three should contain Fizz"
<| fun x -> Expect.containsAll (FizzBuzz.fizzbuzz x) "Fizz" "Not contain Fizz" ]
Allo stesso modo possiamo scrivere i restanti generatori.
let multipleOfFive n = n * 5
let multipleOfBoth n = n * 15
let noMultiple n = (multipleOfBoth n) - 1
type FiveGenerator =
static member FiveMultiple() =
Arb.generate<NonNegativeInt>
|> Gen.map (fun (NonNegativeInt n) -> multipleOfFive n)
|> Gen.filter (fun n -> n > 0)
|> Arb.fromGen
type BothGenerator =
static member BothMultiple() =
Arb.generate<NonNegativeInt>
|> Gen.map (fun (NonNegativeInt n) -> multipleOfBoth n)
|> Arb.fromGen
type NoMultipleGenerator =
static member BothMultiple() =
Arb.generate<NonNegativeInt>
|> Gen.map (fun (NonNegativeInt n) -> noMultiple n)
|> Arb.fromGen
Testare le proprietà consente di utilizzare input randomici, che andranno a coprire anche i casi limite e senza necessità di dover generare dei valori attesi.
Abbiamo visto come, cambiando il paradigma con cui testiamo il codice, riusciamo ad ottenere delle suite di test robuste a prova di sviluppatori pigri!
Trovate qui i riferimenti
Spero di avervi incuriosito.
Al prossimo articolo!