I test unitari hanno aiutato Citigroup ad evitare questo costoso errore?

85

Ho letto di questo snafu: Programmazione bug costa Citigroup $ 7 milioni dopo transazioni legittime scambiate per dati di test per 15 anni .

When the system was introduced in the mid-1990s, the program code filtered out any transactions that were given three-digit branch codes from 089 to 100 and used those prefixes for testing purposes.

But in 1998, the company started using alphanumeric branch codes as it expanded its business. Among them were the codes 10B, 10C and so on, which the system treated as being within the excluded range, and so their transactions were removed from any reports sent to the SEC.

(Penso che questo dimostri che l'uso di un indicatore di dati non espliciti è ... non ottimale. Sarebbe stato molto meglio popolare e usare una proprietà Branch.IsLive semanticamente esplicita.)

A parte questo, la mia prima reazione è stata "I test di unità avrebbero aiutato qui" ... ma avrebbero?

Recentemente ho letto Perché la maggior parte dei test unitari sono rifiuti con interesse , quindi la mia domanda è: quali sarebbero i test unitari che sarebbero falliti nell'introduzione di codici di codici alfanumerici?

    
posta Matthew Evans 14.07.2016 - 05:40
fonte

11 risposte

18

Stai davvero chiedendo, "i test unitari ci hanno aiutato qui?", o stai chiedendo, "potrebbe mai esserci stato qualche tipo di test qui?"

La forma più ovvia di test che avrebbe aiutato, è un'asserzione precondizione nel codice stesso, che un identificatore di ramo consiste solo di cifre (supponendo che questa sia l'ipotesi invocata dal codificatore nello scrivere il codice).

Questo potrebbe quindi aver fallito in una sorta di test di integrazione, e non appena vengono introdotti i nuovi ID alfanumerici, l'asserzione esplode. Ma non è un test unitario.

In alternativa, potrebbe esserci un test di integrazione della procedura che genera il report SEC. Questo test garantisce che ogni identificatore di ramo reale segnala le sue transazioni (e quindi richiede input nel mondo reale, un elenco di tutti gli identificatori di ramo in uso). Quindi non è neanche un test unitario.

Non riesco a vedere alcuna definizione o documentazione delle interfacce coinvolte, ma potrebbe essere che i test di unità non abbiano probabilmente rilevato l'errore perché l'unità non era difettosa . Se l'unità è autorizzata ad assumere che gli identificatori di ramo siano costituiti solo da cifre, e gli sviluppatori non abbiano mai preso una decisione sul codice da eseguire nel caso in cui non lo facesse, allora non dovrebbero scrivere un test unitario per impone un comportamento particolare nel caso di identificatori non a cifre poiché il test rigetterebbe un'ipotetica implementazione valida dell'unità che gestiva correttamente gli identificatori alfanumerici, e di solito non si desidera scrivere un test unitario che impedisca future implementazioni ed estensioni valide. O forse un documento scritto 40 anni fa implicitamente definito (tramite un intervallo lessicografico in EBCDIC grezzo, invece di una regola di confronto più umana) che 10B è un identificatore di test perché in effetti cade tra 089 e 100. Ma poi 15 anni fa qualcuno ha deciso di usarlo come un vero identificatore, quindi il "difetto" non risiede nell'unità che implementa correttamente la definizione originale: si trova nel processo che non ha notato che 10B è definito come un identificatore di test e quindi non dovrebbe essere assegnato a un ramo. Lo stesso accadrebbe in ASCII se definissi 089 - 100 come intervallo di test e poi introducessi un identificatore 10 $ o 1.0. Succede solo che in EBCDIC le cifre arrivano dopo le lettere.

Un test unitario (o discutibilmente un test funzionale) che concepibilmente potrebbe aver salvato il giorno, è un test dell'unità che genera o convalida nuovi identificatori di ramo. Tale test affermerebbe che gli identificatori devono contenere solo cifre e che dovrebbero essere scritti per consentire agli utenti degli identificatori di ramo di assumere lo stesso valore. O forse c'è un'unità da qualche parte che importa veri identificatori di ramo ma non vede mai quelli di test, e che potrebbe essere testato unitamente per garantire che rifiuti tutti gli identificatori di test (se gli identificatori sono solo tre caratteri possiamo enumerarli tutti e confrontare il comportamento di il validatore a quello del filtro di test per assicurarsi che combacino, che si occupa della solita obiezione ai test di prova). Poi, quando qualcuno ha cambiato le regole, il test unitario sarebbe fallito dal momento che contraddice il nuovo comportamento richiesto.

Poiché il test era lì per una buona ragione, il punto in cui è necessario rimuoverlo a causa dei mutati requisiti aziendali diventa un'opportunità per qualcuno a cui assegnare il lavoro, "trova ogni posizione nel codice che si basa sul comportamento che vogliamo cambiare ". Ovviamente questo è difficile e quindi inaffidabile, quindi non sarebbe in alcun modo garantito il salvataggio della giornata. Ma se acquisisci le tue ipotesi nei test delle unità che stai assumendo le proprietà di, allora ti sei dato una possibilità e quindi lo sforzo non è interamente sprecato.

Sono d'accordo, ovviamente, che se l'unità non fosse stata definita in primo luogo con un input "a forma di buffa", non ci sarebbe nulla da testare. Le divisioni dei namespace possono essere difficili da testare correttamente perché la difficoltà non sta nell'implementare la tua divertente definizione, ma nel far sì che tutti capiscano e rispettino la tua divertente definizione. Quella non è una proprietà locale di un'unità di codice. Inoltre, la modifica di alcuni tipi di dati da "una stringa di cifre" a "una stringa di caratteri alfanumerici" equivale a fare in modo che un programma basato su ASCII gestisca Unicode: non sarà semplice se il codice è strongmente accoppiato alla definizione originale e quando il tipo di dati è fondamentale per ciò che fa il programma, quindi spesso è strongmente accoppiato.

it’s a bit disturbing to think it’s largely wasted effort

Se i tuoi test unitari a volte falliscono (ad esempio, mentre esegui il refactoring, ad esempio), e così facendo ti forniscono informazioni utili (ad esempio il tuo cambiamento è sbagliato), lo sforzo non è stato sprecato. Quello che non fanno è testare se il tuo sistema funziona. Quindi se stai scrivendo test di unità invece di con test funzionali e di integrazione, potresti utilizzare il tuo tempo in modo sub-ottimale.

    
risposta data 15.07.2016 - 12:23
fonte
120

I test di unità avrebbero potuto rilevare che i codici di derivazione 10B e 10C erano classificati erroneamente come "rami di prova", ma trovo improbabile che i test per quella classificazione di ramo sarebbero stati sufficientemente estesi da rilevare tale errore.

D'altro canto, i controlli in loco dei report generati avrebbero potuto rivelare che i branch 10B e 10C erano costantemente mancanti nei report molto prima dei 15 anni in cui il bug era ora consentito rimanere presente.

Infine, questo è un buon esempio perché è una cattiva idea mescolare i dati di test con i dati di produzione reali in un unico database. Se avessero usato un database separato che contiene i dati di test, non sarebbe stato necessario filtrarlo tra quelli ufficiali e sarebbe stato impossibile filtrare troppo.

    
risposta data 14.07.2016 - 08:50
fonte
75

Il software doveva gestire alcune regole di business. Se ci fossero stati dei test unitari, i test unitari avrebbero controllato che il software gestisse correttamente le regole aziendali.

Le regole aziendali sono cambiate.

A quanto pare nessuno si è reso conto che le regole aziendali erano cambiate e nessuno ha cambiato il software per applicare le nuove regole aziendali. Se ci fossero stati dei test unitari, quei test unitari avrebbero dovuto essere cambiati, ma nessuno lo avrebbe fatto perché nessuno si era reso conto che le regole aziendali erano cambiate.

Quindi no, i test unitari non l'avrebbero capito.

L'eccezione si verificherebbe se l'unità eseguisse il test e il software fosse stato creato da team indipendenti, e il team che eseguiva i test di unità ha modificato i test per applicare le nuove regole aziendali. Quindi i test unitari sarebbero falliti, il che, si spera, avrebbe portato a un cambiamento del software.

Ovviamente nello stesso caso, se solo il software è stato modificato e non l'unit test, anche i test unitari fallirebbero. Ogni volta che un test unitario fallisce, non significa che il software sia sbagliato, significa che il software o il test dell'unità (a volte entrambi) sono errati.

    
risposta data 14.07.2016 - 10:07
fonte
30

No. Questo è uno dei grandi problemi con i test unitari: ti cullano in un falso senso di sicurezza.

Se passano tutti i test, ciò non significa che il sistema funzioni correttamente; significa che tutti i tuoi test stanno passando . Significa che le parti del tuo progetto a cui hai pensato coscientemente e hai scritto dei test stanno funzionando come pensavi coscientemente, il che non è comunque un gran problema: quella era la roba che stavi davvero prestando attenzione quindi è molto probabile che tu abbia capito bene comunque! Ma non fa nulla per catturare casi che non hai mai pensato, come questo, perché non hai mai pensato di scrivere un test per loro (e se lo avessi fatto, avresti saputo che ciò significava le modifiche al codice erano necessarie e tu le avresti cambiate.)

    
risposta data 14.07.2016 - 14:31
fonte
10

No, non necessariamente.

Il requisito originale era usare i codici di derivazione numerici, quindi un test unitario sarebbe stato prodotto per un componente che accettava vari codici e rifiutato come 10B. Il sistema sarebbe stato passato come funzionante (che era).

Quindi, il requisito sarebbe stato modificato ei codici aggiornati, ma ciò avrebbe significato la modifica del codice di test unitario che forniva i dati non validi (che ora sono buoni dati).

Ora supponiamo che, le persone che gestiscono il sistema saprebbero che questo era il caso e cambierebbero il test unitario per gestire i nuovi codici ... ma se sapevano che stava accadendo, avrebbero anche saputo di cambiare il codice che ha gestito questi codici comunque ... e non l'hanno fatto. Un test unitario che originariamente respingeva il codice 10B avrebbe detto felicemente "va tutto bene qui" quando si esegue, se non si sapesse aggiornare quel test.

I test unitari vanno bene per lo sviluppo originale ma non per i test di sistema, in particolare non 15 anni dopo che i requisiti sono stati dimenticati da molto tempo.

Ciò di cui hanno bisogno in questo tipo di situazione è un test di integrazione end-to-end. Uno in cui è possibile passare i dati che si prevede di lavorare e vedere se lo fa. Qualcuno avrebbe notato che i suoi nuovi dati di input non producevano un report e quindi avrebbero approfondito ulteriormente.

    
risposta data 14.07.2016 - 09:57
fonte
9

Digitare test (il processo di test degli invarianti che utilizzano dati validi generati casualmente, come esemplificato dalla libreria di test Haskell QuickCheck e vari porti / alternative ispirati ad esso in altre lingue) potrebbe aver colto questo problema, il test unitario quasi certamente non avrebbe funzionato.

Questo perché quando le regole per la validità dei codici di filiale sono state aggiornate, è improbabile che qualcuno abbia pensato di testare tali intervalli specifici per assicurarsi che funzionassero correttamente.

Tuttavia, se fosse stato utilizzato il test di tipo, qualcuno dovrebbe al momento in cui il sistema originale è stato implementato ha scritto una coppia di proprietà, una per controllare che i codici specifici per i rami di prova fossero trattati come dati di test e uno per verificare che nessun altro codice fosse ... quando è stata aggiornata la definizione del tipo di dati per il codice filiale (che sarebbe stato necessario per consentire di verificare che qualsiasi modifica per il codice derivato da cifra a numerico funzionasse ), questo test avrebbe iniziato a testare i valori nel nuovo intervallo e molto probabilmente avrebbe identificato l'errore.

Ovviamente, il QuickCheck è stato sviluppato per la prima volta nel 1999, quindi era già troppo tardi per rilevare questo problema.

    
risposta data 14.07.2016 - 10:56
fonte
5

Dubito davvero che i test unitari possano fare la differenza per questo problema. Sembra una di quelle situazioni di visione del tunnel perché la funzionalità è stata modificata per supportare i nuovi codici di filiale, ma ciò non è stato eseguito in tutte le aree del sistema.

Usiamo i test unitari per progettare una classe. La ripetizione di un test di unità è necessaria solo se il progetto è cambiato. Se una determinata unità non cambia, allora i test unitari invariati restituiranno gli stessi risultati di prima. I test unitari non ti mostreranno l'impatto delle modifiche ad altre unità (se lo fanno non stai scrivendo test unitari).

Si può solo ragionevolmente rilevare questo problema tramite:

  • Test di integrazione - ma dovresti aggiungere in modo specifico i nuovi formati di codice per alimentare più unità nel sistema (cioè ti mostrerebbero il problema solo se i test originali includevano i rami ora validi)
  • Test end-to-end: l'azienda deve eseguire un test end-to-end che incorpori formati di codice filiale nuovi e nuovi

Non avere abbastanza test end-to-end è più preoccupante. Non è possibile fare affidamento sul test delle unità come test SOLO o MAIN per le modifiche del sistema. Sembra che solo qualcuno abbia bisogno di eseguire un rapporto sui nuovi formati di codice filiale supportati.

    
risposta data 14.07.2016 - 14:06
fonte
2

Il takeaway da questo è per Fail Fast .

Non abbiamo il codice, né abbiamo molti esempi di prefissi che sono o non sono prefissi di test branch in base al codice. Tutto ciò che abbiamo è questo:

  • 089 - 100 = > ramo di prova
  • 10B, 10C = > ramo di prova
  • < 088 = > presumibilmente rami reali
  • > 100 = > presumibilmente rami reali

Il fatto che il codice consenta numeri e stringhe è più che un po 'strano. Naturalmente, 10B e 10C possono essere considerati numeri esadecimali, ma se i prefissi sono tutti trattati come numeri esadecimali, 10B e 10C non rientrano nell'intervallo di test e saranno trattati come rami reali.

Questo probabilmente significa che il prefisso è memorizzato come una stringa ma in alcuni casi viene trattato come un numero. Ecco il codice più semplice che riesca a pensare che riproduca questo comportamento (usando C # a scopo illustrativo):

bool IsTest(string strPrefix) {
    int iPrefix;
    if(int.TryParse(strPrefix, out iPrefix))
        return iPrefix >= 89 && iPrefix <= 100;
    return true; //here is the problem
}

In inglese, se la stringa è un numero ed è compresa tra 89 e 100, è un test. Se non è un numero, è un test. Altrimenti non è un test.

Se il codice segue questo modello, nessun test unitario sarebbe stato rilevato al momento della distribuzione del codice. Ecco alcuni test unitari di esempio:

assert.isFalse(IsTest("088"))
assert.isTrue(IsTest("089"))
assert.isTrue(IsTest("095"))
assert.isTrue(IsTest("100"))
assert.isFalse(IsTest("101"))
assert.isTrue(IsTest("10B")) // <--- business rule change

Il test unitario mostra che "10B" deve essere trattato come un ramo di prova. User @ gnasher729 sopra dice che le regole aziendali sono cambiate ed è quello che mostra l'ultima affermazione sopra. A un certo punto, l'affermazione dovrebbe essere passata a isFalse , ma ciò non è accaduto. I test unitari vengono eseguiti allo sviluppo e al momento della compilazione, ma in un secondo momento.

Qual è la lezione qui? Il codice ha bisogno di un modo per segnalare che ha ricevuto input inaspettati. Ecco un modo alternativo per scrivere questo codice che sottolinea che si aspetta che il prefisso sia un numero:

// Alternative A
bool TryGetIsTest(string strPrefix, out bool isTest) {
    int iPrefix;
    if(int.TryParse(strPrefix, out iPrefix)) {
        isTest = iPrefix >= 89 && iPrefix <= 100;
        return true;
    }
    isTest = true; //this is just some value that won't be read
    return false;
}

Per coloro che non conoscono C #, il valore di ritorno indica se il codice è stato in grado di analizzare un prefisso dalla stringa data o meno. Se il valore restituito è true, il codice chiamante può utilizzare la variabile isTest out per verificare se il prefisso di ramo è un prefisso di test. Se il valore restituito è falso, il codice chiamante deve riportare che il prefisso specificato non è previsto e la variabile isTest out non ha significato e deve essere ignorata.

Se stai bene con le eccezioni, puoi farlo invece:

// Alternative B
bool IsTest(string strPrefix) {
    int iPrefix = int.Parse(strPrefix);
    return iPrefix >= 89 && iPrefix <= 100;
}

Questa alternativa è più semplice. In questo caso, il codice chiamante deve rilevare l'eccezione. In entrambi i casi, il codice dovrebbe avere un modo di segnalare al chiamante che non si aspettava uno strPrefix che non poteva essere convertito in un numero intero. In questo modo il codice fallisce rapidamente e la banca può trovare rapidamente il problema senza il grave imbarazzo della SEC.

    
risposta data 15.07.2016 - 20:45
fonte
1

Un'asserzione incorporata nel tempo di esecuzione potrebbe aver aiutato; ad esempio:

  1. Crea una funzione come bool isTestOnly(string branchCode) { ... }
  2. Utilizza questa funzione per decidere quali report filtrare
  3. Riutilizzare quella funzione in un'asserzione, nel codice di creazione del ramo, per verificare o affermare che un ramo non è (non può essere) creato usando questo tipo di codice filiale!
  4. Questa asserzione è abilitata nel vero tempo di esecuzione (e non "ottimizzata via tranne nella versione di sviluppo del codice debug-only")!

Vedi anche:

risposta data 14.07.2016 - 17:47
fonte
1

Tante risposte e nemmeno una citazione di Dijkstra:

Testing shows the presence, not the absence of bugs.

Pertanto, dipende. Se il codice è stato testato correttamente, molto probabilmente questo errore non esisterebbe.

    
risposta data 18.07.2016 - 12:10
fonte
-1

Penso che un test unitario qui avrebbe assicurato che il problema non esistesse mai in primo luogo.

Considera che hai scritto la funzione bool IsTestData(string branchCode) .

Il primo test di unità che scrivi dovrebbe essere per una stringa vuota e vuota. Quindi per stringhe di lunghezza errate quindi per stringhe non intere.

Per passare tutti questi test devi aggiungere il controllo dei parametri alla funzione.

Anche se poi testi solo per i "buoni" dati 001 - > 999 non pensando alla possibilità di 10A il controllo dei parametri ti costringerà a riscrivere la funzione quando inizierai a usare alfanumerici per evitare le eccezioni che genererà

    
risposta data 14.07.2016 - 20:57
fonte

Leggi altre domande sui tag