Come evitare errori logici nel codice, quando TDD non ha aiutato?

67

Recentemente stavo scrivendo un piccolo pezzo di codice che indicherebbe in modo umano a quanto vecchio è un evento. Ad esempio, potrebbe indicare che l'evento è accaduto "Tre settimane fa" o "Un mese fa" o "Ieri".

I requisiti erano relativamente chiari e questo era un caso perfetto per lo sviluppo basato sui test. Ho scritto i test uno per uno, implementando il codice per superare ogni test e tutto sembrava funzionare perfettamente. Fino a quando un bug è apparso in produzione.

Ecco la parte di codice rilevante:

now = datetime.datetime.utcnow()
today = now.date()
if event_date.date() == today:
    return "Today"

yesterday = today - datetime.timedelta(1)
if event_date.date() == yesterday:
    return "Yesterday"

delta = (now - event_date).days

if delta < 7:
    return _number_to_text(delta) + " days ago"

if delta < 30:
    weeks = math.floor(delta / 7)
    if weeks == 1:
        return "A week ago"

    return _number_to_text(weeks) + " weeks ago"

if delta < 365:
    ... # Handle months and years in similar manner.

I test stavano verificando il caso di un evento accaduto oggi, ieri, quattro giorni fa, due settimane fa, una settimana fa, ecc., e il codice è stato costruito di conseguenza.

Quello che mi è sfuggito è che un evento può accadere un giorno prima di ieri, pur essendo un giorno fa: per esempio un evento accaduto ventisei ore fa sarebbe un giorno fa, mentre non esattamente ieri se ora è l'1. Più esattamente, è un punto qualcosa, ma dal momento che delta è un numero intero, sarà solo uno. In questo caso, l'applicazione visualizza "Un giorno fa", che è ovviamente inaspettata e non gestita nel codice. Può essere risolto aggiungendo:

if delta == 1:
    return "A day ago"

subito dopo aver calcolato delta .

Mentre l'unica conseguenza negativa del bug è che ho sprecato mezz'ora a chiedermi come questo caso potrebbe accadere (e credendo che abbia a che fare con i fusi orari, nonostante l'uso uniforme di UTC nel codice), la sua presenza è mi preoccupa Indica che:

  • È molto facile commettere un errore logico anche in un semplice codice sorgente.
  • Lo sviluppo guidato dai test non ha aiutato.

Anche preoccupante è che non riesco a vedere come potrebbero essere evitati tali errori. A parte pensare prima di scrivere il codice, l'unico modo che posso pensare è di aggiungere un sacco di asserzioni per i casi che credo non accadrà mai (come credevo che un giorno fa sia necessariamente ieri), e poi di passare in rassegna ogni secondo per negli ultimi dieci anni, verificando l'eventuale violazione di asserzione, che sembra troppo complessa.

Come potrei evitare di creare questo bug in primo luogo?

    
posta Arseni Mourzenko 12.07.2018 - 23:39
fonte

16 risposte

57

Questi sono i tipi di errori che si trovano tipicamente nel passaggio refactor di red / green / refactor. Non dimenticare quel passaggio! Prendi in considerazione un refactor come il seguente (non verificato):

def pluralize(num, unit):
    if num == 1:
        return unit
    else:
        return unit + "s"

def convert_to_unit(delta, unit):
    factor = 1
    if unit == "week":
        factor = 7 
    elif unit == "month":
        factor = 30
    elif unit == "year":
        factor = 365
    return delta // factor

def best_unit(delta):
    if delta < 7:
        return "day"
    elif delta < 30:
        return "week"
    elif delta < 365:
        return "month"
    else:
        return "year"

def human_friendly(event_date):
    date = event_date.date()
    today = now.date()
    yesterday = today - datetime.timedelta(1)
    if date == today:
        return "Today"
    elif date == yesterday:
        return "Yesterday"
    else:
        delta = (now - event_date).days
        unit = best_unit(delta)
        converted = convert_to_unit(delta, unit)
        pluralized = pluralize(converted, unit)
        return "{} {} ago".format(converted, pluralized)

Qui hai creato 3 funzioni a un livello inferiore di astrazione che sono molto più coese e più facili da testare in isolamento. Se hai lasciato fuori un arco di tempo che intendi, si attaccherebbe come un pollice dolente nelle più semplici funzioni di aiuto. Inoltre, rimuovendo la duplicazione, si riduce il potenziale di errore. Dovresti effettivamente aggiungere codice per implementare il tuo caso rotto.

Anche altri casi di test più sottili vengono in mente più facilmente quando si guarda una forma refactored come questa. Ad esempio, che cosa dovrebbe fare best_unit se delta è negativo?

In altre parole, il refactoring non è solo per renderlo bello. Rende più facile agli umani individuare errori che il compilatore non può.

    
risposta data 13.07.2018 - 07:28
fonte
148

Test driven development didn't help.

Sembra che abbia aiutato, è solo che non hai avuto un test per lo scenario "un giorno fa". Presumibilmente, hai aggiunto un test dopo che questo caso è stato trovato; questo è ancora TDD, in quanto quando vengono scoperti dei bug si scrive un test unitario per rilevare il bug, quindi correggerlo.

Se ti dimentichi di scrivere un test per un comportamento, TDD non ha nulla per aiutarti; ti dimentichi di scrivere il test e quindi non scrivere l'implementazione.

    
risposta data 12.07.2018 - 23:54
fonte
114

an event happening twenty six hours ago would be one day ago

I test non saranno di grande aiuto se un problema non è ben definito. Evidentemente stai mescolando giorni di calendario con giorni calcolati in ore. Se ti limiti ai giorni di calendario, ieri alle 1:00, 26 ore fa è non . E se ti limiti alle ore, poi 26 ore fa va a 1 giorno fa indipendentemente dal tempo.

    
risposta data 13.07.2018 - 07:19
fonte
38

Non puoi. TDD è ottimo per proteggerti da eventuali problemi di cui sei a conoscenza. Non aiuta se ti imbatti in problemi che non hai mai preso in considerazione. La tua scommessa migliore è far testare il sistema a qualcun altro, potrebbero trovare i casi limite che non hai mai considerato.

Lettura correlata: E 'possibile raggiungere lo stato di errore zero assoluto per il software su larga scala?

    
risposta data 13.07.2018 - 02:38
fonte
35

Ci sono due approcci che prendo normalmente che trovo possano aiutare.

Per prima cosa, cerco i casi limite. Questi sono posti in cui il comportamento cambia. Nel tuo caso, il comportamento cambia in diversi punti lungo la sequenza di giorni interi positivi. C'è un caso limite a zero, a uno, a sette, ecc. Quindi scriverò casi di test su e intorno ai casi limite. Avrei casi di test a -1 giorni, 0 giorni, 1 ora, 23 ore, 24 ore, 25 ore, 6 giorni, 7 giorni, 8 giorni, ecc.

La seconda cosa che cercherò sono i modelli di comportamento. Nella tua logica da settimane, hai una gestione speciale per una settimana. Probabilmente hai una logica simile in ognuno dei tuoi altri intervalli non mostrati. Questa logica è non presente per giorni, però. Lo guarderei con sospetto fino a quando non potrei spiegare in modo verificabile perché quel caso è diverso, o aggiungo la logica.

    
risposta data 13.07.2018 - 05:36
fonte
14

non puoi rilevare gli errori logici presenti nei tuoi requisiti con TDD. Ma ancora, TDD aiuta. Dopo tutto hai trovato l'errore e hai aggiunto un caso di test. Ma fondamentalmente, TDD solo garantisce che il codice sia conforme al tuo modello mentale. Se il tuo modello mentale è difettoso, i casi di test non li prenderanno.

Ma tieni a mente, mentre correggi il bug, i casi di test che hai già fatto si assicuravano che nessun comportamento esistente e funzionante fosse rotto. Questo è abbastanza importante, è facile correggere un bug ma presentarne un altro.

Per trovare questi errori in anticipo, di solito provi a utilizzare casi di test basati sulla classe di equivalenza. usando questo principio, sceglieresti un caso da ogni classe di equivalenza e poi tutti i casi limite.

Sceglieresti una data da oggi, ieri, qualche giorno fa, esattamente una settimana fa e diverse settimane fa come esempi da ogni classe di equivalenza. Quando esegui il test delle date, assicurati anche che i test non utilizzino la data di sistema, ma utilizzi una data predeterminata per il confronto. Questo evidenzierebbe anche alcuni casi limite: dovresti assicurarti di eseguire i tuoi test in un momento della giornata arbitrario, lo eseguirai direttamente dopo mezzanotte, direttamente prima di mezzanotte e anche direttamente a a mezzanotte. Questo significa che per ogni test ci saranno quattro tempi di base su cui è stato testato.

Quindi aggiungere sistematicamente i casi limite a tutte le altre classi. Hai il test per oggi. Quindi aggiungi un tempo prima e dopo il comportamento dovrebbe cambiare. Lo stesso per ieri. Lo stesso per una settimana fa ecc.

Le probabilità sono che enumerando tutti i casi limite in modo sistematico e scrivendo i casi di test per loro, si scopre che le vostre specifiche mancano di dettagli e la aggiungono. Nota che le date di gestione sono qualcosa che le persone spesso sbagliano, perché spesso le persone dimenticano di scrivere i loro test in modo che possano essere eseguiti con tempi diversi.

Si noti, tuttavia, che la maggior parte di ciò che ho scritto ha poco a che fare con TDD. Riguarda la scrittura di classi di equivalenza e l'accertamento che le proprie specifiche siano sufficientemente dettagliate su di esse. Quel è il processo con il quale minimizzate gli errori logici. TDD semplicemente si assicura che il tuo codice sia conforme al tuo modello mentale.

Venire con i casi di test è difficile . Il test basato sulla classe di equivalenza non è la fine di tutto e in alcuni casi può aumentare significativamente il numero di casi di test. Nel mondo reale, aggiungere tutti quei test spesso non è economicamente fattibile (anche se in teoria dovrebbe essere fatto).

    
risposta data 13.07.2018 - 09:17
fonte
12

The only way I can think of is to add lots of asserts for the cases that I believe would never happen (like I believed that a day ago is necessarily yesterday), and then to loop through every second for the past ten years, checking for any assertion violation, which seems too complex.

Perché no? Sembra una buona idea!

L'aggiunta di contratti (asserzioni) al codice è un modo abbastanza solido per migliorarne la correttezza. Generalmente li aggiungiamo come precondizioni sulla voce della funzione e postconditions sulla funzione return. Ad esempio, potremmo aggiungere una post-condizione che tutti i valori restituiti siano o di modulo "A [unità] fa" o "[numero] [unità] fa". Quando viene eseguito in modo disciplinato, questo porta a design per contratto ed è uno dei metodi più comuni di scrittura del codice di alta sicurezza.

Criticamente, i contratti non sono destinati a essere testati; sono tanto le specifiche del tuo codice quanto i tuoi test. Tuttavia, puoi testare tramite i contratti: chiama il codice nel test e, se nessuno dei contratti genera errori, il test passa. Passare attraverso ogni secondo degli ultimi dieci anni è un po 'troppo. Ma possiamo sfruttare un altro stile di test chiamato test basato sulla proprietà .

In PBT invece di testare specifici output del codice, si verifica che l'output rispetti alcune proprietà. Ad esempio, una proprietà di una funzione reverse() è quella per qualsiasi elenco l , reverse(reverse(l)) = l . L'aspetto positivo dei test di scrittura come questo è che puoi avere il motore PBT generare poche centinaia di liste arbitrarie (e alcune patologiche) e controllare che abbiano tutte questa proprietà. Se un non è , il motore "rimpicciolisce" il caso in cui non riesce a trovare una lista minima che infrange il codice. Sembra che tu stia scrivendo Python, che ha Hypothesis come framework PBT principale.

Quindi, se vuoi trovare un buon modo per trovare i casi più complicati che potresti non pensare, l'uso di contratti e test basati sulle proprietà aiuterà molto. Questo non sostituisce ovviamente i test delle unità di scrittura, ma lo fa aumentare, il che è davvero il meglio che possiamo fare come ingegneri.

    
risposta data 13.07.2018 - 07:20
fonte
5

Questo è un esempio in cui sarebbe stato utile aggiungere un po 'di modularità. Se un segmento di codice soggetto a errori viene utilizzato più volte, è buona pratica avvolgerlo in una funzione, se possibile.

def time_ago(delta, unit):
    delta_str = _number_to_text(delta) + " " + unit;
    if delta == 1:
        return delta_str + " ago"
    else:
        return delta_str = "s ago"

now = datetime.datetime.utcnow()
today = now.date()
if event_date.date() == today:
    return "Today"

yesterday = today - datetime.timedelta(1)
if event_date.date() == yesterday:
    return "Yesterday"

delta = (now - event_date).days

if delta < 7:
    return time_ago(delta, "day")

if delta < 30:
    weeks = math.floor(delta / 7)
    return time_ago(weeks, "week")

if delta < 365:
    months = math.floor(delta / 31)
    return time_ago(months, "month")
    
risposta data 13.07.2018 - 00:05
fonte
5

Test driven development didn't help.

TDD funziona meglio come tecnica se la persona che scrive i test è contraddittoria. Questo è difficile se non stai programmando la coppia, quindi un altro modo per pensarci è:

  • Non scrivere test per confermare che la funzione in prova funzioni come hai fatto. Scrivi test che lo interrompono deliberatamente.

Questa è un'arte diversa, che si applica alla scrittura di codice corretto con o senza TDD, e forse anche complessa (se non di più) rispetto alla scrittura di codice. È qualcosa di cui hai bisogno per esercitarti, ed è qualcosa per cui non esiste una risposta singola, facile e semplice.

La tecnica principale per scrivere software robusto, è anche la tecnica principale per capire come scrivere test efficaci:

Comprendi le precondizioni per una funzione - gli stati validi (ovvero quali ipotesi stai facendo riguardo allo stato della classe la funzione è un metodo di) e gli intervalli di parametri di input validi - ogni tipo di dati ha un intervallo di valori possibili - un sottoinsieme di cui verrà gestita la tua funzione.

Se non fai altro che testare esplicitamente queste ipotesi sulla voce della funzione, e assicurarti che una violazione venga registrata o generata e / o che la funzione vada fuori uso senza ulteriore trattamento, puoi rapidamente sapere se il tuo software sta fallendo nella produzione, renderlo robusto e tollerante agli errori e sviluppare le abilità di scrittura del test del contraddittorio.

NB. Esiste un'intera letteratura su pre e post condizioni, invarianti e così via, insieme a librerie che possono applicarle utilizzando gli attributi. Personalmente non sono un fan dell'andare così formale, ma vale la pena esaminarlo.

    
risposta data 14.07.2018 - 16:51
fonte
1

Questo è uno dei fatti più importanti sullo sviluppo del software: è assolutamente, assolutamente impossibile scrivere codice senza errori.

TDD non ti salverà dall'introdurre bug corrispondenti a casi di test che non hai pensato. Inoltre non ti salverà dallo scrivere un test errato senza rendertene conto, quindi scrivendo codice errato che capita di superare il test del buggy. E ogni altra singola tecnica di sviluppo software mai creata ha buche simili. Come sviluppatori, siamo esseri umani imperfetti. Alla fine della giornata, non c'è modo di scrivere codice privo di bug al 100%. Non è mai successo e mai succederà.

Questo non vuol dire che dovresti rinunciare alla speranza. Mentre è impossibile scrivere codice completamente perfetto, è molto possibile scrivere codice che ha così pochi bug che appaiono in casi rari così rari che il software è estremamente pratico da usare. Il software che non mostra comportamenti buggy nella pratica è molto possibile scrivere.

Ma scrivere ci richiede di abbracciare il fatto che produrremo software buggy. Quasi tutte le moderne pratiche di sviluppo del software sono costruite a un livello o impedendo la comparsa di bug o proteggendo noi stessi dalle conseguenze dei bug che inevitabilmente produciamo:

  • La raccolta di requisiti approfonditi ci consente di sapere quale comportamento scorretto si presenta nel nostro codice.
  • Scrivere un codice pulito e accuratamente architettato semplifica l'introduzione di bug e semplifica la loro correzione quando li identifichiamo.
  • I test di scrittura ci permettono di produrre una registrazione di ciò che crediamo che molti dei peggiori bug possibili nel nostro software sarebbero e provare che evitiamo almeno questi bug. TDD produce questi test prima del codice, BDD ricava i test dai requisiti e i test di unità obsoleti producono test dopo che il codice è stato scritto, ma tutti prevengono le peggiori regressioni in futuro.
  • recensioni peer significano che ogni volta che il codice viene modificato, almeno due paia di occhi hanno visto il codice, diminuendo la frequenza con cui i bug si insinuano nel master.
  • L'uso di un bug tracker o di un tracker degli utenti che tratta i bug come storie di utenti significa che quando vengono visualizzati degli errori, vengono mantenuti e in definitiva gestiti, non dimenticati e lasciati costantemente entrare nelle modalità degli utenti.
  • L'uso di un server di staging significa che prima di una release principale, eventuali bug di show-stopper hanno la possibilità di apparire e di essere gestiti.
  • L'utilizzo del controllo della versione significa che, nella peggiore delle ipotesi, dove il codice con i principali bug viene spedito ai clienti, puoi eseguire un rollback di emergenza e riportare un prodotto affidabile nelle mani dei tuoi clienti mentre risolvi i problemi.

La soluzione definitiva al problema che hai identificato non è combattere il fatto che non puoi garantire che tu possa scrivere un codice privo di bug, ma piuttosto abbracciarlo. Adotta le migliori pratiche del settore in tutte le aree del tuo processo di sviluppo e fornirai coerentemente codice ai tuoi utenti che, sebbene non del tutto perfetto, è più che abbastanza robusto per il lavoro.

    
risposta data 13.07.2018 - 23:20
fonte
1

Semplicemente non avevi pensato a questo caso prima e quindi non avevi un caso di test per questo.

Questo succede sempre ed è normale. È sempre un compromesso quanti sforzi ci metti a creare tutti i possibili casi di test. Puoi spendere un tempo infinito per considerare tutti i casi di test.

Per un autopilota di un aeromobile impiegherebbe molto più tempo che per un semplice strumento.

Spesso aiuta a pensare agli intervalli validi delle variabili di input e a testare questi limiti.

Inoltre, se il tester è una persona diversa da quella dello sviluppatore, spesso vengono trovati casi più significativi.

    
risposta data 14.07.2018 - 08:35
fonte
1

(and believing that it has to do with time zones, despite the uniform use of UTC in the code)

Questo è un altro errore logico nel tuo codice per il quale non hai ancora un test unitario :) - il tuo metodo restituirà risultati errati per gli utenti in fusi orari non UTC. È necessario convertire entrambi "ora" e la data dell'evento nel fuso orario locale dell'utente prima del calcolo.

Esempio: in Australia, un evento si verifica alle 9:00 ora locale. Alle 11 sarà visualizzato come "ieri" perché la data UTC è cambiata.

    
risposta data 16.07.2018 - 05:34
fonte
0
  • Lascia che qualcun altro scriva i test. In questo modo, qualcuno che non ha familiarità con la tua implementazione potrebbe verificare situazioni rare a cui non hai pensato.

  • Se possibile, iniettare casi di test come raccolte. Questo rende l'aggiunta di un altro test facile come aggiungere un'altra riga come yield return new TestCase(...) . Questo può andare nella direzione di test esplorativi , automatizzando la creazione di casi di test: "Vediamo cosa restituisce il codice per tutti i secondi di una settimana fa ".

risposta data 14.07.2018 - 12:54
fonte
0

Sembra che tu sia sotto l'idea sbagliata che se tutti i tuoi test superano, non hai bug. In realtà, se passano tutti i test, tutto il comportamento noto è corretto. Non sai ancora se il comportamento sconosciuto è corretto o meno.

Si spera che si stia utilizzando la copertura del codice con il TDD. Aggiungi un nuovo test per il comportamento imprevisto. Quindi è possibile eseguire solo il test per il comportamento imprevisto per vedere quale percorso effettivamente prende attraverso il codice. Una volta che conosci il comportamento corrente, puoi apportare una modifica per correggerlo e quando tutti i test passeranno di nuovo, saprai di averlo fatto correttamente.

Questo non significa che il tuo codice sia privo di bug, solo che è migliore di prima, e ancora una volta tutto il comportamento noto è corretto!

Usare TDD correttamente non significa scrivere codice senza bug, significa che scriverà meno bug. Tu dici:

The requirements were relatively clear

Questo significa che il comportamento più di un giorno, ma non ieri è stato specificato nei requisiti? Se ti sei perso un requisito scritto, è colpa tua. Se ti rendessi conto che i requisiti erano incompleti come lo stavi codificando, fa bene a te! Se tutti quelli che hanno lavorato ai requisiti non hanno rispettato il caso, non sei peggio degli altri. Ognuno commette errori e quanto più sottili sono, tanto più è facile che manchi. La cosa importante è che TDD non impedisce tutti errori!

    
risposta data 16.07.2018 - 04:56
fonte
0

It's very easy to commit a logical mistake even in a such simple source code.

Sì. Lo sviluppo guidato dal test non cambia questo. Puoi ancora creare bug nel codice reale e anche nel codice di test.

Test driven development didn't help.

Oh, ma è successo! Prima di tutto, quando hai notato il bug hai già installato il framework di test completo e hai dovuto solo correggere il bug nel test (e il codice reale). In secondo luogo, non sai quanti altri bug avresti avuto se non avessi fatto TDD all'inizio.

Also worrisome is that I can't see how could such bugs be avoided.

Non puoi. Nemmeno la NASA ha trovato un modo per evitare i bug; Nemmeno noi esseri umani di livello inferiore lo facciamo.

Aside thinking more before writing code,

Questo è un errore. Uno dei maggiori vantaggi di TDD è che puoi codificare con less pensando, perché tutti questi test catturano almeno le regressioni piuttosto bene. Inoltre, anche, o in particolare con TDD, è non che ci si aspetta che fornisca un codice privo di bug in primo luogo (o la velocità di sviluppo si fermerà semplicemente fino a fermarsi).

the only way I can think of is to add lots of asserts for the cases that I believe would never happen (like I believed that a day ago is necessarily yesterday), and then to loop through every second for the past ten years, checking for any assertion violation, which seems too complex.

Ciò sarebbe chiaramente in conflitto con il principio di codificare solo ciò di cui hai effettivamente bisogno in questo momento. Pensavi di aver bisogno di quei casi, e così è stato. Era un pezzo di codice non critico; come hai detto tu non ci sono stati danni, tranne che te ne sei chiesto per 30 minuti.

Per il codice mission-critical, potresti effettivamente fare quello che hai detto, ma non per il tuo codice standard di tutti i giorni.

How could I avoid creating this bug in the first place?

Non lo fai. Ti fidi dei tuoi test per trovare la maggior parte delle regressioni; tieni il ciclo del referee rosso-verde, scrivi i test prima / durante l'effettiva codifica e (importante!) imposti l'importo minimo necessario per fare l'interruttore rosso-verde (non di più, non di meno). Questo finirà con una grande copertura di test, almeno una positiva.

Quando, non se, trovi un bug, scrivi un test per riprodurre quel bug e correggi il bug con la minor quantità di lavoro per rendere il test passato da rosso a verde.

    
risposta data 16.07.2018 - 18:04
fonte
-2

Hai appena scoperto che non importa quanto tu ci provi, non sarai mai in grado di intercettare tutti i possibili errori nel tuo codice.

Quindi questo significa che anche il tentativo di catturare tutti i bug è un esercizio di futilità, quindi dovresti usare solo tecniche come TDD come metodo per scrivere codice migliore, codice che ha meno bug, non 0 bug.

Questo a sua volta significa che dovresti dedicare meno tempo a utilizzare queste tecniche e dedicare il tempo risparmiato a lavorare su metodi alternativi per trovare i bug che sfuggono alla rete di sviluppo.

alternative come test di integrazione o team di test, test di sistema, registrazione e analisi di tali log.

Se non riesci a cogliere tutti i bug, allora devi disporre di una strategia per mitigare gli effetti degli errori che ti sfuggono. Se devi farlo comunque, mettere più impegno in questo ha più senso che cercare (invano) di fermarli in primo luogo.

Dopo tutto, è inutile spendere una fortuna nel tempo a scrivere dei test e il primo giorno che dai il tuo prodotto a un cliente cade, soprattutto se poi non hai idea di come trovare e risolvere quel bug. La risoluzione dei bug post-mortem e post-delivery è così importante e richiede più attenzione di quanto la maggior parte delle persone spenda per scrivere i test unitari. Salva il test dell'unità per i bit complicati e non cercare la perfezione in anticipo.

    
risposta data 15.07.2018 - 14:54
fonte

Leggi altre domande sui tag