Scrivere il codice minimo per superare un test unitario - senza barare!


36

Quando si esegue TDD e si scrive un unit test, come si fa a resistere all'impulso di "imbrogliare" quando si scrive la prima iterazione del codice di "implementazione" che si sta testando?

Ad esempio:
dobbiamo calcolare il fattoriale di un numero. Comincio con un unit test (usando MSTest) qualcosa del tipo:

[TestClass]
public class CalculateFactorialTests
{
    [TestMethod]
    public void CalculateFactorial_5_input_returns_120()
    {
        // Arrange
        var myMath = new MyMath();
        // Act
        long output = myMath.CalculateFactorial(5);
        // Assert
        Assert.AreEqual(120, output);
    }
}

Eseguo questo codice e non riesce poiché il CalculateFactorialmetodo non esiste nemmeno. Quindi, ora scrivo la prima iterazione del codice per implementare il metodo in prova, scrivendo il codice minimo richiesto per superare il test.

Il fatto è che sono continuamente tentato di scrivere quanto segue:

public class MyMath
{
    public long CalculateFactorial(long input)
    {
        return 120;
    }
}

Questo è, tecnicamente, corretto in quanto è davvero il codice minimo richiesto per far passare quel test specifico (diventa verde), anche se è chiaramente un "imbroglio" dal momento che in realtà non tenta nemmeno di svolgere la funzione di calcolo di un fattoriale. Naturalmente, ora la parte di refactoring diventa un esercizio di "scrittura della corretta funzionalità" piuttosto che un vero refactoring dell'implementazione. Ovviamente, l'aggiunta di test aggiuntivi con parametri diversi fallirà e forzerà un refactoring, ma devi iniziare con quel test.

Quindi, la mia domanda è: come si ottiene quell'equilibrio tra "scrivere il codice minimo per superare il test" pur mantenendolo funzionale e nello spirito di ciò che si sta effettivamente cercando di ottenere?


4
È una cosa umana: devi resistere alla tentazione di imbrogliare. Non c'è altro. Potresti aggiungere più test e scrivere più codice di test che codice per testare, ma se non hai quel lusso di quello che dovrai resistere. Ci sono MOLTI posti nel codice in cui dobbiamo resistere alla tentazione di hackerare o barare, perché sappiamo che, mentre potrebbe funzionare oggi, non funzionerà più tardi.
Dan Rosenstark,

7
Sicuramente, nel TDD, farlo al contrario è barare, ovvero restituire 120 è il modo corretto. Trovo abbastanza difficile farmi fare questo, non correre avanti e iniziare a scrivere il calcolo fattoriale.
Paul Butcher,

2
Lo considero un imbroglione, solo perché può superare il test ma non aggiunge alcuna vera funzionalità o ti avvicina a una soluzione finale al problema attuale.
GrumpyMonkey il

3
Se si scopre che il codice del codice client passa sempre e solo in 5, restituire 120 non è solo un non-cheat, ma in realtà è una soluzione legittima.
Kramii Ripristina Monica il

Sono d'accordo con @PaulButcher - in effetti, molti esempi di test unitari in testi e articoli prenderebbero questo approccio.
HorusKol,

Risposte:


45

È perfettamente legittimo. Rosso, verde, refattore.

Il primo test ha superato.

Aggiungi il secondo test, con un nuovo input.

Ora diventa rapidamente verde, potresti aggiungere un if-else, che funziona benissimo. Passa, ma non hai ancora finito.

La terza parte di Red, Green, Refactor è la più importante. Refactor per rimuovere la duplicazione . Avrai duplicazioni nel tuo codice ora. Due istruzioni che restituiscono numeri interi. E l'unico modo per rimuovere quella duplicazione è codificare correttamente la funzione.

Non sto dicendo di non scriverlo correttamente la prima volta. Sto solo dicendo che non è barare se non lo fai.


12
Questo solleva solo la domanda, perché non scrivere semplicemente correttamente la funzione in primo luogo?
Robert Harvey,

8
@Robert, i numeri fattoriali sono banalmente semplici. Il vero vantaggio di TDD è quando scrivi librerie non banali e scrivere il test ti costringe a progettare l'API prima dell'implementazione, il che - secondo la mia esperienza - porta a un codice migliore.

1
@Robert, sei tu che sei preoccupato di risolvere il problema invece di superare il test. Ti sto dicendo che per problemi non banali funziona semplicemente meglio rinviare il progetto duro fino a quando non avrai posto in atto i test.

1
@ Thorbjørn Ravn Andersen, no, non sto dicendo che puoi avere un solo ritorno. Esistono validi motivi per molteplici (ad es. Dichiarazioni di guardia). Il problema è che entrambe le dichiarazioni di ritorno erano "uguali". Hanno fatto la stessa "cosa". Hanno appena avuto valori diversi. TDD non riguarda la rigidità e il rispetto di una dimensione specifica del rapporto test / codice. Si tratta di creare un livello di comfort all'interno della tua base di codice. Se riesci a scrivere un test fallito, allora una funzione che funzionerà per i test futuri di quella funzione, grande. Fallo, quindi scrivi i test del case edge assicurandoti che la tua funzione funzioni ancora.
CaffGeek,

3
il punto di non scrivere l'implementazione completa (anche se semplice) in una volta è che non hai alcuna garanzia che i tuoi test possano anche fallire. il punto di vedere un test fallito prima di farlo passare è che hai la prova effettiva che la tua modifica al codice è ciò che ha soddisfatto l'affermazione che hai fatto su di esso. questo è l'unico motivo per cui TDD è così eccezionale per la costruzione di una suite di test di regressione e pulisce completamente il pavimento con l'approccio "test after" in questo senso.
Sara

25

Chiaramente è necessaria la comprensione dell'obiettivo finale e il raggiungimento di un algoritmo che soddisfi tale obiettivo.

TDD non è un proiettile magico per il design; devi ancora sapere come risolvere i problemi usando il codice e devi ancora sapere come farlo a un livello superiore a poche righe di codice per effettuare un test di prova.

Mi piace l'idea di TDD perché incoraggia un buon design; ti fa pensare a come puoi scrivere il tuo codice in modo che sia testabile, e in generale quella filosofia spingerà il codice verso una migliore progettazione complessiva. Ma devi ancora sapere come progettare una soluzione.

Non favorisco le filosofie riduzioniste del TDD che sostengono che puoi far crescere un'applicazione semplicemente scrivendo la minima quantità di codice per superare un test. Senza pensare all'architettura, questo non funzionerà e il tuo esempio lo dimostra.

Lo zio Bob Martin dice questo:

Se non stai eseguendo Test Driven Development, è molto difficile definirti un professionista. Jim Coplin mi ha chiamato sul tappeto per questo. Non gli piaceva, l'ho detto. In effetti, la sua posizione in questo momento è che Test Driven Development sta distruggendo le architetture perché le persone stanno scrivendo test per abbandonare qualsiasi altro tipo di pensiero e stanno facendo a pezzi le loro architetture nella folle corsa per far passare i test e ha un punto interessante, è un modo interessante di abusare del rituale e perdere l'intento dietro la disciplina.

se non stai pensando attraverso l'architettura, se invece stai ignorando l'architettura e mettendo insieme i test e facendoli superare, stai distruggendo la cosa che permetterà all'edificio di rimanere in piedi perché è la concentrazione sul struttura del sistema e solide decisioni di progettazione che aiutano il sistema a mantenere la sua integrità strutturale.

Non puoi semplicemente lanciare un sacco di test insieme e farli passare decennio dopo decennio dopo decennio e presumere che il tuo sistema sopravviverà. Non vogliamo evolvere noi stessi all'inferno. Quindi un buon sviluppatore guidato dai test è sempre consapevole di prendere decisioni sull'architettura, pensando sempre al quadro generale.


Non proprio una risposta alla domanda, ma 1+
Nessuno il

2
@rmx: Um, la domanda è: come si ottiene quell'equilibrio tra "scrivere il codice minimo per superare il test" pur mantenendolo funzionale e nello spirito di ciò che si sta effettivamente cercando di ottenere? Stiamo leggendo la stessa domanda?
Robert Harvey,

La soluzione ideale è un algoritmo e non ha nulla a che fare con l'architettura. Fare TDD non ti farà inventare algoritmi. Ad un certo punto è necessario fare dei passi in termini di algoritmo / soluzione.
Joppe,

Sono d'accordo con @rmx. Questo in realtà non risponde alla mia domanda specifica, di per sé, ma dà origine a spunti di riflessione su come TDD in generale si inserisca nel quadro generale del processo di sviluppo software complessivo. Quindi, per quel motivo, +1.
CraigTP,

Penso che potresti sostituire "algoritmi" - e altri termini - con "architettura" e l'argomento è ancora valido; si tratta di non riuscire a vedere il bosco per gli alberi. A meno che non si scriva un test separato per ogni singolo input intero, TDD non sarà in grado di distinguere tra una corretta implementazione fattoriale e alcuni hard-coding perversi che funzionano per tutti i casi testati ma non per altri. Il problema con TDD è la facilità con cui "tutti i test passano" e "il codice è buono" si fondono. Ad un certo punto è necessario applicare una pesante misura di buon senso.
Julia Hayward,

16

Un'ottima domanda ... e non sono d'accordo con quasi tutti tranne @Robert.

scrittura

return 120;

per una funzione fattoriale effettuare un test test è una perdita di tempo . Non è "barare", né segue letteralmente il refactor rosso-verde. E ' sbagliato .

Ecco perché:

  • Calcola fattoriale è la funzione, non "restituisce una costante". "return 120" non è un calcolo.
  • gli argomenti del "refactor" sono sbagliati; se si hanno due casi di test per 5 e 6, questo codice è ancora sbagliata, perché non sta calcolando un fattoriale a tutti :

    if (input == 5) { return 120; } //input=5 case
    else { return 720; }   //input=6 case
    
  • se seguiamo letteralmente l' argomento 'refactor' , quando abbiamo 5 casi di test invochiamo YAGNI e implementiamo la funzione usando una tabella di ricerca:

    if (factorialDictionary.Contains(input)) {
        return factorialDictionary[input]; 
    }
    throw new Exception("Input failure");
    

Nessuno di questi sta effettivamente calcolando nulla, tu lo sei . E non è questo il compito!


1
@rmx: no, non è mancato; "refactor to remove duplication" può essere soddisfatto con una tabella di ricerca. A proposito, il principio secondo cui i test unitari codificano i requisiti non è specifico per BDD, è un principio generale di Agile / XP. Se il requisito era "Rispondi alla domanda 'qual è il fattoriale di 5'", allora 'return 120;' sarebbe legittimo ;-)
Steven A. Lowe il

2
@Chad tutto ciò è un lavoro inutile - basta scrivere la funzione la prima volta ;-)
Steven A. Lowe,

2
@Steven A.Lowe, secondo quella logica, perché scrivere qualche test ?! "Basta scrivere l'applicazione per la prima volta!" Il punto di TDD è piccoli, sicuri, cambiamenti incrementali.
CaffGeek,

1
@Chad: strawman.
Steven A. Lowe,

2
il punto di non scrivere l'implementazione completa (anche se semplice) in una volta è che non hai alcuna garanzia che i tuoi test possano anche fallire. il punto di vedere un test fallito prima di farlo passare è che hai la prova effettiva che la tua modifica al codice è ciò che ha soddisfatto l'affermazione che hai fatto su di esso. questo è l'unico motivo per cui TDD è così eccezionale per la costruzione di una suite di test di regressione e pulisce completamente il pavimento con l'approccio "test after" in questo senso. non si scrive mai accidentalmente un test che non può fallire. inoltre, dai un'occhiata al kata fattore primo di zio.
Sara

10

Quando hai scritto solo un test unitario, l'implementazione su una riga ( return 120;) è legittima. Scrivere un ciclo calcolando il valore di 120 - sarebbe un imbroglio!

Tali semplici test iniziali sono un buon modo per individuare casi limite e prevenire errori una tantum. Cinque in realtà non è il valore di input con cui inizierei.

Una regola empirica che potrebbe essere utile qui è: zero, uno, molti, lotti . Zero e uno sono importanti casi limite per il fattoriale. Possono essere implementati con una linea. Il "molti" test case (es. 5!) Ti costringerebbe a scrivere un loop. Il caso di test "lotti" (1000 !?) potrebbe costringerti a implementare un algoritmo alternativo per gestire numeri molto grandi.


2
Il caso "-1" sarebbe interessante. Perché non è ben definito, quindi sia il ragazzo che scrive il test sia il ragazzo che scrive il codice devono prima concordare cosa dovrebbe accadere.
gnasher729

2
+1 per aver effettivamente sottolineato che factorial(5)è un primo test negativo. partiamo dai casi più semplici possibili e in ogni iterazione rendiamo i test un po 'più specifici, spingendo il codice a diventare un po' più generico. questo è ciò che lo zio bob chiama la premessa della priorità di trasformazione ( blog.8thlight.com/uncle-bob/2013/05/27/… )
sara

5

Finché hai solo un singolo test, il codice minimo necessario per superare il test è veramente return 120;e puoi facilmente mantenerlo per tutto il tempo in cui non hai più test.

Ciò consente di posticipare ulteriormente la progettazione fino a quando non si scrivono effettivamente i test che esercitano ALTRI valori di ritorno di questo metodo.

Ricorda che il test è la versione eseguibile della tua specifica, e se tutta quella specifica dice che f (6) = 120 allora si adatta perfettamente al conto.


Sul serio? In base a questa logica, dovrai riscrivere il codice ogni volta che qualcuno presenta un nuovo input.
Robert Harvey,

6
@Robert, A QUALUNQUE punto l'aggiunta di un nuovo caso non comporterà più il codice più semplice possibile, a quel punto scrivi una nuova implementazione. Dato che hai già eseguito i test, sai esattamente quando la tua nuova implementazione fa lo stesso di quella precedente.

1
@ Thorbjørn Ravn Andersen, esattamente, la parte più importante di Red-Green-Refactor, è il refactoring.
CaffGeek,

+1: Questa è anche l'idea generale delle mie conoscenze, ma bisogna dire qualcosa sull'adempimento del contratto implicito (cioè il nome del metodo fattoriale ). Se specifichi (es. Test) f (6) = 120 solo allora devi solo "restituire 120". Una volta che inizi ad aggiungere test per assicurarti che f (x) == x * x-1 ... * xx-1: upperBound> = x> = 0, otterrai una funzione che soddisfa l'equazione fattoriale.
Steven Evers,

1
@SnOrfus, il posto dove si trovano i "contratti impliciti" è nei casi di test. Se il tuo contratto è per fattoriali, TEST se sono noti fattoriali e se non sono noti fattoriali. Molti di loro. Non ci vuole molto a convertire l'elenco dei dieci primi fattoriali in un test for-loop ogni numero fino al decimo fattoriale.

4

Se sei in grado di "imbrogliare" in questo modo, suggerisce che i tuoi test unitari sono imperfetti.

Invece di testare il metodo fattoriale con un singolo valore, testare era un intervallo di valori. I test basati sui dati possono aiutare qui.

Visualizza i test unitari come una manifestazione dei requisiti: devono definire collettivamente il comportamento del metodo testato. (Questo è noto come sviluppo guidato dal comportamento - è il futuro ;-))

Quindi chiediti: se qualcuno dovesse cambiare l'implementazione in qualcosa di errato, i tuoi test passerebbero comunque o direbbero "aspetta un minuto!"?

Tenendo presente questo, se il tuo unico test era quello in questione, quindi tecnicamente l'implementazione corrispondente è corretta. Il problema viene quindi visualizzato come requisiti mal definiti.


Come ha sottolineato Nanda, puoi sempre aggiungere una serie infinita di caseistruzioni a switche non puoi scrivere un test per ogni possibile input e output per l'esempio del PO.
Robert Harvey,

È possibile testare tecnicamente i valori da Int64.MinValuea Int64.MaxValue. Ci vorrebbe molto tempo per eseguire ma definirebbe esplicitamente il requisito senza spazio per errori. Con la tecnologia attuale, questo non è fattibile (sospetto che potrebbe diventare più comune in futuro) e sono d'accordo, potresti imbrogliare, ma penso che la domanda dei PO non fosse pratica (nessuno trufferebbe in questo modo in pratica), ma teorico.
Nessuno il

@rmx: se potessi farlo, i test sarebbero l'algoritmo e non dovrai più scrivere l'algoritmo.
Robert Harvey,

È vero. La mia tesi universitaria in realtà prevede la generazione automatica dell'implementazione utilizzando i test unitari come guida con un algoritmo genetico come ausilio per TDD - ed è possibile solo con test solidi. La differenza è che legare i requisiti al codice in genere è molto più difficile da leggere e comprendere rispetto a un singolo metodo che incarna i test unitari. Quindi viene la domanda: se la tua implementazione è una manifestazione dei tuoi test unitari e i tuoi test unitari sono una manifestazione dei tuoi requisiti, perché non saltare del tutto i test? Non ho una risposta.
Nessuno il

Inoltre, come umani, non è altrettanto probabile che facciamo un errore nei test unitari come nel codice di implementazione? Allora perché test unitari?
Nessuno il

3

Basta scrivere più test. Alla fine, sarebbe più breve scrivere

public long CalculateFactorial(long input)
{
    return input <= 1 ? 1 : CalculateFactorial(input-1)*input;
}

di

public long CalculateFactorial(long input)
{
    switch (input) {
       case 0: return 1;
       case 1: return 1;
       case 2: return 2;
       case 3: return 6;
       case 4: return 24;
       case 5: return 120;
    }
}

:-)


3
Perché non scrivere semplicemente correttamente l'algoritmo in primo luogo?
Robert Harvey,

3
@Robert, è l' algoritmo corretto per il calcolo fattoriale di un numero da 0 a 5. Inoltre, cosa significa "correttamente"? Questo è un esempio molto semplice, ma quando diventa più complesso, ci sono molte gradazioni di ciò che significa "corretto". Un programma che richiede l'accesso root è "corretto"? L'uso di XML è "corretto", invece di usare CSV? Non puoi rispondere a questo. Qualsiasi algoritmo è corretto purché soddisfi alcuni requisiti aziendali, che sono formulati come test in TDD.
P:

3
Va notato che poiché il tipo di output è lungo, esiste solo un piccolo numero di valori di input (circa 20) che la funzione può eventualmente gestire correttamente, quindi un'istruzione switch di grandi dimensioni non è necessariamente la peggiore implementazione - se la velocità è maggiore importante della dimensione del codice, l'istruzione switch potrebbe essere la strada da percorrere, a seconda delle priorità.
user281377

3

Scrivere test "cheat" è OK, per valori sufficientemente piccoli di "OK". Ma ricorda: il test unitario è completo solo quando tutti i test superano e non è possibile scrivere nuovi test che falliranno . Se vuoi davvero avere un metodo CalculateFactorial che contiene un sacco di istruzioni if (o anche meglio, una grande istruzione switch / case :-) puoi farlo, e dato che hai a che fare con un numero a precisione fissa il codice richiesto implementare questo è finito (anche se probabilmente piuttosto grande e brutto, e forse limitato dalle limitazioni del compilatore o del sistema sulla dimensione massima del codice di una procedura). A questo punto se davveroinsistere sul fatto che tutto lo sviluppo deve essere guidato da un unit test, è possibile scrivere un test che richiede al codice di calcolare il risultato in un tempo più breve di quello che può essere realizzato seguendo tutti i rami dell'istruzione if .

Fondamentalmente, TDD può aiutarti a scrivere codice che implementa correttamente i requisiti , ma non può costringerti a scrivere un buon codice. Dipende da te.

Condividi e divertiti.


+1 per "test unitari è completo solo quando tutti i test vengono superati e non è possibile scrivere nuovi test che falliranno" Molte persone affermano che è legittimo restituire la costante, ma non seguono "per il breve termine" o " se i requisiti generali richiedono solo quei casi specifici "
Thymine,

1

Sono d'accordo al 100% con il suggerimento di Robert Harveys qui, non si tratta solo di superare i test, ma è necessario tenere presente anche l'obiettivo generale.

Come soluzione al tuo punto di vista di "è verificato che funzioni solo con un determinato set di input", proporrei di utilizzare test basati sui dati, come la teoria di xunit. Il potere alla base di questo concetto è che consente di creare facilmente le specifiche degli ingressi alle uscite.

Per i fattoriali, un test sarebbe simile al seguente:

    [Theory]
    [InlineData(0, 1)]
    [InlineData( 1, 1 )]
    [InlineData( 2, 2 )]
    [InlineData( 3, 6 )]
    [InlineData( 4, 24 )]
    public void Test_Factorial(int input, int expected)
    {
        int result = Factorial( input );
        Assert.Equal( result, expected);
    }

Potresti persino implementare un test-data fornire (che ritorna IEnumerable<Tuple<xxx>>) e codificare un invariante matematico, come la divisione ripetuta per n produrrà n-1).

Trovo che questo sia un modo molto potente di testare.


1

Se riesci ancora a imbrogliare, i test non sono sufficienti. Scrivi altri test! Per il tuo esempio, proverò ad aggiungere test con input 1, -1, -1000, 0, 10, 200.

Tuttavia, se ti impegni davvero a imbrogliare, puoi scrivere un infinito if-then. In questo caso, nulla potrebbe aiutare tranne la revisione del codice. Verrai presto catturato dal test di accettazione ( scritto da un'altra persona! )

Il problema con i test unitari è che talvolta i programmatori li considerano come un lavoro inutile. Il modo corretto di vederli è come uno strumento per rendere corretto il risultato del tuo lavoro. Quindi, se crei un if-then, sai inconsciamente che ci sono altri casi da considerare. Questo significa che devi scrivere un altro test. E così via e così via finché non ti rendi conto che il tradimento non funziona ed è meglio semplicemente codificare nel modo corretto. Se senti ancora che non hai finito, non sei finito.


1
Quindi sembra che tu stia dicendo che semplicemente scrivere il codice sufficiente per il test da superare (come sostenitori del TDD) non è sufficiente. Devi anche tenere a mente i principi di progettazione del software audio. Sono d'accordo con te BTW.
Robert Harvey,

0

Vorrei suggerire che la scelta del test non è il test migliore.

Vorrei iniziare con:

factorial (1) come primo test,

fattoriale (0) come il secondo

fattoriale (-ve) come il terzo

e poi continua con casi non banali

e finire con un caso di troppo pieno.


Cosa è -ve??
Robert Harvey,

un valore negativo.
Chris Cudmore,
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.