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

34

Quando si fa TDD e si scrive un test di unità, come si può 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 test unitario (usando MSTest) qualcosa come:

[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 fallisce poiché il metodo CalculateFactorial non esiste nemmeno. Quindi, scrivo ora la prima iterazione del codice per implementare il metodo sotto test, scrivendo minimo codice 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 veramente è il codice minimo richiesto per effettuare quel passaggio di prova specifico (diventa verde), sebbene sia chiaramente un "cheat" dal momento che in realtà nemmeno tenta di eseguire la funzione di calcolo di un fattoriale. Naturalmente, ora la parte del refactoring diventa un esercizio di "scrittura della funzionalità corretta" 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 l'equilibrio tra "scrivere il codice minimo per superare il test" pur continuando a mantenerlo funzionale e nello spirito di ciò che si sta effettivamente cercando di ottenere?

    
posta CraigTP 25.11.2010 - 16:40
fonte

11 risposte

42

È perfettamente legittimo. Rosso, Verde, Refactor.

Il primo test passa.

Aggiungi il secondo test, con un nuovo input.

Ora diventa subito verde, puoi aggiungere un if-else, che funziona bene. Passa, ma non hai ancora finito.

La terza parte di Red, Green, Refactor è la più importante. Refactor per rimuovere la duplicazione . Avrai duplicazione 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.

    
risposta data 25.11.2010 - 17:14
fonte
24

È necessario comprendere chiaramente l'obiettivo finale e il raggiungimento di un algoritmo che soddisfi tale obiettivo.

TDD non è una bacchetta magica per il design; devi ancora sapere come risolvere i problemi usando il codice, e devi ancora sapere come farlo ad un livello più alto di poche righe di codice per fare un passaggio 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 un design migliore nel suo complesso. Ma devi ancora sapere come progettare una soluzione.

Non sono favorevole alle filosofie di TDD riduzioniste che sostengono che è possibile far crescere un'applicazione semplicemente scrivendo la più piccola quantità di codice per superare un test. Senza pensare all'architettura, questo non funzionerà, e il tuo esempio lo dimostra.

Uncle Bob Martin dice questo:

If you're not doing Test Driven Development, it's very difficult to call yourself a professional. Jim Coplin called me on the carpet for this one. He didn't like that I said that. In fact, his position right now is that Test Driven Development is destroying architectures because people are writing tests to the abandon of any other kind of thought and tearing their architectures apart in the mad rush to get tests to pass and he's got an interesting point, that's an interesting way to abuse the ritual and lose the intent behind the discipline.

if you're not thinking through the architecture, if what you're doing instead is ignoring architecture and throwing tests together and getting them to pass, you're destroying the thing that will allow the building to stay up because it's the concentration on the structure of the system and solid design decisions that help the system maintain its structural integrity.

You cannot simply just throw a whole bunch of tests together and make them pass for decade after decade after decade and assume that you're system is going to survive. We don't want to evolve ourselves into hell. So a good test driven developer is always conscious of making architectural decisions, always thinking of the big picture.

    
risposta data 25.11.2010 - 17:15
fonte
15

Una domanda molto buona ... e non sono d'accordo con quasi tutti tranne @Robert.

La scrittura

return 120;

per una funzione fattoriale fare un passaggio di prova è una perdita di tempo . Non è "barare", né sta seguendo letteralmente il reflex rosso-verde. È sbagliato .

Ecco perché:

  • Calcola fattoriale è la funzione, non "restituisce una costante". "return 120" è non un calcolo.
  • gli argomenti del "refactoring" sono fuorviati; se hai due casi di test per 5 e 6, questo codice è ancora sbagliato, perché non stai calcolando un fattoriale :

    if (input == 5) { return 120; } //input=5 case
    else { return 720; }   //input=6 case
    
  • se seguiamo l'argomento 'refactor' letteralmente , allora quando avremo 5 casi di test invocheremo YAGNI e implementeremo 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, sei . E non è questo il compito!

    
risposta data 26.11.2010 - 17:25
fonte
9

Quando hai scritto solo un test di unità, l'implementazione su una sola riga ( return 120; ) è legittima. Scrivendo un ciclo calcolando il valore di 120 - questo sarebbe barare!

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

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

    
risposta data 25.11.2010 - 17:48
fonte
5

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

Ciò ti consente di posticipare ulteriori progetti finché non scrivi effettivamente i test che esercitano gli altri valori di ritorno di questo metodo.

Ricorda che il test è la versione eseguibile della tua specifica, e se tutte le specifiche che dicono che f (6) = 120 è quella che si adatta perfettamente alla fattura.

    
risposta data 25.11.2010 - 17:01
fonte
4

Se sei in grado di "imbrogliare" in questo modo, suggerisce che i tuoi test di unità sono difettosi.

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

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

Quindi chiediti: se qualcuno dovesse cambiare l'implementazione in qualcosa di sbagliato, i tuoi test sarebbero ancora passati o direbbero "aspetta un minuto!"?

Tenendolo a mente, se il tuo unico test era quello della tua domanda, tecnicamente, l'implementazione corrispondente è corretta. Il problema viene quindi considerato come requisiti non ben definiti.

    
risposta data 25.11.2010 - 17:01
fonte
3

Basta scrivere altri 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;
    }
}

: -)

    
risposta data 25.11.2010 - 17:28
fonte
2

Scrivere i test "cheat" è OK, per valori sufficientemente piccoli di "OK". Ma ricorda: il test delle unità è completo solo quando tutti i test superano e non è possibile scrivere nuovi test che falliscono . Se vuoi davvero avere un metodo CalculateFactorial che contiene un gruppo di istruzioni if (o meglio ancora una grande opzione switch / caso :-) puoi farlo e dato che hai a che fare con un numero a precisione fissa, il codice richiesto per implementare questo è finito (anche se probabilmente piuttosto grande e brutto, e forse limitato dal compilatore o dai limiti di sistema sulla dimensione massima del codice di una procedura). A questo punto se veramente insisti che tutto lo sviluppo deve essere guidato da un test unitario puoi scrivere un test che richiede che il codice calcoli il risultato in una quantità di tempo inferiore a quella che può essere raggiunta seguendo tutti i rami dell'istruzione if .

Fondamentalmente, TDD può aiutarti a scrivere codice che implementa i requisiti correttamente , ma non può obbligarti a scrivere il codice buono . Dipende da te.

Condividi e divertiti.

    
risposta data 26.11.2010 - 13:30
fonte
1

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

Come soluzione per il tuo punto di vista di "è verificato solo il funzionamento con un determinato set di input", proporrei di utilizzare test basati sui dati, come la teoria di xunit. Il potere dietro questo concetto è che ti permette di creare facilmente le specifiche degli ingressi alle uscite.

Per Factorials, un test sarebbe simile a questo:

    [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 anche implementare una fornitura di dati di test (che restituisce IEnumerable<Tuple<xxx>> ) e codificare un invariante matematico, ad esempio dividendo ripetutamente per n restituirà n-1).

Trovo che questo tp sia un modo molto efficace di test.

    
risposta data 25.11.2010 - 18:31
fonte
1

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

Tuttavia, se sei davvero impegnato a imbrogliare, puoi scrivere un infinito se-allora. In questo caso, nulla potrebbe aiutare se non la revisione del codice. Sarai presto catturato dal test di accettazione ( scritto da un'altra persona! )

Il problema con i test unitari a volte i programmatori li considerano un lavoro non necessario. Il modo corretto per vederli è 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 fino a quando non ti rendi conto che la truffa non funziona e che è meglio codificare il modo corretto. Se senti ancora di non essere finito, non hai finito.

    
risposta data 25.11.2010 - 16:43
fonte
0

Suggerirei che la scelta del test non è il test migliore.

Vorrei iniziare con:

factorial (1) come primo test,

fattoriale (0) come secondo

factorial (-ve) come terzo

e poi continua con casi non banali

e termina con un caso di overflow.

    
risposta data 26.11.2010 - 18:27
fonte

Leggi altre domande sui tag