Perché scrivere i test per il codice che rifatterò?


15

Sto eseguendo il refactoring di un'enorme classe di codice legacy. Refactoring (presumo) sostiene questo:

  1. scrivere test per la classe legacy
  2. rifatti il ​​diavolo fuori dalla classe

Problema: una volta effettuato il refactoring della classe, i miei test nel passaggio 1 dovranno essere modificati. Ad esempio, ciò che una volta era in un metodo legacy, ora potrebbe essere invece una classe separata. Quello che era un metodo ora potrebbe essere diversi metodi. L'intero panorama della classe legacy può essere cancellato in qualcosa di nuovo, quindi i test che scrivo nel passaggio 1 saranno quasi nulli. In sostanza aggiungerò il passaggio 3. riscrivere abbondantemente i miei test

Qual è lo scopo quindi di scrivere i test prima del refactor? Sembra più un esercizio accademico per creare più lavoro per me stesso. Sto scrivendo test per il metodo ora e sto imparando di più su come testare le cose e su come funziona il metodo legacy. Si può imparare leggendo semplicemente il codice legacy stesso, ma scrivere test è quasi come strofinarci il naso e documentare anche questa conoscenza temporanea in test separati. Quindi in questo modo non ho quasi altra scelta che imparare cosa sta facendo il codice. Ho detto temporaneo qui, perché rifarò il controllo del codice e tutta la mia documentazione e i test saranno nulli per una parte significativa, tranne che le mie conoscenze rimarranno e mi permetteranno di essere più fresco sul refactoring.

È questa la vera ragione per scrivere test prima di refactor - per aiutarmi a capire meglio il codice? Ci deve essere un altro motivo!

Spiega per favore!

Nota:

C'è questo post: ha senso scrivere test per il codice legacy quando non c'è tempo per un refactoring completo? ma dice "scrivi test prima di refactor", ma non dice "perché", o cosa fare se "scrivere test" sembra "lavoro occupato che verrà distrutto presto"


1
La tua premessa è errata. Non cambierai i tuoi test. Scriverai nuovi test. Il passaggio 3 sarà "eliminare tutti i test che sono ora defunti."
pdr

1
Il passaggio 3 può quindi leggere "Scrivi nuovi test. Elimina i test defunti". Penso che equivale ancora a distruggere il lavoro originale
Dennis

3
No, vuoi scrivere i nuovi test durante il passaggio 2. E sì, il passaggio 1 viene distrutto. Ma è stata una perdita di tempo? No, perché ti dà un sacco di rassicurazioni sul fatto che non stai rompendo nulla durante il passaggio 2. I tuoi nuovi test no.
pdr

3
@Dennis - mentre condivido molte delle stesse preoccupazioni che hai riguardo alle situazioni, potremmo considerare la maggior parte degli sforzi di refactoring come "distruggere il lavoro originale" ma se non lo distruggessimo mai, non ci allontaneremmo mai dal codice spaghetti con 10k righe in uno file. Lo stesso dovrebbe probabilmente andare per i test unitari, vanno di pari passo con il codice che stanno testando. Man mano che il codice si evolve e le cose vengono spostate e / o eliminate, anche i test unitari dovrebbero evolversi con esso.
DXM

"Comprendere il codice" non è un piccolo vantaggio. Come ti aspetti di riformattare un programma che non capisci? È inevitabile e quale modo migliore per dimostrare la vera comprensione di un programma che scrivere un test approfondito. Inoltre, si dovrebbe dire che quanto più astratto è il test, tanto meno sarà probabile che dovrai graffiarlo in un secondo momento, quindi, se non altro, attenersi a test di alto livello all'inizio.
Neil,

Risposte:


46

Il refactoring sta ripulendo un pezzo di codice (ad esempio migliorando lo stile, il design o gli algoritmi), senza cambiare il comportamento (visibile esternamente). Scrivi test per non assicurarti che il codice prima e dopo il refactoring sia lo stesso, invece scrivi test come indicatore che la tua applicazione prima e dopo il refactoring si comporta allo stesso modo: il nuovo codice è compatibile e non sono stati introdotti nuovi bug.

La tua preoccupazione principale dovrebbe essere quella di scrivere unit test per l'interfaccia pubblica del tuo software. Questa interfaccia non dovrebbe cambiare, quindi anche i test (che sono un controllo automatico per questa interfaccia) non dovrebbero cambiare.

Tuttavia, i test sono utili anche per individuare gli errori, quindi può avere senso scrivere anche test per parti private del software. Si prevede che questi test cambieranno durante il refactoring. Se si desidera modificare un dettaglio dell'implementazione (come la denominazione di una funzione privata), è necessario innanzitutto aggiornare i test in modo da riflettere le aspettative modificate, quindi assicurarsi che il test abbia esito negativo (le aspettative non sono soddisfatte), quindi modificare il codice effettivo e controlla che tutti i test passino di nuovo. In nessun caso i test per l'interfaccia pubblica dovrebbero iniziare a fallire.

Ciò è più difficile quando si eseguono modifiche su una scala più ampia, ad esempio riprogettando più parti codipendenti. Ma ci sarà una sorta di confine e a quel limite sarai in grado di scrivere test.


6
+1. Leggi la mia mente, ha scritto la mia risposta. Punto importante: potrebbe essere necessario scrivere unit test per dimostrare che gli stessi bug sono ancora presenti dopo il refactoring!
david.pfx il

Domanda: perché nell'esempio di modifica del nome della funzione, si modifica prima il test per assicurarsi che non abbia esito positivo? Voglio dire che ovviamente fallirà quando lo cambi: hai interrotto la connessione che i linker usano per legare insieme il codice! Ti stai forse aspettando che potrebbe esserci un'altra funzione privata esistente con il nome che hai appena scelto e devi verificare che non sia il caso nel caso in cui ti sia perso? Vedo che questo ti darà una certa certezza al confine con il DOC, ma in questo caso sembra un po 'eccessivo. C'è mai una possibile ragione per cui il test nel tuo esempio non fallirà?
Dennis,

^ cont: come tecnica generale vedo che è bene fare il controllo di sanità mentale passo-passo del tuo codice per scoprire che le cose vanno male il prima possibile. Un po 'come se non ti ammalassi se non ti lavi le mani ogni volta, ma semplicemente lavarti le mani come un'abitudine ti manterrà più sano, sia che entri in contatto con cose contaminate o meno. Qui a volte potresti lavarti le mani in modo superfluo o a volte testare il codice in modo superfluo, ma aiuta a mantenere te e il tuo codice in salute. È questo il punto?
Dennis,

@Dennis in realtà, stavo inconsciamente descrivendo un esperimento scientificamente corretto: non possiamo dire quale parametro abbia effettivamente influenzato il risultato quando ho cambiato più di un parametro. Ricorda che i test sono codice e ogni codice contiene bug. Andrai all'inferno programmatore per non aver eseguito i test prima di toccare il codice? Sicuramente no: durante l'esecuzione dei test sarebbe l'ideale, è il tuo giudizio professionale se è necessario. Nota inoltre che un test ha esito negativo se non viene compilato e che la mia risposta è applicabile anche ai linguaggi dinamici, non solo ai linguaggi statici con un linker.
amon

2
Dalla correzione di vari errori durante il refactoring mi rendo conto che non avrei fatto le mosse del codice con la stessa facilità senza test. I test mi avvisano di "differenze" comportamentali / funzionali che presento cambiando il mio codice.
Dennis

7

Ah, mantenendo i sistemi legacy.

Idealmente, i test trattano la classe solo attraverso la sua interfaccia con il resto della base di codice, altri sistemi e / o interfaccia utente. Interfacce. Non è possibile riformattare l'interfaccia senza influire su quei componenti a monte o a valle. Se è tutto un casino strettamente accoppiato, potresti anche considerare lo sforzo di riscrivere piuttosto che refactoring, ma è in gran parte semantico.

Modifica: diciamo che parte del codice misura qualcosa e ha una funzione che restituisce semplicemente un valore. L'unica interfaccia è chiamare la funzione / metodo / whatnot e ricevere il valore restituito. Si tratta di un accoppiamento lento e di un facile test unitario. Se il tuo programma principale ha un sottocomponente che gestisce un buffer, e tutte le chiamate ad esso dipendono dal buffer stesso, da alcune variabili di controllo e restituisce i messaggi di errore attraverso un'altra sezione di codice, allora potresti dire che è strettamente accoppiato ed è difficile da testare l'unità. Puoi ancora farlo con quantità sufficienti di oggetti finti e quant'altro, ma diventa disordinato. Soprattutto in c. Qualsiasi quantità di refactoring del funzionamento del buffer interromperà il sottocomponente.
Fine modifica

Se stai testando la tua classe attraverso interfacce che rimangono stabili, i test dovrebbero essere validi prima e dopo il refactoring. Ciò ti consente di apportare modifiche con la certezza di non averlo rotto. Almeno, più fiducia.

Ti consente anche di apportare modifiche incrementali. Se questo è un grande progetto, non credo che vorrai semplicemente demolire tutto, costruire un sistema nuovo di zecca e quindi iniziare a sviluppare test. Puoi cambiarne una parte, testarlo e assicurarti che il cambiamento non riduca il resto del sistema. O se lo fa, puoi almeno vedere il gigantesco pasticcio aggrovigliato che si sviluppa piuttosto che essere sorpreso da esso quando rilasci.

Mentre potresti dividere un metodo in tre, continueranno a fare la stessa cosa del metodo precedente, quindi puoi fare il test per il vecchio metodo e dividerlo in tre. Lo sforzo di scrivere il primo test non è sprecato.

Inoltre, trattare la conoscenza del sistema legacy come "conoscenza temporanea" non andrà bene. Sapere come ha fatto in precedenza è fondamentale quando si tratta di sistemi legacy. Molto utile per l'annosa domanda del "perché diavolo lo fa?"


Penso di aver capito, ma mi hai perso con le interfacce. cioè i test che sto scrivendo ora verificano se alcune variabili sono state popolate correttamente, dopo aver chiamato il metodo sotto test. Se tali variabili vengono modificate o refactored, lo saranno anche i miei test. La classe legacy esistente con cui sto lavorando non ha interfacce / getter / setter per seh, il che comporterebbe cambiamenti variabili o un lavoro meno intenso. Ma di nuovo non sono sicuro di cosa intendi per interfacce quando si tratta di codice legacy. Forse posso crearne un po '? Ma questo sarà refactoring.
Dennis,

1
Sì, se hai una divinità che fa tutto, allora davvero non ci sono interfacce. Ma se chiama un'altra classe, la classe superiore si aspetta che si comporti in un certo modo, e i test unitari possono verificarlo. Tuttavia, non farei finta che non dovrai aggiornare i tuoi test unitari durante il refactoring.
Filippo

4

La mia risposta / realizzazione:

Dalla correzione di vari errori durante il refactoring mi rendo conto che non avrei fatto le mosse del codice con la stessa facilità senza test. I test mi avvisano di "differenze" comportamentali / funzionali che presento cambiando il mio codice.

Non devi essere iper consapevole quando hai buoni test in atto. Puoi modificare il tuo codice in modo più rilassato. I test eseguono i controlli di verifica e di integrità per te.

Inoltre, i miei test sono rimasti pressoché identici a quelli del refactoring e non sono stati distrutti. In realtà ho notato alcune opportunità in più per aggiungere affermazioni ai miei test mentre approfondivo il codice.

AGGIORNARE

Bene, ora sto cambiando molto i miei test: / Perché ho modificato la funzione originale (rimossa la funzione e creato invece una nuova classe più pulita, spostando la lanugine che era all'interno della funzione al di fuori della nuova classe), quindi ora il codice sotto test che ho eseguito prima assume parametri diversi con un nome di classe diverso e produce risultati diversi (il codice originale con la lanugine aveva più risultati da testare). E quindi i miei test devono riflettere questi cambiamenti e sostanzialmente sto riscrivendo i miei test in qualcosa di nuovo.

Suppongo che ci siano altre soluzioni che posso fare per evitare di riscrivere i test. vale a dire mantenere il vecchio nome della funzione con il nuovo codice e la lanugine al suo interno ... ma non so se sia l'idea migliore e non ho ancora molta esperienza per fare un giudizio su cosa fare.


Sembra più che tu abbia riprogettato l'applicazione insieme al refactoring.
JeffO

Quando è il refactoring e quando viene ridisegnato? vale a dire quando si effettua il refactoring è difficile non suddividere le classi più ingombranti in classi più piccole e spostarle. Quindi sì, non sono esattamente sicuro della distinzione, ma forse sto facendo entrambe le cose.
Dennis,

3

Usa i tuoi test per guidare il tuo codice mentre lo fai. Nel codice legacy questo significa scrivere test per il codice che si intende modificare. In questo modo non sono un artefatto separato. I test dovrebbero riguardare ciò che il codice deve raggiungere e non le viscere interne di come lo fa.

Generalmente si desidera aggiungere test su codice che non ne ha) per il codice che si intende eseguire il refactoring per assicurarsi che il comportamento dei codici continui a funzionare come previsto. Pertanto, eseguire continuamente la suite di test durante il refactoring è una fantastica rete di sicurezza. Il pensiero di cambiare codice senza una suite di test per confermare che le modifiche non stanno influenzando qualcosa di imprevisto è spaventoso.

Per quanto riguarda il grintoso spirito di aggiornare vecchi test, scrivere nuovi test, eliminare vecchi test, ecc. Lo vedo solo come parte del costo dello sviluppo di software professionale moderno.


Il tuo primo paragrafo sembra sostenere l'ignoranza del passaggio 1 e la scrittura dei test mentre procede; il tuo secondo paragrafo sembra contraddirlo.
pdr

Aggiornato la mia risposta.
Michael Durrant,

2

Qual è l'obiettivo del refactoring nel tuo caso specifico?

Presumete ai fini della mia risposta che crediamo tutti (in una certa misura) nel TDD (Test-Driven Development).

Se lo scopo del tuo refactoring è quello di ripulire il codice esistente senza modificare il comportamento esistente, scrivere test prima del refactoring è come assicurarti di non aver modificato il comportamento del codice, se hai successo, i test avranno successo sia prima che dopo tu refattore.

  • I test ti aiuteranno a garantire che il tuo nuovo lavoro funzioni davvero.

  • I test probabilmente scopriranno anche casi in cui il lavoro originale non funziona.

Ma come si fa davvero qualsiasi refactoring significativo senza influire in qualche modo sul comportamento ?

Ecco un breve elenco di alcune cose che potrebbero accadere durante il refactoring:

  • rinomina variabile
  • funzione di rinomina
  • aggiungi funzione
  • funzione di eliminazione
  • dividere la funzione in due o più funzioni
  • combina due o più funzioni in una funzione
  • classe divisa
  • combinare le classi
  • rinomina classe

Sosterrò che ognuna di quelle attività elencate cambia il comportamento in qualche modo.

E ho intenzione di sostenere che se il refactoring cambia comportamento, i test sono ancora in corso di essere come si fa a garantire che non si è rotto niente.

Forse il comportamento non cambia a livello macro, ma il punto del test unitario non è quello di garantire il comportamento macro. Questo è test di integrazione . Il punto del test unitario è garantire che i singoli pezzi da cui si costruisce il prodotto non siano rotti. Catena, anello più debole, ecc.

Che ne dici di questo scenario:

  • Presumi di avere function bar()

  • function foo() fa una chiamata a bar()

  • function flee() effettua anche una chiamata alla funzione bar()

  • Solo per varietà, flam()chiamafoo()

  • Tutto funziona magnificamente (apparentemente, almeno).

  • Rifattori ...

  • bar() viene rinominato in barista()

  • flee() viene modificato in call barista()

  • foo()non è cambiato per chiamarebarista()

Ovviamente, i tuoi test per entrambi foo()e flam()ora falliscono.

Forse non ti sei reso conto di aver foo()chiamato bar()in primo luogo. Certamente non ti sei reso conto che flam()dipendesse bar()da foo().

Qualunque cosa. Il punto è che i tuoi test scopriranno il comportamento appena rotto di entrambi foo()e flam(), in modo incrementale durante il tuo lavoro di refactoring.

I test finiscono per aiutarti a refactoring bene.

A meno che tu non abbia alcun test.

È un po 'un esempio inventato. Ci sono quelli che sostengono che se si cambiano le bar()pause foo(), allora foo()era troppo complesso per cominciare e dovrebbe essere scomposto. Ma le procedure sono in grado di chiamare altre procedure per un motivo ed è impossibile eliminare tutta la complessità, giusto? Il nostro compito è gestire la complessità abbastanza bene.

Prendi in considerazione un altro scenario.

Stai costruendo un edificio.

Costruisci un'impalcatura per garantire che l'edificio sia costruito correttamente.

L'impalcatura ti aiuta a costruire un vano ascensore, tra le altre cose. Successivamente, abbatti il ​​ponteggio, ma il vano ascensore rimane. Hai distrutto "opere originali" distruggendo le impalcature.

L'analogia è tenue, ma il punto è che non è inaudito costruire strumenti per aiutarti a costruire il prodotto. Anche se gli strumenti non sono permanenti, sono utili (anche necessari). I falegnami fanno sempre le maschere, a volte solo per un lavoro. Quindi fanno a pezzi le maschere, a volte usando le parti per costruire altre maschere per altri lavori, a volte no. Ma ciò non rende le maschere inutili o sprecate.

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.