Con TDD, i test ovviamente falliscono prima. Ma è proprio vero?

7

Mi è stato insegnato che con TDD, i test "naturalmente falliscono prima, ma è comunque buona abitudine eseguirli comunque per vedere la luce rossa". Bene, ma sono sicuro che un test unitario scritto per prima per una nuova funzione potrebbe effettivamente passare, se tale funzione è già implementata (e per esempio in qualche modo inibita).

Prendo in prestito il seguente esempio da questa risposta :

    public string ShowScoreEvaluation(byte points)
    {
        switch(points)
         case 3:
            return "You are good!";
         case 2:
            return "Not bad!";
         case 1:
            return "Quite bad";
         case 0:
            return "You suck!"
      return null;

    }

    //caller code
    if (Points>0)
      Display(ShowScoreEvaluation(Points));

In the code above, the calling code does not expect to call the method when Points=0. Maybe during the implementation of that method, the programmer just put something there (as a joke or a placeholder) even for the case when points=0.

And now imagine, that you join the project and get a new request "When player has 0 points, show an encouraging message blabla". You write a unit test with Points=0 and expecting a string with length>0...and it did not fail, although you would expect it.

Questo potrebbe non accadere nella vita reale? Voglio dire, per me questa è la ragione per cui dovrei davvero vedere se un nuovo test fallisce, perché allora come potrei sapere che cosa ha fatto passare?

    
posta John V 19.05.2018 - 09:31
fonte

5 risposte

16

Credo che per quello che vuoi veramente sapere, il tuo esempio non è adatto, quindi lascia che ne descriva uno migliore.

Ci sono diversi problemi di programmazione in cui l'implementazione più elegante e più breve non è quella che serve solo alcuni casi di test, ma quella che risolve il problema in modo più generale di quanto specificato dai test .

Ad esempio, è possibile implementare un algoritmo di ordinamento in modo incrementale scrivendo alcuni casi di test in modo TDD per ordinare due, tre o quattro elementi. Forse si inizia con un'implementazione che può solo ordinare due elementi, come

int[] Sort(int[] list)
{
     // ... maybe some handling for lists with 0 or 1 element here ...
     if(list[0]>list[1])
        return new[]{list[1],list[0]};
     else
        return new[]{list[0],list[1]};
}

Ora, un test che passa 3 elementi in questa funzione fallirà. Tuttavia, quando estendete questo codice a 3, 4 o più elementi, raggiungerete rapidamente il punto in cui un algoritmo generale come un ordinamento di bolle o un ordinamento di inserimento (che funziona per lunghezze di lista arbitrarie) è più semplice di un'implementazione che funziona solo per un numero fisso di elementi. Infatti, facendo TDD, avresti potuto iniziare con un algoritmo complicato che confrontava fino a 4 elementi uno contro uno, ma nella fase di refactoring hai sostituito questo con un algoritmo di ordinamento più generale, che rende l'intero implementazione più semplice.

Ora non dovrebbe essere molto sorprendente quando aggiungi ulteriori test con più elementi, questi test non falliranno, anche se hai seguito tutte le regole TDD letteralmente, e anche se non hai implementato più codice del necessario per soddisfare tutti i test esistenti .

E sì, questo è un caso reale, molto più probabile che accada come questa risposta fa finta. Ho riscontrato questa situazione molte volte per tutti i diversi tipi di problemi nell'elaborazione delle stringhe, nella manipolazione degli insiemi, negli algoritmi matematici o geometrici: le implementazioni generali sono molto più semplici di quelle specializzate.

Quindi cosa si dovrebbe fare in questo caso? Tralasciando i test aggiuntivi con 5, 8 o 20 elementi, solo perché il codice "è già completo e i test aggiuntivi non inducono ulteriori modifiche al codice"? Non consiglierei questo: è ovviamente buono avere test aggiuntivi per un algoritmo complesso, ti darà molta più sicurezza nella correttezza del codice.

L'alternativa migliore qui è quella di rendere i test aggiuntivi falliti artificialmente per un breve periodo. Lo scopo principale di vedere i test che falliscono prima di passare a TDD è assicurarsi che il test sia effettivamente eseguito - è un "test per il test". Ad esempio, potresti aggiungere una frase come

if(list.Length>=5)
    return null;

da qualche parte all'interno della funzione Sort , e rimuoverlo dopo averlo visto fallito il test. Che prove i tuoi nuovi test sono effettivamente eseguiti, e non li hai mescolati con alcuni test esistenti.

Dato che hai chiesto un altro esempio: diciamo che hai una funzione

'string TrimNumeric(string value)'

che dovrebbe sostituire caratteri non numerici dall'inizio o alla fine della stringa di input value e restituire il risultato. Quello che succede con i caratteri non numerici nel mezzo non è specificato finora. I seguenti casi di test stanno già passando:

Assert.AreEqual("123",TrimNumeric("abc123"));
Assert.AreEqual("123",TrimNumeric("123xyz"));
Assert.AreEqual("456",TrimNumeric("abc456xyz"));

Ora ottieni un ulteriore requisito: anche i caratteri non numerici nel mezzo devono essere eliminati. Inizi aggiungendo un test:

Assert.AreEqual("123",TrimNumeric("1a2b3cxyz"));

In tal caso, se non sai come viene implementato internamente TrimNumeric , non c'è indicazione se questo test fallirà o meno. Infatti, se TrimNumeric è stato implementato in modo semplice, semplice e generale, iterando su tutti i caratteri e mantenendo solo quelli numerici, è IMHO molto probabilmente questo test passerà immediatamente. Tuttavia dovrebbe essere chiaro il motivo per cui è necessario scrivere un tale test. Forse il test non passerà, se l'implementazione ha un aspetto diverso. Ma se passa fin dall'inizio, assicurati di farlo fallire almeno temporaneamente, per essere sicuro che venga eseguito.

    
risposta data 19.05.2018 - 11:05
fonte
3

I am quite sure a unit test written first for a new feature might actually pass

Sì, potrebbe. Esistono diversi motivi per cui una funzione potrebbe già essere "presente", non testata, ma se si sta seguendo un TDD rigoroso, il motivo più comune sarebbe che il passaggio refactor nel tuo ciclo red-green-refactor ha reso il comportamento del tuo codice più generale e in grado di gestire casi al di là dei tuoi casi di test specifici iniziali.

L'altro motivo più comune in un contesto professionale è probabilmente il fatto che qualcuno ha già lavorato al codice e inserito la funzione senza un test di corrispondenza!

In entrambi i casi, vale ancora la pena di scrivere il test. Dovresti solo considerare che il tuo prodotto 'ha' una funzione se tale funzionalità è testata e documentata.

    
risposta data 19.05.2018 - 14:04
fonte
1

La tua filosofia è corretta.

(ma il tuo esempio non è un argomento valido).

L'idea di un test TDD che fallisce "naturalmente" non fa realmente parte delle regole . E stai aggiungendo il presupposto che il codice scritto mentre non si seguono le regole, obbedirà comunque alle tue ipotesi.

Le regole dello stato TDD:

  1. You are not allowed to write any production code unless it is to make a failing unit test pass.
  2. You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.
  3. You are not allowed to write any more production code than is sufficient to pass the one failing unit test.

(sottolineatura mia)

Quindi quando dici questo:

... a unit test written first for a new feature might actually pass, if such a feature is already implemented...

Quel codice avrebbe dovuto già avere dei test scritti per questo. E quando scrivono quei test, dovrebbero avere "fallito" prima che il codice fosse scritto.

Allora perché dico che la tua filosofia è corretta?

Dipende tutto da quale caso di test si sceglie di scrivere prima.

Diciamo che creerò una funzione che stampa ogni carattere in una stringa passata in esso. Il mio obiettivo è finire con qualcosa di simile a questo (pseudo codice):

function printLetters (String input) {
    for(var letter in input) {
        console.print(letter);
    }
}

Ora, se sono seduto per lavorare in questa funzione, il mio primo test case potrebbe essere questo:

"Quando una stringa vuota viene passata alla mia funzione, non viene stampato nulla".

Ora, secondo la regola 2, se contiamo "errori di compilazione" come test fallito, una volta che provo a chiamare la funzione (che non ho ancora creato) ho un test fallito! YAY!

Ma, ciò che accadrà è che alla fine inizierei a scrivere il codice prodotto e arrivare a questo punto:

function printLetters (String input) {

}

E ora il mio test sta passando. Quindi, so che il mio codice non è stato eseguito, ma non ho un test fallito.

Ma cosa significa?

Questo significa che non abbiamo scritto il resto dei casi di test per soddisfare le regole aziendali!

Quindi ho bisogno di aggiungere altri casi di test (che falliranno).

E, se seguo le "regole", farò in modo che il test che sto scrivendo stia fallendo ( E FAILING FOR THE CORRECT REASON! ) prima Inizio a scrivere il codice di produzione.

    
risposta data 19.05.2018 - 09:57
fonte
0

Could this not happen in the real life?

In un universo infinito, può accadere quasi tutto ... ma se il comportamento desiderato esiste già, è molto improbabile (non impossibile) ricevere una richiesta di funzionalità per questo.

...[you] get a new request "When player has 0 points, show an encouraging message blabla". You write a unit test with Points=0 and expecting a string with length>0...

Il problema è che il tuo test suggerito non corrisponde alla funzione che stai tentando di scacciare.

Il test implicito nella tua funzione sarebbe "con Punti = 0, aspettati una stringa che corrisponda a" un messaggio incoraggiante blabla "" - che fallirebbe per l'implementazione corrente.

Una volta che l'hai visto fallire, potresti fare il cambiamento più semplice possibile per farlo passare. Cosa sarebbe quello? Dipende da quali altri test sono in atto.

Direi che il cambiamento più semplice sarebbe:

    switch(points)
     case 3:
        return "You are good!";
     case 2:
        return "Not bad!";
     case 1:
        return "Quite bad";
     case 0:
        return "an encouraging message blabla"
  return null;

Ma se hai solo test esistenti per i casi 1-3, la modifica più semplice potrebbe essere:

    switch(points)
     case 3:
        return "You are good!";
     case 2:
        return "Not bad!";
     case 1:
        return "Quite bad";
  return "an encouraging message blabla";

Questo, a sua volta, potrebbe portare a un ulteriore requisito di restituire qualcos'altro in un caso predefinito.

    
risposta data 19.05.2018 - 10:00
fonte
0

Could this not happen in the real life?

Lo fa per me; soprattutto quando mi capita di scegliere un semplice test iniziale, in cui l'implementazione di default avviene per restituire il valore corretto.

Per esempio, supponiamo di provare a sviluppare una funzione che sommi i numeri in una lista. Se dovessimo scegliere una lista vuota come nostro primo test (perché no? È facile da configurare), l'implementazione return 0; produrrà il risultato corretto.

I have been taught that with TDD, the tests "naturally fail first but it is a good habit to run them anyway to see the red light"

Quello che alla fine è venuto a decidere è che RED-GREEN riguarda la calibrazione ; l'obiettivo durante quella parte del processo è verificare che il test che hai creato valuti effettivamente la tua implementazione di produzione.

ROSSO / VERDE è il modello più comune di attività di calibrazione, ma a volte VERDE / ROSSO / VERDE (o, se preferisci, VERDE / ROSSO / REVERT) si verificano invece.

I test calibrati che passano ingenuamente sono ancora validi, in quanto limitano le tue scelte durante la fase di refactoring (più precisamente, aiutano a distinguere i cambiamenti che sono rifattorici da cambiamenti che hanno alterato il comportamento osservabile del tuo sistema).

    
risposta data 19.05.2018 - 14:33
fonte

Leggi altre domande sui tag