Refactoring - è appropriato semplicemente riscrivere il codice, purché tutti i test abbiano superato?


9

Di recente ho visto "All the Little Things" da RailsConf 2014. Durante questo discorso, Sandi Metz ha riformulato una funzione che include una grande dichiarazione if nidificata:

def tick
    if @name != 'Aged Brie' && @name != 'Backstage passes to a TAFKAL80ETC concert'
        if @quality > 0
            if @name != 'Sulfuras, Hand of Ragnaros'
                @quality -= 1
            end
        end
    else
        ...
    end
    ...
end

Il primo passo è suddividere la funzione in più piccole:

def tick
    case name
    when 'Aged Brie'
        return brie_tick
    ...
    end
end

def brie_tick
    @days_remaining -= 1
    return if quality >= 50

    @quality += 1
    @quality += 1 if @days_remaining <= 0
end

Ciò che ho trovato interessante è il modo in cui sono state scritte queste funzioni più piccole. brie_tick, ad esempio, non è stato scritto estraendo le parti pertinenti della tickfunzione originale , ma da zero facendo riferimento ai test_brie_*test unitari. Una volta superati tutti questi test unitari, è brie_tickstato considerato fatto. Una volta eseguite tutte le piccole funzioni, la tickfunzione monolitica originale è stata eliminata.

Sfortunatamente, il presentatore sembrava inconsapevole del fatto che questo approccio ha portato a tre delle quattro *_tickfunzioni sbagliate (e l'altra era vuota!). Esistono casi limite in cui il comportamento delle *_tickfunzioni differisce da quello della tickfunzione originale . Ad esempio, @days_remaining <= 0in brie_tickdovrebbe essere < 0- quindi brie_ticknon funziona correttamente quando viene chiamato con days_remaining == 1e quality < 50.

Cosa è andato storto qui? È un fallimento del test - perché non ci sono stati test per questi casi limite particolari? O un fallimento del refactoring - perché il codice avrebbe dovuto essere trasformato passo dopo passo anziché riscritto da zero?


2
Non sono sicuro di avere la domanda. Ovviamente è OK riscrivere il codice. Non sono sicuro di cosa significhi specificamente "va bene semplicemente riscrivere il codice". Se stai chiedendo "Va bene riscrivere il codice senza pensarci troppo", la risposta è no, così come non è OK scrivere il codice in quel modo.
John Wu,

Ciò accade spesso a causa di piani di test incentrati principalmente sul test di casi d'uso di successo e pochissimo (o per niente) sulla copertura di casi di errore o di sottoutilizzo. Quindi è principalmente una perdita di copertura. Una perdita di test.
Laiv

@JohnWu - Avevo l'impressione che il refactoring fosse generalmente fatto come una serie di piccole trasformazioni al codice sorgente ("metodo di estrazione" ecc.) Piuttosto che semplicemente riscrivendo il codice (con cui intendo riscriverlo da zero senza nemmeno guardando il codice esistente, come fatto nella presentazione collegata).
user200783

@JohnWu - La riscrittura da zero è una tecnica di refactoring accettabile? Altrimenti, è deludente vedere una presentazione così apprezzata sul refactoring adottare questo approccio. OTOH, se è accettabile, allora i cambiamenti involontari nel comportamento possono essere attribuiti ai test mancanti - ma c'è un modo per essere sicuri che i test coprano tutti i possibili casi limite?
user200783

@ User200783 Bene, questa è una domanda più grande, non è vero (come posso garantire che i miei test siano completi?) Pragmaticamente, probabilmente eseguirò un rapporto sulla copertura del codice prima di apportare qualsiasi modifica ed esaminerei attentamente tutte le aree di codice che non lo fanno esercitati, assicurandoti che il team di sviluppo sia consapevole di loro mentre riscrivono la logica.
John Wu,

Risposte:


11

È un fallimento del test - perché non ci sono stati test per questi casi limite particolari? O un fallimento del refactoring - perché il codice avrebbe dovuto essere trasformato passo dopo passo anziché riscritto da zero?

Tutti e due. Il refactoring utilizzando solo i passaggi standard del libro originale di Fowlers è sicuramente meno soggetto a errori rispetto a una riscrittura, quindi è spesso preferibile utilizzare solo questi tipi di piccoli passi. Anche se non ci sono test unitari per ogni caso limite, e anche se l'ambiente non fornisce refactoring automatici, un singolo cambio di codice come "introduci spiegando la variabile" o "estrae funzione" ha una possibilità molto più piccola di cambiare i dettagli di comportamento del codice esistente rispetto a una riscrittura completa di una funzione.

A volte, tuttavia, riscrivere una sezione di codice è ciò di cui hai bisogno o che vuoi fare. E se è così, hai bisogno di test migliori.

Si noti che anche quando si utilizza uno strumento di refactoring, c'è sempre un certo rischio di introdurre errori quando si modifica il codice, indipendentemente dall'applicazione di passaggi più piccoli o più grandi. Ecco perché il refactoring necessita sempre di test. Si noti inoltre che i test possono solo ridurre la probabilità di bug, ma mai provare la loro assenza - tuttavia l'utilizzo di tecniche come guardare il codice e la copertura del ramo può darti un alto livello di confidenza, e in caso di riscrittura di una sezione di codice, è spesso vale la pena applicare tali tecniche.


1
Grazie, ha senso. Quindi, se la soluzione definitiva ai cambiamenti indesiderabili nel comportamento è quella di avere test completi, c'è modo di essere sicuri che i test coprano tutti i possibili casi limite? Ad esempio, sarebbe possibile avere una copertura del 100% brie_ticksenza ancora testare il @days_remaining == 1caso problematico , ad esempio testando con @days_remainingset su 10e -10.
user200783

2
Non si può mai essere assolutamente certi che i test coprano tutti i possibili casi limite, poiché non è possibile testare con tutti gli input possibili. Ma ci sono molti modi per acquisire maggiore confidenza nei test. È possibile esaminare il test delle mutazioni , che è un modo per testare l'efficacia dei test.
bdsl,

1
In questo caso, i rami mancanti potrebbero essere stati catturati con uno strumento di copertura del codice durante lo sviluppo dei test.
cbojar,

2

Cosa è andato storto qui? È un fallimento del test - perché non ci sono stati test per questi casi limite particolari? O un fallimento del refactoring - perché il codice avrebbe dovuto essere trasformato passo dopo passo anziché riscritto da zero?

Una delle cose che è davvero difficile lavorare con il codice legacy: acquisire una comprensione completa del comportamento attuale.

Il codice legacy senza test che vincolano tutti i comportamenti è un modello comune in natura. Ciò ti lascia supporre: ciò significa che i comportamenti non vincolati sono variabili libere? o requisiti non specificati?

Dal discorso :

Ora questo è un vero refactoring secondo la definizione di refactoring; Rifatterò questo codice. Ho intenzione di cambiare la sua disposizione senza alterarne il comportamento.

Questo è l'approccio più conservativo; se i requisiti possono essere specificati in modo insufficiente, se i test non acquisiscono tutta la logica esistente, è necessario fare molta attenzione a come si procede.

Certo, puoi affermare che se i test descrivono in modo inadeguato il comportamento del sistema, hai un "fallimento del test". E penso che sia giusto - ma in realtà non utile; questo è un problema comune da avere in natura.

O un fallimento del refactoring - perché il codice avrebbe dovuto essere trasformato passo dopo passo anziché riscritto da zero?

Il problema non è proprio che le trasformazioni avrebbero dovuto essere graduali; ma piuttosto che la scelta dello strumento di refactoring (operatore di tastiera umana? piuttosto che automazione guidata) non era ben allineata con la copertura del test, a causa del più alto tasso di errore.

Questo avrebbe potuto essere affrontata sia utilizzando strumenti di refactoring con maggiore affidabilità o introducendo una batteria più ampia di test per migliorare i vincoli del sistema.

Quindi penso che la tua congiunzione sia scelta male; ANDno OR.


2

Il refactoring non dovrebbe modificare il comportamento visibile esternamente del codice. Questo è l'obiettivo.

Se i test delle unità falliscono, significa che hai cambiato il comportamento. Ma superare i test unitari non è mai l'obiettivo. Aiuta più o meno a raggiungere il tuo obiettivo. Se il refactoring modifica il comportamento visibile esternamente e tutti i test unitari superano, il refactoring fallisce.

I test delle unità di lavoro in questo caso ti danno solo la sensazione sbagliata di successo. Ma cosa è andato storto? Due cose: il refactoring era disattento e i test unitari non erano molto buoni.


1

Se si definisce "corretto" come "il superamento dei test", per definizione non è sbagliato modificare il comportamento non testato.

Se è necessario definire un comportamento del bordo particolare , aggiungere un test per esso, in caso contrario, è OK non preoccuparsi di ciò che accade. Se sei veramente pedante, puoi scrivere un test che controlla truequando in quel caso limite documenta che non ti importa quale sia il comportamento.

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.