Come dovresti TDD un gioco Yahtzee?


36

Diciamo che stai scrivendo un gioco in stile TDD Yahtzee. Si desidera testare la parte del codice che determina se un set di cinque tiri di dado è o meno un full. Per quanto ne so, quando fai TDD, segui questi principi:

  • Scrivi prima i test
  • Scrivi la cosa più semplice possibile che funzioni
  • Affina e refatta

Quindi un test iniziale potrebbe assomigliare a questo:

public void Returns_true_when_roll_is_full_house()
{
    FullHouseTester sut = new FullHouseTester();
    var actual = sut.IsFullHouse(1, 1, 1, 2, 2);

    Assert.IsTrue(actual);
}

Quando segui "Scrivi la cosa più semplice possibile che funziona", dovresti ora scrivere il IsFullHousemetodo in questo modo:

public bool IsFullHouse(int roll1, int roll2, int roll3, int roll4, int roll5)
{
    if (roll1 == 1 && roll2 == 1 && roll3 == 1 && roll4 == 2 && roll5 == 2)
    {
        return true;
    }

    return false;
}

Ciò si traduce in un test ecologico ma l'implementazione è incompleta.

Dovresti testare ogni possibile combinazione valida (sia di valori che di posizioni) per un full house? Sembra l'unico modo per essere assolutamente sicuri che il tuo IsFullHousecodice sia completamente testato e corretto, ma sembra anche abbastanza folle farlo.

Come testereste qualcosa del genere?

Aggiornare

Erik e Kilian sottolineano che l'uso dei letterali nell'implementazione iniziale per ottenere un test ecologico potrebbe non essere la migliore idea. Vorrei spiegare perché l'ho fatto e quella spiegazione non rientra in un commento.

La mia esperienza pratica con i test unitari (in particolare utilizzando un approccio TDD) è molto limitata. Ricordo di aver visto una registrazione della Masterclass TDD di Roy Osherove su Tekpub. In uno degli episodi costruisce uno stile TDD String Calculator. Le specifiche complete del calcolatore di stringhe sono disponibili qui: http://osherove.com/tdd-kata-1/

Inizia con un test come questo:

public void Add_with_empty_string_should_return_zero()
{
    StringCalculator sut = new StringCalculator();
    int result = sut.Add("");

    Assert.AreEqual(0, result);
}

Ciò si traduce in questa prima implementazione del Addmetodo:

public int Add(string input)
{
    return 0;
}

Quindi viene aggiunto questo test:

public void Add_with_one_number_string_should_return_number()
{
    StringCalculator sut = new StringCalculator();
    int result = sut.Add("1");

    Assert.AreEqual(1, result);
}

E il Addmetodo è refactored:

public int Add(string input)
{
    if (input.Length == 0)
    {
        return 0;
    }

    return 1;
}

Dopo ogni passaggio Roy dice "Scrivi la cosa più semplice che funzionerà".

Quindi ho pensato di provare questo approccio quando provavo a fare un gioco Yahtzee in stile TDD.


8
"Scrivi la cosa più semplice possibile che funziona" è in realtà un'abbreviazione; il consiglio corretto è "Scrivi la cosa più semplice possibile che non sia del tutto risoluta e ovviamente errata che funzioni". Quindi no, non dovresti scrivereif (roll1 == 1 && roll2 == 1 && roll3 == 1 && roll4 == 2 && roll5 == 2)
Carson63000,

3
Grazie per aver riassunto la risposta di Erik, sia in modo meno polemico o civile.
Kristof Claes,

1
"Scrivi la cosa più semplice che funziona", come @ Carson63000, è in realtà una semplificazione. In realtà è pericoloso pensare così; porta alla famigerata debacle del Sudoku TDD (google it). Se seguito alla cieca, TDD è davvero intelligente: non puoi generalizzare un algoritmo non banale facendo ciecamente "la cosa più semplice che funziona" ... devi davvero pensare! Sfortunatamente, anche i presunti maestri di XP e TDD a volte lo seguono alla cieca ...
Andres F.

1
@AndresF. Nota che il tuo commento è apparso più elevato nelle ricerche di Google rispetto a gran parte del commento sulla "debacle di Soduko TDD" dopo meno di tre giorni. Tuttavia, come non risolvere un Sudoku lo ha riassunto: TDD è per qualità, non correttezza. È necessario risolvere l'algoritmo prima di iniziare la codifica, in particolare con TDD. (Non che non sia anch'io un primo programmatore di codici.)
Mark Hurd,

1
pvv.org/~oma/TDDinC_Yahtzee_27oct2011.pdf potrebbe essere di interesse.

Risposte:


40

Ci sono già molte buone risposte a questa domanda e ne ho commentate e votate diverse. Tuttavia, vorrei aggiungere alcuni pensieri.

La flessibilità non è per i principianti

L'OP afferma chiaramente di non avere esperienza con TDD e penso che una buona risposta debba tenerne conto. Nella terminologia del modello di acquisizione di abilità di Dreyfus , è probabilmente un novizio . Non c'è niente di sbagliato nell'essere un novizio: siamo tutti novizi quando iniziamo a imparare qualcosa di nuovo. Tuttavia, ciò che spiega il modello Dreyfus è che i novizi sono caratterizzati da

  • rigida aderenza a regole o piani insegnati
  • nessun esercizio di giudizio discrezionale

Questa non è una descrizione di una carenza di personalità, quindi non c'è motivo di vergognarsene - è una fase che tutti noi dobbiamo attraversare per imparare qualcosa di nuovo.

Questo vale anche per TDD.

Sebbene sia d'accordo con molte delle altre risposte qui che il TDD non deve essere dogmatico e che a volte può essere più utile lavorare in modo alternativo, ciò non aiuta nessuno a iniziare. Come puoi esercitare un giudizio discrezionale quando non hai esperienza?

Se un principiante accetta il consiglio che a volte è OK non fare TDD, come può determinare quando è OK saltare TDD?

Senza esperienza o guida, l'unica cosa che un principiante può fare è saltare il TDD ogni volta che diventa troppo difficile. Questa è la natura umana, ma non è un buon modo per imparare.

Ascolta i test

Saltare fuori dal TDD ogni volta che diventa difficile è perdere uno dei vantaggi più importanti del TDD. I test forniscono un feedback tempestivo sull'API del SUT. Se il test è difficile da scrivere, è un segno importante che il SUT è difficile da usare.

Questo è il motivo per cui uno dei messaggi più importanti di GOOS è: ascolta i tuoi test!

Nel caso di questa domanda, la mia prima reazione quando ho visto l'API proposta per il gioco Yahtzee e la discussione sulla combinatoria che si possono trovare in questa pagina è stata che si tratta di un feedback importante sull'API.

L'API deve rappresentare i tiri di dado come una sequenza ordinata di numeri interi? Per me, quell'odore di Primitive Obsession . Ecco perché sono stato felice di vedere la risposta di tallseth che suggerisce l'introduzione di una Rolllezione. Penso che sia un suggerimento eccellente.

Tuttavia, penso che alcuni dei commenti a quella risposta abbiano sbagliato. Ciò che TDD suggerisce allora è che una volta che hai avuto l'idea che una Rollclasse sarebbe una buona idea, sospendi il lavoro sul SUT originale e inizi a lavorare su TDD nella Rollclasse.

Anche se concordo sul fatto che TDD è più orientato al "percorso felice" di quanto non sia destinato a test completi, aiuta comunque a scomporre il sistema in unità gestibili. Una Rollclasse suona come qualcosa che potresti completare TDD molto più facilmente.

Quindi, una volta che la Rollclasse è sufficientemente evoluta, torneresti al SUT originale e lo perfezioneresti in termini di Rollinput.

Il suggerimento di un Test Helper non implica necessariamente casualità: è solo un modo per rendere il test più leggibile.

Un altro modo per approcciare e modellare l'input in termini di Rollistanze sarebbe introdurre un generatore di dati di test .

Red / Green / Refactor è un processo in tre fasi

Mentre sono d'accordo con il sentimento generale che (se hai abbastanza esperienza nel TDD), non hai bisogno di attenerti rigorosamente al TDD, penso che sia un consiglio piuttosto scarso nel caso di un esercizio Yahtzee. Anche se non conosco i dettagli delle regole Yahtzee, qui non vedo argomentazioni convincenti sul fatto che non è possibile attenersi rigorosamente al processo Rosso / Verde / Rifattore e ottenere comunque un risultato adeguato.

Ciò che la maggior parte delle persone qui sembra dimenticare è la terza fase del processo Rosso / Verde / Rifattore. Per prima cosa scrivi il test. Quindi scrivi l'implementazione più semplice che supera tutti i test. Quindi rifattori.

È qui, in questo terzo stato, che puoi mettere a frutto tutte le tue capacità professionali. Qui è dove ti è permesso riflettere sul codice.

Tuttavia, penso che sia un cop-out affermare che dovresti solo "Scrivi la cosa più semplice possibile che non sia del tutto risoluta e ovviamente errata che funzioni". Se (pensi di sapere) abbastanza sull'implementazione in anticipo, allora tutto ciò che manca della soluzione completa sarà ovviamente errato . Per quanto riguarda i consigli, quindi, questo è abbastanza inutile per un principiante.

Quello che dovrebbe davvero accadere è che se riesci a far passare tutti i test con un'implementazione ovviamente errata , è un feedback che dovresti scrivere un altro test .

È sorprendente quanto spesso ciò ti porti a un'implementazione completamente diversa da quella che avevi in ​​mente per prima. A volte, l'alternativa che cresce così potrebbe rivelarsi migliore del tuo piano originale.

Il vigore è uno strumento di apprendimento

Ha molto senso attenersi a processi rigorosi come il rosso / verde / refactor finché si impara. Costringe lo studente a fare esperienza con TDD non solo quando è facile, ma anche quando è difficile.

Solo quando hai padroneggiato tutte le parti dure sei in grado di prendere una decisione informata su quando deviare dal percorso 'vero'. Questo è quando inizi a formare il tuo percorso.


'altro novizio TDD qui, con tutti i soliti dubbi sul provare. Interessante assumere se riesci a superare tutti i test con un'implementazione ovviamente errata, questo è il feedback che dovresti scrivere un altro test. Sembra un buon modo per affrontare la percezione che testare le implementazioni "Braindead" sia un lavoro inutile.
shambulator

1
Wow grazie. Sono davvero spaventato dalla tendenza delle persone a dire ai principianti nel TDD (o in qualsiasi disciplina) di "non preoccuparti delle regole, fai solo ciò che è meglio". Come puoi sapere cosa si sente meglio quando non hai conoscenza o esperienza? Vorrei anche menzionare il principio di priorità di trasformazione, o che il codice dovrebbe diventare più generico man mano che i test diventano più specifici. i sostenitori TDD più duri come lo zio bob non starebbero dietro l'idea di "basta aggiungere una nuova istruzione if per ogni test".
Sara

41

Come disclaimer, questo è TDD mentre lo pratico e, come giustamente sottolinea Kilian, sarei diffidente nei confronti di chiunque abbia suggerito che esiste un modo giusto per praticarlo. Ma forse ti aiuterà ...

Innanzitutto, la cosa più semplice che potresti fare per superare il test sarebbe questa:

public bool IsFullHouse(int roll1, int roll2, int roll3, int roll4, int roll5)
{
    return true;
}

Questo è significativo perché non a causa di alcune pratiche TDD, ma perché il duro lavoro in tutti quei letterali non è davvero una buona idea. Una delle cose più difficili da tenere a mente con TDD è che non è una strategia di test completa: è un modo per evitare regressioni e contrassegnare i progressi mantenendo semplice il codice. È una strategia di sviluppo e non una strategia di test.

Il motivo per cui menziono questa distinzione è che aiuta a guidare quali test dovresti scrivere. La risposta a "quali test devo scrivere?" è "qualunque test sia necessario per ottenere il codice nel modo desiderato." Pensa a TDD come a un modo per aiutarti a stuzzicare algoritmi e ragioni sul tuo codice. Quindi, dato il tuo test e la mia implementazione "semplice verde", quale test verrà dopo? Bene, hai creato qualcosa che è un full house, quindi quando non è un full house?

public void Returns_true_when_roll_is_full_house()
{
    FullHouseTester sut = new FullHouseTester();
    var actual = sut.IsFullHouse(1, 2, 3, 4, 5);

    Assert.IsFalse(actual);
}

Ora devi trovare un modo per distinguere tra i due casi di test che è significativo . Personalmente mi occuperei di chiarire un po 'le informazioni sul "fai la cosa più semplice per far passare il test" e dire "fai la cosa più semplice per fare il test che promuove la tua implementazione". Scrivere test non riusciti è il tuo pretesto per modificare il codice, quindi quando vai a scrivere ogni test, dovresti chiederti "cosa non fa il mio codice che voglio che faccia e come posso esporre quella mancanza?" Può anche aiutarti a rendere il tuo codice robusto e gestire i casi limite. Cosa fai se un chiamante inserisce sciocchezze?

public void Returns_true_when_roll_is_full_house()
{
    FullHouseTester sut = new FullHouseTester();
    var actual = sut.IsFullHouse(-1, -2, -3, -4, -5);

    //I dunno - throw exception, return false, etc, whatever you think it should do....
}

Per riassumere, se stai testando ogni combinazione di valori, stai quasi sicuramente sbagliando (e probabilmente finirai con un'esplosione combinatoria di condizionali). Quando si tratta di TDD, è necessario scrivere la quantità minima di casi di test necessari per ottenere l'algoritmo desiderato. Eventuali ulteriori test che scrivi inizieranno in verde e diventeranno quindi documentazione, in sostanza, e non strettamente parte del processo TDD. Scriverai ulteriori casi di test TDD solo se i requisiti cambiano o viene rilevato un bug, nel qual caso documenterai la carenza con un test e poi lo farai superare.

Aggiornare:

Ho iniziato questo come un commento in risposta al tuo aggiornamento, ma ha iniziato a diventare piuttosto lungo ...

Direi che il problema non è con l'esistenza di letterali, punto, ma con la cosa "più semplice" che è un condizionale in 5 parti. Quando ci pensi, un condizionale in 5 parti è in realtà piuttosto complicato. Sarà comune usare i letterali durante il passaggio dal rosso al verde e quindi astrarli in costanti nel passaggio del rifrattore, oppure generalizzarli in un test successivo.

Durante il mio viaggio con TDD, mi sono reso conto che c'è una distinzione importante da fare: non è bene confondere "semplice" e "ottuso". Cioè, quando ho iniziato, ho visto le persone fare TDD e ho pensato "stanno solo facendo la cosa più stupida possibile per superare i test" e l'ho imitato per un po ', fino a quando ho capito che "semplice" era leggermente diverso di "ottuso". A volte si sovrappongono, ma spesso no.

Quindi, mi scuso se ho dato l'impressione che l'esistenza dei letterali fosse il problema - non lo è. Direi che il problema è la complessità del condizionale con le 5 clausole. Il tuo primo rosso-verde può essere semplicemente "ritorna vero" perché è veramente semplice (e ottuso, per coincidenza). Il prossimo caso di test, con (1, 2, 3, 4, 5) dovrà restituire false, ed è qui che inizi a lasciarti alle spalle "ottuso". Devi chiederti "perché (1, 1, 1, 2, 2) è un full e (1, 2, 3, 4, 5) no?" La cosa più semplice che potresti trovare potrebbe essere che uno ha l'ultimo elemento sequenza 5 o il secondo elemento sequenza 2 e l'altro no. Sono semplici, ma sono anche (inutilmente) ottusi. Quello a cui vuoi veramente guidare è "quanti hanno lo stesso numero?" Quindi potresti far passare il secondo test controllando se c'è o meno una ripetizione. In uno con una ripetizione, hai un full, e nell'altro no. Ora il test ha esito positivo e scrivi un altro caso di test che ha una ripetizione ma non è un full per affinare ulteriormente il tuo algoritmo.

Puoi farlo o meno con i letterali mentre procedi, e va bene se lo fai. Ma l'idea generale sta aumentando il tuo algoritmo 'organicamente' quando aggiungi altri casi.


Ho aggiornato la mia domanda per aggiungere ulteriori informazioni sul perché ho iniziato con l'approccio letterale.
Kristof Claes,

9
Questa è un'ottima risposta
tallseth,

1
Grazie mille per la tua risposta ponderata e ben spiegata. In realtà ha molto senso ora che ci penso.
Kristof Claes,

1
Test approfonditi non significano testare ogni combinazione ... È sciocco. Per questo caso particolare, prendi un full full o due e un paio di full non. Anche eventuali combinazioni speciali che potrebbero causare problemi (ad es. 5 di un tipo).
Schleis,

3
+1 I principi alla base di questa risposta sono descritti dalla Trasformation Priority Premise Premise di Robert C. Martin cleancoder.posterous.com/the-transformation-priority-premise
Mark Seemann

5

Testare cinque valori letterali particolari in una particolare combinazione non è "più semplice" per il mio cervello febbrile. Se la soluzione a un problema è davvero ovvia (conta se hai esattamente tre e esattamente due di qualsiasi valore), allora vai avanti e codifica quella soluzione e scrivi alcuni test che sarebbero molto, molto improbabili da soddisfare accidentalmente con la quantità di codice che hai scritto (cioè diversi letterali e diversi ordini di triple e doppie).

Le massime TDD sono davvero strumenti, non credenze religiose. Il loro punto è farti scrivere rapidamente un codice corretto e ben ponderato. Se una massima ovviamente si frappone a ciò, basta saltare avanti per passare al passaggio successivo. Ci saranno molti bit non ovvi nel tuo progetto in cui puoi applicarlo.


5

La risposta di Erik è ottima, ma ho pensato di condividere un trucco nella scrittura di prova.

Inizia con questo test:

[Test]
public void FullHouseReturnsTrue()
{
    var pairNum = AnyDiceValue();
    var trioNum = AnyDiceValue();

    Assert.That(sut.IsFullHouse(trioNum, pairNum, trioNum, pairNum, trioNum));
}

Questo test migliora ancora se crei una Rollclasse invece di passare 5 parametri:

[Test]
public void FullHouseReturnsTrue()
{
    var roll = AnyFullHouse();

    Assert.That(sut.IsFullHouse(roll));
}

Questo dà questa implementazione:

public bool IsFullHouse(Roll toCheck)
{
    return true;
}

Quindi scrivi questo test:

[Test]
public void StraightReturnsFalse()
{
    var roll = AnyStraight();

    Assert.That(sut.IsFullHouse(roll), Is.False);
}

Una volta che passa, scrivi questo:

[Test]
public void ThreeOfAKindReturnsFalse()
{
    var roll = AnyStraight();

    Assert.That(sut.IsFullHouse(roll), Is.False);
}

Dopodiché, scommetto che non devi più scrivere (forse due coppie, o forse yahtzee, se pensi che non sia un full).

Ovviamente, implementa i tuoi metodi Any per restituire Roll casuali che soddisfino la tua critica.

Ci sono alcuni vantaggi di questo approccio:

  • Non è necessario scrivere un test il cui unico scopo è impedirti di rimanere bloccato su valori specifici
  • I test comunicano davvero bene il tuo intento (il codice del primo test urla "qualsiasi full house restituisce vero")
  • ti porta rapidamente al punto di lavorare sulla carne del problema
  • a volte noterà casi a cui non hai pensato

Se segui questo approccio, dovrai migliorare i tuoi messaggi di registro nelle tue affermazioni. Lo sviluppatore deve vedere quale input ha causato l'errore.
Bringer128,

Questo non crea un dilemma pollo o uovo? Quando si implementa AnyFullHouse (utilizzando anche TDD) non è necessario IsFullHouse per verificarne la correttezza? In particolare, se AnyFullHouse ha un bug, quel bug potrebbe essere replicato in IsFullHouse.
ceretta il

AnyFullHouse () è un metodo in un caso di test. Tipicamente TDD i tuoi casi di test? No. Inoltre, è molto più semplice creare un esempio casuale di un full (o di qualsiasi altro tiro) di quanto non sia testarne l'esistenza. Naturalmente, se il test ha un bug, potrebbe essere replicato nel codice di produzione. Questo è vero per ogni test però.
tallseth

AnyFullHouse è un metodo "di supporto" in un caso di test. Se sono abbastanza generici, anche i metodi di supporto vengono testati!
Mark Hurd,

Dovrebbe IsFullHousedavvero tornare truese pairNum == trioNum ?
recursion.ninja,

2

Posso pensare a due modi principali che prenderei in considerazione durante il test di questo;

  1. Aggiungi "alcuni" altri casi di test (~ 5) di set full-house validi e la stessa quantità di falsi previsti ({1, 1, 2, 3, 3} è buona. Ricorda che 5, ad esempio, potrebbero essere riconosciuto come "3 dello stesso più una coppia" da un'implementazione errata). Questo metodo presuppone che lo sviluppatore non stia solo cercando di superare i test, ma di implementarlo correttamente.

  2. Metti alla prova tutti i possibili set di dadi (ce ne sono solo 252 diversi). Questo ovviamente presuppone che tu abbia un modo per sapere quale sia la risposta prevista (nel test questa è nota come oracle.) Potrebbe trattarsi di un'implementazione di riferimento della stessa funzione o di un essere umano. Se vuoi essere davvero rigoroso, varrebbe la pena codificare manualmente ogni risultato previsto.

In realtà, ho scritto una Yahtzee AI una volta, che ovviamente doveva conoscere le regole. Puoi trovare il codice per la parte che valuta il punteggio qui , tieni presente che l'implementazione è per la versione scandinava (Yatzy) e la nostra implementazione presuppone che i dadi siano dati in ordine.


La domanda da un milione di dollari è: hai tratto l'IA Yahtzee usando il TDD puro? La mia scommessa è che non puoi; si deve usare la conoscenza del dominio, che per definizione non è cieco :)
Andres F.

Sì, immagino tu abbia ragione. Questo è un problema generale con TDD, che i casi di test necessitano di output previsti a meno che non si desideri solo verificare arresti imprevisti ed eccezioni non gestite.
Rispondi

0

Questo esempio manca davvero il punto. Stiamo parlando di un'unica funzione semplice qui non di un progetto software. È un po 'complicato? si, quindi lo scomponi. E non testate assolutamente ogni possibile input da 1, 1, 1, 1, 1 a 6, 6, 6, 6, 6, 6. La funzione in questione non richiede ordine, solo una combinazione, vale a dire AAABB.

Non hai bisogno di 200 test logici separati. Puoi usare un set per esempio. Quasi tutti i linguaggi di programmazione ne hanno uno integrato:

Set set;
set.add(a);
set.add(b);
set.add(c);
set.add(d);
set.add(e);

if(set.size() == 2) { // means we *must* be of the form AAAAB or AAABB.
    if(a==b==c==d) // eliminate AAAAB
        return false;
    else
        return true;
}
return false;

E se ricevi un input che non è un tiro Yahtzee valido, dovresti lanciare come se non ci fosse un domani.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.