Come si mantengono in funzione i test unitari durante il refactoring?


29

In un'altra domanda, è stato rivelato che uno dei problemi con TDD è mantenere la suite di test in sincronia con la base di codice durante e dopo il refactoring.

Ora sono un grande fan del refactoring. Non ho intenzione di rinunciare a fare TDD. Ma ho anche riscontrato i problemi dei test scritti in modo tale che un piccolo refactoring porti a molti fallimenti dei test.

Come evitare i test di rottura durante il refactoring?

  • Scrivi i test "meglio"? In tal caso, cosa dovresti cercare?
  • Eviti alcuni tipi di refactoring?
  • Esistono strumenti di test di refactoring?

Modifica: ho scritto una nuova domanda che mi ha chiesto cosa intendevo fare (ma ho mantenuto questa come una variante interessante).


7
Avrei pensato che, con TDD, il tuo primo passo nel refactoring è scrivere un test che fallisce e quindi riformattare il codice per farlo funzionare.
Matt Ellen,

Il tuo IDE non riesce a capire come riformattare anche i test?

@ Thorbjørn Ravn Andersen, sì, e ho scritto una nuova domanda che mi ha chiesto cosa volevo fare (ma ho mantenuto questa come una variante interessante; vedi la risposta di azheglov, che è essenzialmente quello che dici)
Alex Feinman,

Considerato l'aggiunta di thar Info a questa domanda?

Risposte:


35

Quello che stai cercando di fare non è davvero il refactoring. Con il refactoring, per definizione, non cambi ciò che fa il tuo software, ma cambi anche il modo in cui lo fa.

Inizia con tutti i test verdi (tutti i passaggi), quindi apporta le modifiche "sotto il cofano" (ad es. Sposta un metodo da una classe derivata alla base, estrai un metodo o incapsula un composito con un generatore , ecc.). I test dovrebbero comunque passare.

Quello che stai descrivendo sembra non essere refactoring, ma una riprogettazione, che aumenta anche la funzionalità del tuo software sotto test. TDD e refactoring (come ho cercato di definirlo qui) non sono in conflitto. Puoi ancora refactoring (verde-verde) e applicare TDD (rosso-verde) per sviluppare la funzionalità "delta".


7
Lo stesso codice X ha copiato 15 posti. Personalizzato in ogni luogo. La rendi una libreria comune e parametrizzi la X o usi il modello di strategia per tenere conto di queste differenze. Garantisco che i test unitari per X falliranno. I client di X falliranno perché l'interfaccia pubblica cambia leggermente. Riprogettazione o refactoring? Lo chiamo refattore, ma in entrambi i casi rompe ogni sorta di cose. La linea di fondo è che non puoi refactoring a meno che tu non sappia esattamente come si adatta tutto insieme. Quindi fissare i test è noioso ma alla fine banale.
Kevin,

3
Se i test richiedono una regolazione costante, è probabilmente un suggerimento di avere test troppo dettagliati. Ad esempio, supponiamo che un pezzo di codice debba innescare eventi A, B e C in determinate circostanze, in nessun ordine particolare. Il vecchio codice lo fa per ABC e i test prevedono gli eventi in quell'ordine. Se il codice refactored genera eventi in ordine ACB, continua a funzionare secondo le specifiche ma il test fallirà.
otto

3
@Kevin: credo che ciò che descrivi sia una riprogettazione, perché l'interfaccia pubblica cambia. La definizione di refactoring di Fowler ("alterare la struttura interna [del codice] senza cambiare il suo comportamento esterno") è abbastanza chiara al riguardo.
Azheglov,

3
@azheglov: forse, ma nella mia esperienza, se l'implementazione è sbagliata, lo è anche l'interfaccia
Kevin,

2
Una domanda perfettamente valida e chiara finisce in una discussione sul "significato della parola". Chi se ne frega di come lo chiami, facciamo quella discussione altrove. Nel frattempo questa risposta sta omettendo completamente qualsiasi risposta reale ma mantenendo di gran lunga il maggior numero di voti. Capisco perché la gente si riferisce al TDD come a una religione.
Dirk Boer,

21

Uno dei vantaggi di avere i test unitari è che puoi eseguire il refactoring in tutta sicurezza.

Se il refactoring non modifica l'interfaccia pubblica, si lasciano i test unitari così come sono e si assicura che dopo il refactoring tutti passino.

Se il refactoring modifica l'interfaccia pubblica, i test devono essere riscritti per primi. Rifattore fino al superamento dei nuovi test.

Non eviterei mai alcun refactoring perché interrompe i test. Scrivere test unitari può essere una seccatura ma ne vale la pena a lungo termine.


7

Contrariamente alle altre risposte, è importante notare che alcuni modi di test possono diventare fragili quando il sistema sotto test (SUT) viene refactored, se il test è whitebox.

Se sto usando un framework beffardo che verifica l' ordine dei metodi chiamati sulle simulazioni (quando l'ordine è irrilevante perché le chiamate sono prive di effetti collaterali); quindi se il mio codice è più pulito con quelle chiamate di metodo in un ordine diverso e io refactoring, il mio test si interromperà. In generale, le beffe possono introdurre fragilità nei test.

Se sto controllando lo stato interno del mio SUT esponendo i suoi membri privati ​​o protetti (potremmo usare "amico" in Visual Basic, o aumentare il livello di accesso "interno" e usare "internalsvisibleto" in c #; in molte lingue OO, tra cui c # una " sottoclasse specifica per il test " potrebbe essere utilizzata), quindi all'improvviso lo stato interno della classe avrà importanza - potresti refactoring della classe come una scatola nera, ma i test della scatola bianca falliranno. Supponiamo che un singolo campo venga riutilizzato per significare cose diverse (non una buona pratica!) Quando il SUT cambia stato - se lo dividiamo in due campi, potrebbe essere necessario riscrivere i test non funzionanti.

Le sottoclassi specifiche del test possono anche essere utilizzate per testare metodi protetti, il che può significare che un refattore dal punto di vista del codice di produzione è una svolta dal punto di vista del codice di test. Spostare alcune righe all'interno o all'esterno di un metodo protetto potrebbe non avere effetti collaterali sulla produzione, ma interrompere un test.

Se utilizzo " hook di test " o qualsiasi altro codice di compilazione specifico o condizionale del test, può essere difficile garantire che i test non vengano interrotti a causa delle fragili dipendenze dalla logica interna.

Quindi, per evitare che i test si accoppino ai dettagli interni intimi del SUT, può aiutare a:

  • Usa stub piuttosto che beffe, ove possibile. Per maggiori informazioni consultare il blog di Fabio Periera sui test tautologici e il mio blog sui test tautologici .
  • Se si usano beffe, evitare di verificare l'ordine dei metodi chiamati, a meno che non sia importante.
  • Cerca di evitare la verifica dello stato interno del tuo SUT - usa la sua API esterna se possibile.
  • Cerca di evitare la logica specifica del test nel codice di produzione
  • Cerca di evitare l'uso di sottoclassi specifiche del test.

Tutti i punti sopra riportati sono esempi di accoppiamento a scatola bianca utilizzati nei test. Quindi, per evitare completamente i test di rottura del refactoring, utilizzare il test black-box del SUT.

Disclaimer: allo scopo di discutere di refactoring qui, sto usando la parola un po 'più in generale per includere il cambiamento dell'implementazione interna senza effetti esterni visibili. Alcuni puristi potrebbero non essere d'accordo e riferirsi esclusivamente al libro Refactoring di Martin Fowler e Kent Beck, che descrive le operazioni di refactoring atomico.

In pratica, tendiamo a compiere passi non-break leggermente più grandi rispetto alle operazioni atomiche ivi descritte, e in particolare i cambiamenti che lasciano il codice di produzione comportarsi in modo identico dall'esterno potrebbero non lasciare passare i test. Ma penso che sia giusto includere "un algoritmo sostitutivo per un altro algoritmo che ha un comportamento identico" come un refattore, e penso che Fowler sia d'accordo. Lo stesso Martin Fowler afferma che il refactoring può interrompere i test:

Quando scrivi un test di simulazione, stai testando le chiamate in uscita del SUT per assicurarti che parli correttamente con i suoi fornitori. Un test classico si preoccupa solo dello stato finale, non di come lo stato è stato derivato. I test di simulazione sono quindi più associati all'implementazione di un metodo. La modifica della natura delle chiamate ai collaboratori di solito provoca l'interruzione di un test di simulazione.

[...]

L'accoppiamento con l'implementazione interferisce anche con il refactoring, poiché le modifiche all'implementazione hanno maggiori probabilità di superare i test rispetto ai test classici.

Fowler - Le beffe non sono tronconi


Fowler scrisse letteralmente il libro su Refactoring; e il libro più autorevole sui test unitari (xUnit Test Patterns di Gerard Meszaros) è nella serie "firma" di Fowler, quindi quando dice che il refactoring può superare un test, probabilmente ha ragione.
perfezionista il

5

Se i tuoi test si interrompono quando esegui il refactoring, per definizione non stai eseguendo il refactoring, che "modifica la struttura del tuo programma senza cambiare il comportamento del tuo programma".

A volte è necessario modificare il comportamento dei test. Forse hai bisogno di unire due metodi insieme (diciamo, bind () e Listen () su una classe socket TCP in ascolto), quindi hai altre parti del tuo codice che provano e non riescono a usare l'API ora modificata. Ma questo non è refactoring!


E se cambia il nome di un metodo testato dai test? I test falliranno se non li rinominerai anche nei test. Qui non sta cambiando il comportamento del programma.
Oscar Mederos,

2
Nel qual caso anche i suoi test vengono sottoposti a refactoring. Devi stare attento però: prima rinominare il metodo, quindi eseguire il test. Dovrebbe fallire per i giusti motivi (non può essere compilato (C #), si ottiene un'eccezione MessageNotUnderstood (Smalltalk), nulla sembra accadere (modello null-eating di Objective-C)). Quindi modifichi il test, sapendo che non hai introdotto accidentalmente alcun bug. "Se i test si interrompono" significa "se i test si interrompono dopo aver terminato il refactoring", in altre parole. Prova a mantenere piccoli i pezzi di ricambio!
Frank Shearar,

1
I test unitari sono intrinsecamente accoppiati alla struttura del codice. Ad esempio, Fowler ne ha molti in refactoring.com/catalog che potrebbero influire sui test unitari (ad esempio, metodo hide, metodo inline, sostituzione del codice di errore con eccezione e così via).
Kristian H,

falsa. La fusione di due metodi insieme è ovviamente un refactoring che ha nomi ufficiali (ad es. Il refactoring di metodi inline si adatta alla definizione) e interromperà i test di un metodo che è in linea - alcuni dei casi di test ora dovrebbero essere riscritti / testati con altri mezzi. Non devo modificare il comportamento di un programma per interrompere i test unitari, tutto quello che devo fare è ristrutturare gli interni con test unitari associati. Finché il comportamento di un programma non viene modificato, ciò si adatta comunque alla definizione di refactoring.
KolA

Ho scritto quanto sopra presupponendo test ben scritti: se stai testando la tua implementazione - se la struttura del test rispecchia gli interni del codice in prova, certo. In tal caso, testare il contratto dell'unità, non l'implementazione.
Frank Shearar,

4

Penso che il problema con questa domanda sia che persone diverse stanno prendendo la parola "refactoring" in modo diverso. Penso che sia meglio definire attentamente alcune cose che probabilmente intendi:

>  Keep the API the same, but change how the API is implemented internally
>  Change the API

Come ha già notato un'altra persona, se si mantiene l'API uguale e tutti i test di regressione funzionano sull'API pubblica, non si dovrebbero avere problemi. Il refactoring non dovrebbe causare alcun problema. Qualsiasi test fallito OVUNQUE significa che il tuo vecchio codice aveva un bug e il tuo test non è valido, oppure il tuo nuovo codice ha un bug.

Ma è abbastanza ovvio. Quindi intendi PROBABILMENTE per refactoring che stai cambiando l'API.

Quindi lasciami rispondere su come affrontarlo!

  • Innanzitutto crea una NUOVA API, che fa quello che vuoi che sia il tuo NUOVO comportamento API. Se succede che questa nuova API ha lo stesso nome di un'API OLDER, allora aggiungo il nome _NEW al nuovo nome API.

    int DoSomethingInterestingAPI ();

diventa:

int DoSomethingInterestingAPI_NEW( int takes_more_arguments );
int DoSomethingInterestingAPI_OLD();
int DoSomethingInterestingAPI() { DoSomethingInterestingAPI_NEW (whatever_default_mimics_the_old_API);

OK - in questa fase - tutti i test di regressione superano il davanzale - usando il nome DoSomethingInterestingAPI ().

SUCCESSIVO, scorrere il codice e modificare tutte le chiamate a DoSomethingInterestingAPI () nella variante appropriata di DoSomethingInterestingAPI_NEW (). Ciò include l'aggiornamento / la riscrittura delle parti dei test di regressione che è necessario modificare per utilizzare la nuova API.

SUCCESSIVO, contrassegnare DoSomethingInterestingAPI_OLD () come [[deprecated ()]]. Mantieni l'API obsoleta per tutto il tempo che desideri (fino a quando non avrai aggiornato in modo sicuro tutto il codice che potrebbe dipendere da esso).

Con questo approccio, qualsiasi errore nei test di regressione è semplicemente un bug nel test di regressione o identifica i bug nel tuo codice, esattamente come vorresti. Questo processo graduale di revisione di un'API mediante la creazione esplicita delle versioni _NEW e _OLD dell'API consente di far coesistere per un po 'parti del vecchio e nuovo codice.


Mi piace questa risposta perché è ovvio che Unit Test per il SUT è lo stesso dei client esterni per un'APi pubblicata. Ciò che prescrivi è molto simile al protocollo SemVer per gestire la libreria / componente pubblicata al fine di evitare "l'inferno delle dipendenze". Questo tuttavia ha un costo in termini di tempo e flessibilità, estrapolare questo approccio all'interfaccia pubblica di ogni microunità significa anche estrapolare i costi. Un approccio più flessibile consiste nel disaccoppiare i test dall'implementazione il più possibile, ad esempio i test di integrazione o un DSL separato per descrivere gli input e gli output dei test
KolA

1

Suppongo che i tuoi test unitari siano di una granularità che definirei "stupidi" :) cioè testano le minuzie assolute di ogni classe e funzione. Allontanati dagli strumenti del generatore di codice e scrivi i test che si applicano su una superficie più grande, quindi puoi riformattare gli interni quanto vuoi, sapendo che le interfacce per le tue applicazioni non sono cambiate e i tuoi test funzionano ancora.

Se vuoi avere test unitari che testano ogni singolo metodo, allora aspettati di doverli refactoring allo stesso tempo.


1
La risposta più utile che in realtà affronta la domanda - non costruire la copertura del test su una base instabile di curiosità interne, o non aspettarti che cada costantemente - ma la maggior parte declassata perché TDD prescrive di fare esattamente il contrario. Questo è ciò che si ottiene sottolineando una verità scomoda su un approccio iperprotetto.
KolA,

1

mantenere la suite di test in sincronia con la base di codice durante e dopo il refactoring

Ciò che rende difficile l' accoppiamento . Qualsiasi test viene fornito con un certo grado di accoppiamento ai dettagli di implementazione, ma i test unitari (indipendentemente dal fatto che sia TDD o meno) sono particolarmente dannosi perché interferiscono con gli interni: più test unitari equivalgono a più codice accoppiato ad unità, ad esempio metodi firme / qualsiasi altra interfaccia pubblica di unità - almeno.

Le "unità" per definizione sono dettagli di implementazione di basso livello, l'interfaccia delle unità può e deve cambiare / dividere / unire e altrimenti mutare man mano che il sistema si evolve. L'abbondanza di test unitari può effettivamente ostacolare questa evoluzione più di quanto aiuti.

Come evitare i test di rottura durante il refactoring? Evitare l'accoppiamento. In pratica significa evitare quante più unit test possibile e preferire test di livello superiore / integrazione più agnostici dei dettagli di implementazione. Ricorda però che non esiste un proiettile d'argento, i test devono ancora accoppiarsi a qualcosa a un certo livello, ma idealmente dovrebbe essere un'interfaccia che viene esplicitamente versionata usando il Semantic Versioning, di solito a livello di API / applicazione pubblicato (non vuoi fare SemVer per ogni singola unità nella tua soluzione).


0

I tuoi test sono troppo strettamente associati all'implementazione e non al requisito.

potresti scrivere i tuoi test con commenti come questo:

//given something
...test code...
//and something else
...test code...
//when something happens
...test code...
//then the state should be...
...test code...

in questo modo non è possibile modificare il significato dei test.

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.