TDD rende ridondante la programmazione difensiva?


104

Oggi ho avuto un'interessante discussione con un collega.

Sono un programmatore difensivo. Ritengo che la regola " una classe deve garantire che i suoi oggetti abbiano uno stato valido quando interagiscono con al di fuori della classe " deve essere sempre rispettata. Il motivo di questa regola è che la classe non sa chi sono i suoi utenti e che dovrebbe prevedibilmente fallire quando interagisce in modo illegale. A mio avviso, tale regola si applica a tutte le classi.

Nella situazione specifica in cui ho avuto una discussione oggi, ho scritto un codice che conferma che gli argomenti per il mio costruttore sono corretti (ad esempio un parametro intero deve essere> 0) e se il prerequisito non è soddisfatto, viene generata un'eccezione. D'altro canto, il mio collega ritiene che tale controllo sia ridondante, poiché i test unitari dovrebbero rilevare eventuali usi errati della classe. Inoltre, ritiene che anche le convalide della programmazione difensiva debbano essere testate in unità, quindi la programmazione difensiva aggiunge molto lavoro e non è quindi ottimale per il TDD.

È vero che TDD è in grado di sostituire la programmazione difensiva? La convalida dei parametri (e non intendo l'input dell'utente) di conseguenza non è necessaria? O le due tecniche si completano a vicenda?


120
Consegnate la vostra libreria completamente testata dall'unità senza i controlli del costruttore a un client da utilizzare e infrangono il contratto di classe. A che servono questi test unitari adesso?
Robert Harvey,

42
L'IMO è il contrario. La programmazione difensiva, le condizioni pre e pro adeguate e un sistema di tipo avanzato rendono superflui i test.
gardenhead

37
Posso pubblicare una risposta che dice "Buon dolore?" La programmazione difensiva protegge il sistema in fase di esecuzione. I test verificano tutte le potenziali condizioni di runtime a cui il tester può pensare, inclusi argomenti non validi passati ai costruttori e altri metodi. I test, se completi, confermeranno che il comportamento di runtime sarà come previsto, comprese le opportune eccezioni o altri comportamenti intenzionali che si verificano quando vengono passati argomenti non validi. Ma i test non fanno una cosa maledetta per proteggere il sistema in fase di esecuzione.
Craig,

16
"i test unitari dovrebbero rilevare eventuali usi errati della classe" - uh, come? I test unitari mostreranno il comportamento dato gli argomenti corretti e quando gli argomenti errati; non possono mostrarti tutti gli argomenti che sarà mai dato.
OJFord,

34
Non credo di aver visto un esempio migliore di come il pensiero dogmatico sullo sviluppo del software possa portare a conclusioni dannose.
sdenham,

Risposte:


196

È ridicolo. TDD impone al codice di superare i test e impone a tutto il codice di eseguire alcuni test. Non impedisce ai tuoi consumatori di chiamare in modo errato il codice, né impedisce ai programmatori di perdere casi di test.

Nessuna metodologia può costringere gli utenti a utilizzare correttamente il codice.

V'è una leggera giustificata ma anche che se si sapeva già TDD che avrebbe catturato la vostra> 0 check-in un banco di prova, prima di implementare, e affrontato questo - probabilmente da voi aggiungendo l'assegno. Ma se hai fatto TDD, il tuo requisito (> 0 nel costruttore) apparirebbe prima come un testcase che fallisce. Dandoti così il test dopo aver aggiunto il tuo controllo.

È anche ragionevole testare alcune delle condizioni difensive (hai aggiunto la logica, perché non vorresti testare qualcosa di così facilmente testabile?). Non sono sicuro del motivo per cui sembri non essere d'accordo.

O le due tecniche si completano a vicenda?

TDD svilupperà i test. L'implementazione della convalida dei parametri li farà passare.


7
Non sono in disaccordo con la convinzione che la convalida delle condizioni preliminari debba essere verificata, ma non sono d'accordo con l'opinione del mio collega secondo cui il lavoro extra causato dalla necessità di testare la convalida delle condizioni preliminari è un argomento per non creare la convalida delle condizioni preliminari nella prima posto. Ho modificato il mio post per chiarire.
user2180613

20
@ user2180613 Crea un test che verifica che un errore del prerequisito sia gestito in modo appropriato: ora l'aggiunta del controllo non è un lavoro "extra", è il lavoro richiesto da TDD per rendere il test verde. Se l'opinione del tuo collega è che dovresti fare il test, osservarlo fallendo, e quindi e solo allora implementare il controllo dei presupposti, allora potrebbe avere un punto di vista dal punto di vista del purista TDD. Se sta dicendo solo di ignorare completamente l'assegno, allora è sciocco. Non c'è nulla in TDD che dica che non si può essere proattivi nello scrivere test per potenziali modalità di fallimento.
RM,

4
@RM Non stai scrivendo un test per testare il controllo dei presupposti. Stai scrivendo un test per verificare il comportamento corretto previsto del codice chiamato. I controlli preliminari sono, dal punto di vista del test, un dettaglio di implementazione opaco che garantisce il comportamento corretto. Se si pensa a un modo migliore per garantire lo stato corretto nel codice chiamato, farlo in questo modo invece di utilizzare un tradizionale controllo dei presupposti. Il test confermerà se hai avuto successo o meno e ancora non saprà o se ne fregerà di come lo hai fatto.
Craig,

@ user2180613 Questa è una fantastica giustificazione: D se il tuo obiettivo nella scrittura del software è ridurre il numero di test che devi creare ed eseguire, non scrivere alcun software - zero test!
Gusdor,

3
L'ultima frase di questa risposta la inchioda.
Robert Grant,

32

La programmazione difensiva e i test unitari sono due modi diversi per rilevare gli errori e ognuno ha punti di forza diversi. L'uso di un solo modo per rilevare errori rende fragili i meccanismi di rilevamento degli errori. L'utilizzo di entrambi rileverà errori che potrebbero essere stati persi dall'uno o dall'altro, anche nel codice che non è un'API pubblica; ad esempio, qualcuno potrebbe aver dimenticato di aggiungere un test unitario per i dati non validi passati all'API pubblica. Controllare tutto nei posti appropriati significa avere più possibilità di rilevare l'errore.

Nella sicurezza delle informazioni, questo si chiama Defense In Depth. Avere più livelli di difesa assicura che se uno fallisce, ce ne sono ancora altri a prenderlo.

Il tuo collega ha ragione su una cosa: dovresti testare le tue convalide, ma questo non è "lavoro inutile". È lo stesso che testare qualsiasi altro codice, vuoi assicurarti che tutti gli usi, anche quelli non validi, abbiano un risultato atteso.


È corretto affermare che la convalida dei parametri è una forma di convalida dei presupposti e che i test unitari sono convalide postcondizione, motivo per cui si completano a vicenda?
user2180613

1
"È lo stesso che testare qualsiasi altro codice, vuoi assicurarti che tutti gli usi, anche quelli non validi, abbiano un risultato atteso." Questo. Nessun codice dovrebbe mai passare attraverso l'input passato che non è stato progettato per gestire. Ciò viola il principio "fail fast" e può rendere il debug un incubo.
jpmc26,

@ user2180613 - non proprio, ma più che i test unitari verificano le condizioni di errore che lo sviluppatore si aspetta, mentre le tecniche di programmazione difensiva verificano le condizioni che lo sviluppatore non si aspetta. I test unitari possono essere utilizzati per validare le precondizioni (usando un oggetto simulato iniettato al chiamante che controlla le precondizioni).
Periata Breatta,

1
@ jpmc26 Sì, l'errore è il "risultato previsto" per il test. Esegui un test per dimostrare che non riesce, piuttosto che mostrare silenziosamente un comportamento indefinito (imprevisto).
KRyan,

6
TDD rileva errori nel tuo codice, la programmazione difensiva rileva errori nel codice di altre persone. TDD può quindi aiutarti a essere abbastanza difensivo :)
jwenting

30

TDD non sostituisce assolutamente la programmazione difensiva. Invece, puoi usare TDD per assicurarti che tutte le difese siano in atto e funzionino come previsto.

In TDD, non dovresti scrivere codice senza prima scrivere un test - segui religiosamente il ciclo dei rifattori rosso-verde. Ciò significa che se si desidera aggiungere la convalida, scrivere innanzitutto un test che richiede questa convalida. Chiamare il metodo in questione con numeri negativi e con zero e aspettarsi che generi un'eccezione.

Inoltre, non dimenticare il passaggio del "refattore". Mentre TDD è test- driven , questo non significa solo test . Dovresti comunque applicare il design corretto e scrivere un codice sensibile. Scrivere codice difensivo è un codice ragionevole, perché rende le aspettative più esplicite e il tuo codice nel complesso più solido - individuare tempestivamente eventuali errori rende più facile il debug.

Ma non dovremmo usare i test per individuare gli errori? Affermazioni e test sono complementari. Una buona strategia di test mescolerà vari approcci per assicurarsi che il software sia robusto. Solo i test unitari o solo i test di integrazione o solo le asserzioni nel codice sono tutti insoddisfacenti, è necessaria una buona combinazione per raggiungere un livello sufficiente di fiducia nel software con uno sforzo accettabile.

Quindi c'è un grande equivoco concettuale del tuo collega: i test unitari non possono mai testare gli usi della tua classe, solo che la classe stessa funziona come previsto isolatamente. Utilizzereste i test di integrazione per verificare che l'interazione tra i vari componenti funzioni, ma l'esplosione combinatoria di possibili casi di test rende impossibile testare tutto. I test di integrazione dovrebbero pertanto limitarsi a un paio di casi importanti. Test più dettagliati che coprono anche casi limite e casi di errore sono più adatti per i test unitari.


16

I test sono lì per supportare e garantire la programmazione difensiva

La programmazione difensiva protegge l'integrità del sistema in fase di esecuzione.

I test sono strumenti diagnostici (principalmente statici). In fase di esecuzione, i test non sono in vista. Sono come le impalcature utilizzate per montare un alto muro di mattoni o una cupola di roccia. Non lasci parti importanti fuori dalla struttura perché hai un'impalcatura che lo sostiene durante la costruzione. Hai un'impalcatura che lo sostiene durante la costruzione per facilitare l' inserimento di tutti i pezzi importanti.

EDIT: un'analogia

Che dire di un'analogia con i commenti nel codice?

I commenti hanno il loro scopo, ma possono essere ridondanti o addirittura dannosi. Ad esempio, se si inserisce una conoscenza intrinseca del codice nei commenti , quindi si modifica il codice, i commenti diventano irrilevanti nella migliore delle ipotesi e dannosi nella peggiore.

Quindi supponiamo che tu abbia messo molta conoscenza intrinseca della tua base di codice nei test, come MethodA non può prendere nulla e l'argomento di MethodB deve essere > 0. Quindi il codice cambia. Null va bene per A ora e B può assumere valori piccoli come -10. I test esistenti ora sono funzionalmente errati, ma continueranno a passare.

Sì, dovresti aggiornare i test mentre aggiorni il codice. Dovresti anche aggiornare (o rimuovere) i commenti mentre aggiorni il codice. Ma sappiamo tutti che queste cose non sempre accadono e che vengono fatti degli errori.

I test verificano il comportamento del sistema. Tale comportamento effettivo è intrinseco al sistema stesso, non intrinseco ai test.

Che cosa potrebbe andare storto?

L'obiettivo per quanto riguarda i test è di pensare a tutto ciò che potrebbe andare storto, scrivere un test per verificarne il comportamento corretto, quindi creare il codice di runtime in modo che superi tutti i test.

Ciò significa che il punto è la programmazione difensiva .

TDD guida la programmazione difensiva, se i test sono completi.

Più test, guidando una programmazione più difensiva

Quando i bug vengono inevitabilmente trovati, vengono scritti più test per modellare le condizioni che manifestano il bug. Quindi il codice viene corretto, con il codice per far passare quei test e i nuovi test rimangono nella suite di test.

Una buona serie di test sta per passare argomenti validi e negativi a una funzione / metodo e si aspettano risultati coerenti. Questo, a sua volta, significa che il componente testato utilizzerà controlli preliminari (programmazione difensiva) per confermare gli argomenti passati.

In generale ...

Ad esempio, se un argomento null a una determinata procedura non è valido, almeno un test passerà un null e si aspetterà un'eccezione / errore "argomento null non valido" di qualche tipo.

Almeno un altro test sta per passare un argomento valido , ovviamente - o passare in rassegna un array grande e passare ennesimi argomenti validi - e confermare che lo stato risultante è appropriato.

Se un test non passa quell'argomento nullo e viene schiaffeggiato con l'eccezione prevista (e quell'eccezione è stata lanciata perché il codice ha controllato in modo difensivo lo stato passato ad esso), allora il null può finire assegnato a una proprietà di una classe o sepolto in una raccolta di qualche tipo dove non dovrebbe essere.

Ciò potrebbe causare comportamenti imprevisti in alcune parti del sistema completamente diverse a cui viene passata l'istanza della classe, in alcune impostazioni geografiche distanti dopo che il software è stato spedito . E questo è il genere di cose che stiamo effettivamente cercando di evitare, giusto?

Potrebbe anche essere peggio. L'istanza di classe con lo stato non valido può essere serializzata e archiviata, solo per causare un errore quando viene ricostituita per un utilizzo successivo. Accidenti, non lo so, forse è un sistema di controllo meccanico di qualche tipo che non può riavviarsi dopo un arresto perché non può deserializzare il proprio stato di configurazione persistente. Oppure l'istanza di classe potrebbe essere serializzata e passata a un sistema completamente diverso creato da un'altra entità e tale sistema potrebbe arrestarsi in modo anomalo.

Soprattutto se i programmatori di quell'altro sistema non codificassero in modo difensivo.


2
È divertente, il downvote è arrivato così in fretta che ora è assolutamente possibile che il downvoter abbia letto oltre il primo paragrafo.
Craig,

1
:-) Ho appena votato senza leggere oltre il primo paragrafo, quindi spero che questo possa bilanciare ...
SusanW

1
Sembrava il minimo che potessi fare :-) (In realtà, ho fatto leggere il resto solo per assicurarsi che non deve essere sciatto -.! In particolare su un tema come questo)
SusanW

1
Ho pensato che probabilmente l'hai fatto. :)
Craig,

i controlli difensivi possono essere eseguiti in fase di compilazione con strumenti come Code Contracts.
Matthew Whited,

9

Invece di TDD parliamo di "test del software" in generale, e invece di "programmazione difensiva" in generale, parliamo del mio modo preferito di fare programmazione difensiva, che è usando asserzioni.


Quindi, dal momento che eseguiamo test del software, dovremmo smettere di inserire dichiarazioni di asserzione nel codice di produzione, giusto? Vorrei contare i modi in cui questo è sbagliato:

  1. Le asserzioni sono facoltative, quindi se non ti piacciono, esegui il sistema con le asserzioni disabilitate.

  2. Le asserzioni controllano cose che i test non possono (e non dovrebbero). Perché i test dovrebbero avere una vista in black box del sistema, mentre le asserzioni hanno una vista in white box. (Certo, dal momento che vivono in esso.)

  3. Le asserzioni sono un eccellente strumento di documentazione. Nessun commento è mai stato, o lo sarà mai, inequivocabile come un pezzo di codice che afferma la stessa cosa. Inoltre, la documentazione tende a diventare obsoleta con l'evoluzione del codice e non è in alcun modo applicabile dal compilatore.

  4. Le asserzioni possono rilevare errori nel codice di test. Ti sei mai imbattuto in una situazione in cui un test fallisce e non sai chi ha torto: il codice di produzione o il test?

  5. Le asserzioni possono essere più pertinenti dei test. I test verificheranno ciò che è prescritto dai requisiti funzionali, ma il codice spesso deve fare alcune ipotesi che sono molto più tecniche di così. Le persone che scrivono documenti sui requisiti funzionali raramente pensano alla divisione per zero.

  6. Le asserzioni individuano errori che i test suggeriscono solo ampiamente. Quindi, il test stabilisce alcune condizioni preliminari estese, invoca un lungo pezzo di codice, raccoglie i risultati e scopre che non sono quelli previsti. Data la risoluzione dei problemi sufficiente alla fine troverai esattamente dove sono andate le cose male, ma le asserzioni di solito la troveranno prima.

  7. Le asserzioni riducono la complessità del programma. Ogni singola riga di codice che scrivi aumenta la complessità del programma. Le asserzioni e la parola chiave final( readonly) sono gli unici due costrutti che conosco che riducono effettivamente la complessità del programma. Non ha prezzo.

  8. Le asserzioni aiutano il compilatore a comprendere meglio il tuo codice. Per favore, prova questo a casa: il void foo( Object x ) { assert x != null; if( x == null ) { } }tuo compilatore dovrebbe emettere un avviso che ti dice che la condizione x == nullè sempre falsa. Questo può essere molto utile.

Quanto sopra è stato un riepilogo di un post dal mio blog, 21/09/2014 "Asserzioni e prove"


Penso che per lo più non sono d'accordo con questa risposta. (5) In TDD, la suite di test è la specifica. Dovresti scrivere il codice più semplice facendo passare i test, niente di più. (4) Il flusso di lavoro rosso-verde assicura che il test fallisca quando dovrebbe e passa quando è presente la funzionalità prevista. Le asserzioni non aiutano molto qui. (3,7) La documentazione è documentazione, le asserzioni no. Ma rendendo espliciti i presupposti, il codice diventa più autocompattante. Li considererei come commenti eseguibili. (2) I test in white box possono far parte di una strategia di test valida.
amon,

5
"In TDD, la suite di test è la specifica. Dovresti scrivere il codice più semplice per far passare i test, niente di più.": Non penso che sia sempre una buona idea: come sottolineato nella risposta, ci sono ipotesi interna aggiuntiva nel codice che si potrebbe voler verificare. Che dire dei bug interni che si annullano a vicenda? I test superano ma alcuni presupposti all'interno del codice sono errati, il che può portare a bug insidiosi in seguito.
Giorgio,

5

Credo che alla maggior parte delle risposte manchi una distinzione critica: dipende da come verrà utilizzato il codice.

Il modulo in questione verrà utilizzato da altri client indipendentemente dall'applicazione che si sta testando? Se stai fornendo una libreria o API per l'utilizzo da parte di terzi, non hai modo di assicurarti che chiamino il tuo codice solo con input validi. Devi convalidare tutti gli input.

Ma se il modulo in questione viene utilizzato solo dal codice che controlli, allora il tuo amico potrebbe avere un punto. È possibile utilizzare i test unitari per verificare che il modulo in questione sia chiamato solo con input valido. I controlli delle condizioni preliminari potrebbero ancora essere considerati una buona pratica, ma è un compromesso: se rifiuti il ​​codice che verifica la presenza di condizioni che sai non possono mai sorgere, oscura semplicemente l'intento del codice.

Non sono d'accordo sul fatto che i controlli preliminari richiedano ulteriori test unitari. Se decidi che non è necessario testare alcune forme di input non validi, non dovrebbe importare se la funzione contiene o meno controlli preliminari. Ricorda che i test dovrebbero verificare il comportamento, non i dettagli di implementazione.


4
Se la procedura chiamata non verifica la validità degli input (che è il dibattito originale), i test delle unità non possono garantire che il modulo in questione sia chiamato solo con input valido. In particolare, potrebbe essere chiamato con input non valido ma potrebbe capitare di restituire comunque un risultato corretto nei casi testati: esistono vari tipi di comportamento indefinito, gestione dell'overflow, ecc. Che potrebbero restituire il risultato atteso in un ambiente di test con ottimizzazioni disabilitate ma fallire nella produzione.
Peteris,

@Peteris: stai pensando a comportamenti indefiniti come in C? Richiamare comportamenti indefiniti che hanno esiti diversi in ambienti diversi è ovviamente un bug, ma non può essere impedito nemmeno dai controlli preliminari. Ad esempio, come si fa a controllare un argomento del puntatore che punta alla memoria valida?
Jacques B

3
Funzionerà solo nei negozi più piccoli. Una volta che la tua squadra va oltre, diciamo, sei persone, avrai comunque bisogno dei controlli di validazione.
Robert Harvey,

1
@RobertHarvey: in tal caso il sistema deve essere suddiviso in sottosistemi con interfacce ben definite e la convalida dell'input deve essere eseguita sull'interfaccia.
Jacques B

Questo. Dipende dal codice, questo codice deve essere utilizzato dal team? Il team ha accesso al codice sorgente? Se il suo codice puramente interno, quindi il controllo degli argomenti potrebbe essere solo un onere, ad esempio, si controlla 0, quindi si genera un'eccezione e il chiamante quindi esamina il codice oh questa classe può generare un'eccezione ecc. Ecc. E attendere .. in questo caso che L'oggetto non riceverà mai 0 in quanto viene filtrato prima di 2 livelli. Se questo è un codice di libreria che verrà utilizzato da terze parti, questa è un'altra storia. Nessun codice è stato scritto per essere utilizzato da tutto il mondo.
Aleksander Fular,

3

Questo argomento mi confonde, perché quando ho iniziato a praticare TDD, i miei test unitari del modulo "oggetto risponde <in un certo modo> quando <input non valido>" sono aumentati di 2 o 3 volte. Mi chiedo come il tuo collega riesca a superare con successo questo tipo di test unitari senza che le sue funzioni eseguano la convalida.

Il caso contrario, che i test unitari dimostrano che non si producono mai output errati che verranno passati agli argomenti di altre funzioni, è molto più difficile da dimostrare. Come il primo caso, dipende fortemente dalla copertura completa dei casi limite, ma hai il requisito aggiuntivo che tutti gli ingressi delle tue funzioni debbano provenire dalle uscite di altre funzioni le cui uscite sono state testate dall'unità e non, per esempio, dagli input dell'utente o moduli di terze parti.

In altre parole, ciò che TDD fa non ti impedisce di avere bisogno del codice di convalida quanto di aiutarti a non dimenticarlo .


2

Penso di interpretare le osservazioni del tuo collega in modo diverso dalla maggior parte delle altre risposte.

Mi sembra che l'argomento sia:

  • Tutto il nostro codice è testato in unità.
  • Tutto il codice che utilizza il tuo componente è il nostro codice o, in caso contrario, è testato dall'unità da qualcun altro (non esplicitamente dichiarato, ma è quello che ho capito da "test unitari dovrebbero catturare eventuali usi errati della classe").
  • Pertanto, per ogni chiamante della tua funzione c'è un test unitario da qualche parte che prende in giro il tuo componente, e il test fallisce se il chiamante passa un valore non valido a quel mock.
  • Pertanto, non importa cosa fa la tua funzione quando viene passato un valore non valido, perché i nostri test dicono che non può accadere.

Per me, questo argomento ha una certa logica, ma fa troppo affidamento sui test unitari per coprire ogni possibile situazione. Il semplice fatto è che la copertura al 100% di linea / ramo / percorso non esercita necessariamente tutti i valori che il chiamante potrebbe trasmettere, mentre la copertura al 100% di tutti i possibili stati del chiamante (vale a dire, tutti i possibili valori dei suoi input e variabili) è impossibile dal punto di vista computazionale.

Pertanto, preferirei preferire un test unitario dei chiamanti per garantire che (per quanto riguarda i test), non passino mai valori errati e inoltre richiedere che il componente non riesca in modo riconoscibile quando viene passato un valore errato ( almeno nella misura in cui è possibile riconoscere valori errati nella lingua scelta). Questo aiuterà il debug quando si verificano problemi nei test di integrazione e aiuterà anche tutti gli utenti della tua classe che sono meno che rigorosi nell'isolare la loro unità di codice da quella dipendenza.

Tieni presente, tuttavia, che se documenti e testa il comportamento della tua funzione quando viene passato un valore <= 0, i valori negativi non sono più validi (almeno, non più validi di qualsiasi argomento throw, dal momento che anche è documentato per generare un'eccezione!). I chiamanti hanno il diritto di fare affidamento su quel comportamento difensivo. Se il linguaggio lo consente, è possibile che questo sia lo scenario migliore: la funzione non ha "input non validi", ma i chiamanti che si aspettano di non provocare la funzione nel lancio di un'eccezione dovrebbero essere testati a sufficienza per assicurarsi che non " t passa tutti i valori che lo causano.

Nonostante pensi che il tuo collega sia in qualche modo meno completamente sbagliato della maggior parte delle risposte, arrivo alla stessa conclusione, che è che le due tecniche si completano a vicenda. Programmare in modo difensivo, documentare i controlli difensivi e testarli. Il lavoro è "inutile" solo se gli utenti del tuo codice non possono beneficiare di utili messaggi di errore quando commettono errori. In teoria se testano a fondo tutto il loro codice prima di integrarlo con il tuo e non ci sono mai errori nei loro test, allora non vedranno mai i messaggi di errore. In pratica, anche se stanno facendo TDD e l'iniezione di dipendenza totale, potrebbero comunque esplorare durante lo sviluppo o potrebbero esserci delle interruzioni nei loro test. Il risultato è che chiamano il tuo codice prima che il loro codice sia perfetto!


Quella attività di porre l'accento sul testare i chiamanti per assicurarsi che non passino valori cattivi sembra prestarsi a un codice fragile con molte dipendenze dei bassi verso il basso e nessuna chiara separazione delle preoccupazioni. Non credo proprio che mi piacerebbe il codice che sarebbe derivato dal pensiero dietro quell'approccio.
Craig,

@Craig: guardalo in questo modo, se hai isolato un componente per il test deridendo le sue dipendenze, perché non dovresti testare che passa solo i valori corretti a quelle dipendenze? E se non riesci a isolare il componente hai davvero separato le preoccupazioni? Non sono in disaccordo con la codifica difensiva, ma se i controlli difensivi sono i mezzi con cui stai testando la correttezza del codice chiamante, allora è un casino. Quindi penso che il collega dell'interrogante abbia ragione sul fatto che i controlli siano ridondanti, ma è sbagliato vedere questo come un motivo per non scriverli :-)
Steve Jessop,

l'unico bagliore che vedo è che sto ancora testando che i miei componenti non possono trasmettere valori non validi a quelle dipendenze, che sono pienamente d'accordo che dovrebbero essere prese, ma quante decisioni prende da quanti manager aziendali per rendere un privato componente pubblico in modo che i partner possano chiamarlo? Questo in realtà mi sta ricordando la progettazione del database e tutta l'attuale relazione amorosa con gli ORM, risultando in così tante persone (per lo più giovani) che dichiarano che i database sono solo una stupida memoria di rete e non dovrebbero proteggersi con vincoli, chiavi esterne e procedure memorizzate.
Craig,

L'altra cosa che vedo è che in quello scenario, ovviamente, è che stai solo testando le chiamate alle beffe, non alle dipendenze effettive. In ultima analisi, è il codice in quelle dipendenze che può o non può funzionare in modo appropriato con un determinato valore passato, non il codice nel chiamante. Quindi la dipendenza deve fare la cosa giusta e deve esserci una copertura di prova indipendente sufficiente della dipendenza per assicurarsi che lo faccia. Ricorda, questi test di cui stiamo parlando sono chiamati test "unitari". Ogni dipendenza è un'unità. :)
Craig,

1

Le interfacce pubbliche possono e saranno utilizzate in modo improprio

L'affermazione del collega "test unitari dovrebbe rilevare eventuali usi errati della classe" è rigorosamente falsa per qualsiasi interfaccia che non sia privata. Se una funzione pubblica può essere chiamata con argomenti interi, allora può e verrà chiamata con qualsiasi argomento intero e il codice dovrebbe comportarsi in modo appropriato. Se una firma di funzione pubblica accetta ad es. Il tipo Double Java, quindi null, NaN, MAX_VALUE, -Inf sono tutti valori possibili. Il test di unità non può prendere usi non corretti della classe perché quei test non possono testare il codice che utilizzerà questa classe, perché quel codice non è stato ancora scritto, potrebbero non essere scritti da voi, e sarà sicuramente al di fuori della portata delle vostre unit test .

D'altra parte, questo approccio può essere valido per le proprietà (si spera molto più numerose) private - se una classe può garantire che alcuni fatti siano sempre veri (es. La proprietà X non può mai essere nulla, la posizione intera non supera la lunghezza massima , quando viene chiamata la funzione A, tutte le strutture di dati dei prerequisiti sono ben formate), può essere opportuno evitare di verificarlo più e più volte per motivi di prestazioni e fare invece affidamento su test unitari.


L'intestazione e il primo paragrafo sono veri perché non sono i test unitari che eserciteranno il codice in fase di esecuzione. È qualunque altro codice di runtime e condizioni di cambiamento del mondo reale, input di utenti non validi e tentativi di hacking interagiscono con il codice.
Craig,

1

La difesa contro l'uso improprio è una caratteristica , sviluppata a causa di un requisito per questo. (Non tutte le interfacce richiedono controlli rigorosi contro l'uso improprio; ad esempio quelle interne utilizzate in modo molto limitato.)

La funzione richiede test: la difesa contro l'uso improprio funziona davvero? L'obiettivo del test di questa funzionalità è provare a dimostrare che non funziona: escogitare un uso improprio del modulo che non viene intercettato dai suoi controlli.

Se controlli specifici sono una caratteristica richiesta, è davvero assurdo affermare che l'esistenza di alcuni test li rende inutili. Se è una caratteristica di alcune funzioni che (diciamo) genera un'eccezione quando il parametro tre è negativo, allora non è negoziabile; lo farà.

Tuttavia, sospetto che il tuo collega abbia effettivamente senso dal punto di vista di una situazione in cui non è richiesto un controllo specifico sugli input, con risposte specifiche a input errati: una situazione in cui esiste solo un requisito generale compreso per robustezza.

I controlli all'entrata in alcune funzioni di livello superiore sono lì, in parte, per proteggere alcuni codici interni deboli o mal testati da combinazioni inaspettate di parametri (in modo che se il codice è ben testato, i controlli non sono necessari: il codice può semplicemente " tempo "i parametri cattivi).

C'è verità nell'idea del collega, e ciò che probabilmente intende è questo: se costruiamo una funzione a partire da pezzi di livello inferiore molto robusti che sono codificati in modo difensivo e testati individualmente contro ogni abuso, allora è possibile che la funzione di livello superiore sia robusto senza autocontrollo approfondito.

Se il suo contratto viene violato, allora si tradurrà in un uso improprio delle funzioni di livello inferiore, magari lanciando eccezioni o altro.

L'unico problema è che le eccezioni di livello inferiore non sono specifiche dell'interfaccia di livello superiore. Se questo è un problema dipende da quali sono i requisiti. Se il requisito è semplicemente "la funzione deve essere robusta contro l'uso improprio e generare una sorta di eccezione anziché crash, o continuare a calcolare con i dati della spazzatura", in realtà potrebbe essere coperta da tutta la robustezza dei pezzi di livello inferiore su cui si trova costruito.

Se la funzione ha un requisito per la segnalazione di errori molto specifica e dettagliata relativa ai suoi parametri, i controlli di livello inferiore non soddisfano pienamente tali requisiti. Assicurano solo che la funzione esploda in qualche modo (non continua con una cattiva combinazione di parametri, producendo un risultato di immondizia). Se il codice client viene scritto per rilevare in modo specifico determinati errori e gestirli, potrebbe non funzionare correttamente. Il codice client potrebbe ottenere da sé, come input, i dati su cui si basano i parametri e potrebbe aspettarsi che la funzione li controlli e traduca valori errati negli errori specifici come documentato (in modo da poterli gestire errori correttamente) piuttosto che alcuni altri errori che non sono gestiti e forse fermano l'immagine del software.

TL; DR: il tuo collega probabilmente non è un idiota; stai solo parlando l'uno con l'altro con prospettive diverse attorno alla stessa cosa, perché i requisiti non sono completamente definiti e ognuno di voi ha un'idea diversa di quali siano i "requisiti non scritti". Pensi che quando non ci sono requisiti specifici per il controllo dei parametri, dovresti comunque codificare il controllo dettagliato; pensa il collega, lascia che il robusto codice di livello inferiore esploda quando i parametri sono sbagliati. È in qualche modo poco produttivo discutere dei requisiti non scritti attraverso il codice: riconoscere che non si è d'accordo sui requisiti piuttosto che sul codice. Il tuo modo di codificare riflette ciò che pensi siano i requisiti; il modo del collega rappresenta la sua visione dei requisiti. Se lo vedi in questo modo, è chiaro che ciò che è giusto o sbagliato non è t nel codice stesso; il codice è solo un proxy per la tua opinione su quale dovrebbe essere la specifica.


Ciò si collega a una generale difficoltà filosofica nel gestire quelli che possono essere requisiti imprecisi. Se a una funzione viene concesso un regno libero significativo ma non totale di comportarsi in modo arbitrario quando vengono forniti input non validi (ad es. Se un decodificatore di immagine può soddisfare i requisiti se può essere garantito, a suo piacimento, di produrre una combinazione arbitraria di pixel o di terminare in modo anomalo , ma non se può consentire a input creati in modo pericoloso di eseguire codice arbitrario), potrebbe non essere chiaro quali casi di test sarebbero appropriati per garantire che nessun input produca comportamenti inaccettabili.
Supercat,

1

I test definiscono il contratto della tua classe.

Come corollario, l' assenza di un test definisce un contratto che include un comportamento indefinito . Quindi, quando si passa nulla Foo::Frobnicate(Widget widget), e ne consegue un caos non dichiarato di runtime, si è ancora all'interno del contratto della propria classe.

Successivamente decidi che "non vogliamo la possibilità di un comportamento indefinito", che è una scelta sensata. Ciò significa che si deve avere un comportamento previsto per il passaggio nulla Foo::Frobnicate(Widget widget).

E documentate quella decisione includendo a

[Test]
void Foo_FrobnicatesANullWidget_ThrowsInvalidArgument() 
{
    Given(Foo foo);
    When(foo.Frobnicate(null));
    Then(Expect_Exception(InvalidArgument));
}

1

Una buona serie di test eserciterà l' interfaccia esterna della tua classe e assicurerà che tali abusi generino la risposta corretta (un'eccezione o qualunque cosa tu definisca "corretta"). In effetti, il primo caso di test che scrivo per una classe è chiamare il suo costruttore con argomenti fuori portata.

Il tipo di programmazione difensiva che tende ad essere eliminato con un approccio completamente testato dall'unità è la convalida non necessaria di invarianti interni che non possono essere violati da un codice esterno.

Un'idea utile che talvolta utilizzo è quella di fornire un metodo che collauda gli invarianti dell'oggetto; il tuo metodo di demolizione può chiamarlo per confermare che le tue azioni esterne sull'oggetto non rompono mai gli invarianti.


0

I test di TDD rileveranno errori durante lo sviluppo del codice .

I limiti che controlli che descrivi come parte della programmazione difensiva colpiranno errori durante l'uso del codice .

Se i due domini sono uguali, ovvero il codice che stai scrivendo viene sempre e solo utilizzato internamente da questo specifico progetto, allora può essere vero che TDD precluderà la necessità dei limiti di programmazione difensiva che ti vengono descritti, ma solo se quei tipi del controllo dei limiti vengono eseguiti specificamente nei test TDD .


Come esempio specifico, supponiamo che una libreria di codice finanziario sia stata sviluppata usando TDD. Uno dei test potrebbe affermare che un determinato valore non può mai essere negativo. Ciò garantisce che gli sviluppatori della libreria non utilizzino accidentalmente le classi mentre implementano le funzionalità.

Ma dopo che la libreria è stata rilasciata e la sto usando nel mio programma, quei test TDD non mi impediscono di assegnare un valore negativo (supponendo che sia esposto). Il controllo dei limiti sarebbe.

Il mio punto è che mentre un'asserzione TDD potrebbe affrontare il problema del valore negativo se il codice viene utilizzato solo internamente come parte dello sviluppo di un'applicazione più grande (sotto TDD), se sarà una libreria utilizzata da altri programmatori senza TDD quadro e test , controllo dei limiti.


1
Non ho espresso il mio voto negativo, ma concordo con il voto negativo sul presupposto che l'aggiunta di sottili distinzioni a questo tipo di argomento offuschi l'acqua.
Craig,

@Craig Sarei interessato al tuo feedback sull'esempio specifico che ho aggiunto.
Blackhawk,

Mi piace la specificità dell'esempio. L'unica preoccupazione che ho ancora è generale per l'intero argomento. Per esempio; arriva un nuovo sviluppatore nel team e scrive un nuovo componente che utilizza quel modulo finanziario. Il nuovo ragazzo non è a conoscenza di tutte le complessità del sistema, per non parlare del fatto che tutti i tipi di conoscenza esperta su come il sistema dovrebbe funzionare è incorporato nei test piuttosto che nel codice che viene testato.
Craig,

Quindi al nuovo ragazzo / ragazza manca la creazione di alcuni test vitali, e finisci con la ridondanza nei tuoi test - i test in diverse parti del sistema stanno verificando le stesse condizioni e diventano incoerenti col passare del tempo, invece di mettere semplicemente affermazioni appropriate e controlli preliminari nel codice in cui si trova l'azione.
Craig,

1
Qualcosa del genere. Tranne il fatto che molti degli argomenti qui riguardano il fatto che i test per il codice chiamante facciano tutti i controlli. Ma se hai qualche grado di fan-in, finisci per fare gli stessi controlli da un sacco di posti diversi, e questo è un problema di manutenzione in sé e per sé. Cosa succede se l'intervallo di input validi per una procedura cambia, ma si dispone delle conoscenze del dominio per quell'intervallo integrato nei test che esercitano componenti diversi? Sono ancora completamente a favore della programmazione difensiva e utilizzo della profilazione per determinare se e quando hai problemi di prestazioni da affrontare.
Craig,

0

TDD e programmazione difensiva vanno di pari passo. L'uso di entrambi non è ridondante, ma in realtà complementare. Quando hai una funzione, vuoi assicurarti che funzioni come descritto e scrivere test per essa; se non si copre ciò che accade in caso di input errato, rendimento errato, cattivo stato, ecc., non si sta scrivendo i test in modo sufficientemente solido e il codice sarà fragile anche se tutti i test vengono superati.

Come ingegnere incorporato, mi piace usare l'esempio di scrivere una funzione per aggiungere semplicemente due byte insieme e restituire il risultato in questo modo:

uint8_t AddTwoBytes(uint8_t a, uint8_t b, uint8_t *sum); 

Ora, se semplicemente *(sum) = a + blo facessi , funzionerebbe, ma solo con alcuni input. a = 1e b = 2farebbe sum = 3; tuttavia, poiché la dimensione della somma è un byte a = 100e b = 200sarebbe sum = 44dovuta a un overflow. In C, in questo caso restituiresti l'errore per indicare che la funzione non è riuscita; generare un'eccezione è la stessa cosa nel tuo codice. Non considerare i guasti o testare come gestirli non funzionerà a lungo termine, perché se si verificano tali condizioni, non verranno gestite e potrebbero causare un numero qualsiasi di problemi.


Sembra un buon esempio di intervista (perché ha un valore di ritorno e un parametro "out" - e cosa succede quando sumè un puntatore nullo?).
Toby Speight,
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.