Di seguito viene riportato un esempio di codice difettoso:
File file = new File(stringName);
file.delete();
Il difetto è che File.delete non può avere esito negativo, ma il codice non controlla tale
errore. La relativa correzione richiede l'aggiunta del codice in corsivo mostrato di seguito:
File file = new File(stringName);
if (file.delete()
== false) {...}
Questa linea guida descrive un metodo per la rilevazione dei casi in cui il codice non gestisce il risultato di
chiamata al metodo. (Tenere presente che presuppone che il metodo chiamato produca il risultato corretto per qualsiasi
input che viene fornito. Tale situazione dovrebbe essere testata, ma la creazione di proposte di test per il metodo
chiamato è un'attività separata. Ciò significa che non è previsto il test di File.delete.)
La nozione chiave presuppone la creazione di una proposta di test per ogni distinto risultato rilevante non
gestito di una chiamata al metodo. Per definire tale termine, viene di seguito esaminato il termine
risultato. Quando viene eseguito un metodo, quest'ultimo cambia lo stato del mondo. Eccone alcuni esempi:
-
Potrebbe spingere i valori di ritorno sullo stack di runtime.
-
Potrebbe emettere un'eccezione.
-
Potrebbe modificare una variabile globale.
-
Potrebbe aggiornare un record in un database.
-
Potrebbe inviare dati sulla rete.
-
Potrebbe stampare un messaggio nell'output standard.
Di seguito viene riesaminato il termine rilevante, utilizzando alcuni esempi.
-
Si supponga che il metodo chiamato stampi un messaggio nell'output standard. Ciò "cambia lo stato del mondo", ma
non può influenzare l'ulteriore elaborazione di questo programma. Non importa qual è il risultato della stampa, se
anche fosse inesistente, non potrebbe influenzare l'esecuzione del codice.
-
Se il metodo restituisce true per successo e false per errore, il programma probabilmente dovrebbe essere diramato
in base al risultato. Quindi, tale valore di ritorno è rilevante.
-
Se il metodo chiamato aggiorna un record del database che il codice legge e utilizza in un secondo momento, il
risultato (l'aggiornamento del record) è rilevante.
(Non esiste una linea assoluta tra rilevante e irrilevante. Chiamando print, è possibile che il
metodo causi l'assegnazione dei buffer e che tale assegnazione sia rilevante. E' concepibile che un difetto possa
dipendere da quali buffer vengano o meno assegnati. E' concepibile, ma è plausibile?)
Un metodo potrebbe disporre di un numero elevato di risultati, ma solo alcuni saranno distinti. Ad esempio, si
consideri un metodo che scrive i byte sul disco. Potrebbe restituire un numero inferiore a zero per indicare un errore
o il numero di byte scritti (che potrebbe essere inferiore al numero richiesto). L'elevato numero di possibilità può
essere riassunto in tre risultati distinti:
-
un numero inferiore a zero.
-
il numero scritto è uguale al numero richiesto
-
alcuni byte sono stati scritti, ma in numero inferiore rispetto a quello richiesto.
Tutti i valori inferiori a zero vengono raggruppati in un risultato perché nessun programma sensato farà una
distinzione tra di loro. Tutti (se, effettivamente, è possibile utilizzarne più di uno) dovrebbero essere trattati come
un errore. In modo simile, se il codice ha richiesto la scrittura di 500 byte, non importa se ne sono effettivamente
stati scritti 34 o 340: lo stesso avverrà con i byte non scritti. (Se occorre eseguire un'azione diversa per qualche
valore, ad esempio 0, questa restituirà un risultato nuovo e distinto).
Occorre fare ancora una precisazione. Questa specifica tecnica di verifica non ha attinenza con risultati distinti già
gestiti. Si consideri, nuovamente, questo codice:
File file = new File(stringName);
if (file.delete() == false) {...}
Sono presenti due risultati separati (true e false). Il codice li gestisce. E' possibile che li gestisca in modo non
corretto, ma le proposte di test da Linea guida del prodotto di lavoro: Proposte di test per valori booleani e boundary
lo verificheranno. Questa tecnica di test riguarda risultati distinti che non sono gestiti specificamente dal codice
distinto. Ciò potrebbe verificarsi per due motivi: si pensava che la distinzione fosse irrilevante o la si è
semplicemente trascurata. Di seguito viene riportato un esempio del primo caso:
result = m.method();
switch (result) {
case FAIL:
case CRASH:
...
break;
case DEFER:
...
break;
default:
...
break;
}
FAIL CRASH sono gestiti dallo stesso codice. Potrebbe essere una buona
idea verificare che ciò è corretto. Di seguito viene riportato un esempio di distinzione trascurata:
result = s.shutdown();
if (result == PANIC) {
...
} else {
// successo! Arrestare il reattore.
...
}
Viene fuori che tale spegnimento può restituire un ulteriore risultato distinto: RETRY. Il
codice come è stato scritto tratta tale caso come se fosse lo stesso del caso di esito positivo, che è sicuramente
sbagliato.
Quindi l'obiettivo è pensare a questi risultati distinti rilevanti che sono stati trascurati in precedenza. Ciò sembra
impossibile: perché realizzare ora che sono rilevanti se prima non lo è stato fatto?
La risposta è che un riesame sistematico del codice, con un'attitudine mentale di verifica e non di programmazione,
potrebbe, talvolta, far scaturire nuove idee. E' possibile porsi domande sulle proprie ipotesi esaminando
metodicamente il codice e i metodi chiamati, controllando di nuovo la relativa documentazione e tirando le somme. Di
seguito vengono riportati alcuni casi da verificare.
Casi "impossibili"
Spesso, risulterà che gli errori restituiti sono impossibili. Verificare due volte le proprie ipotesi.
Questo esempio mostra un'implementazione Java di un linguaggio Unix comune per la gestione dei file temporanei.
File file = new File("tempfile");
FileOutputStream s;
try {
// aprire il file temp.
s = new FileOutputStream(file);
} catch (IOException e) {...}
// Accertarsi che il file temp verrà eliminato
file.delete();
L'obiettivo è verificare che un file temporaneo venga sempre eliminato, indipendentemente dalle modalità di chiusura
del programma. Ciò viene eseguito creando il file temporaneo ed eliminandolo subito dopo. Su Unix, è possibile
continuare a lavorare con il file eliminato e il sistema operativo si prende cura di eseguire la ripulitura al termine
del processo. Un programmatore Unix non scrupoloso potrebbe non scrivere il codice per controllare un'eliminazione non
riuscita. Dal momento che ha appena creato il file, deve poterlo eliminare.
Questo trucco non funziona su Windows. L'eliminazione avrà esito negativo perché il file è aperto. La rilevazione di
tale situazione è ardua: relativamente all'agosto del 2000, la documentazione Java non ha elencato le situazioni in cui
delete poteva avere esito negativo; indica semplicemente che può. Ma, forse, nella "modalità di
verifica" il programmatore potrebbe porsi delle domande sulla propria ipotesi. Dal momento che si suppone che il codice
venga "scritto una volta ed eseguito ovunque", potrebbe chiedere a un programmatore Windows quando File.delete ha esito negativo in Windows e quindi scoprire la terribile verità.
Casi "irrilevanti"
Un altro elemento sfavorevole che impedisce la rilevazione di un valore distinto significativo è essere già convinti
che non abbia importanza. Un metodo del ComparatoreJava, compare ,
restituisce il numero <0, 0 o il numero >0. Questi sono casi distinti che è possibile verificare. Questo codice
ne considera due contemporaneamente:
void allCheck(Comparator c) {
...
if (c.compare(o1, o2) <= 0) {
...
} else {
...
}
Ma potrebbe essere sbagliato. Il modo per rilevare se lo è o meno è per tentare due casi separatamente, anche se si ha
quasi la certezza che non faccia la differenza. (Le proprie convinzioni sono realmente ciò che si sta testando). Tenere
presente che è possibile che si stia eseguendo il caso then dell'istruzione if più volte per altre ragioni. Perché non tentarne uno con il risultato inferiore a 0 e uno con il
risultato esattamente uguale a zero?
Eccezioni non rilevate
Le eccezioni sono un tipo di risultato distinto. Tramite il background, considerare questo code:
void process(Reader r) {
...
try {
...
int c = r.read();
...
} catch (IOException e) {
...
}
}
Ci si aspetterebbe di controllare se il codice del gestore effettua l'azione adeguata con un errore di lettura. Ma si
supponga che un'eccezione rimanga ingestita in modo esplicito. Viene invece consentita la propagazione di quest'ultima
tramite il codice nel test. In Java, sarà simile al seguente esempio:
void process(Reader r)
throws IOException {
...
int c = r.read();
...
}
Questa tecnica chiede di testare il caso anche se il codice non la gestisce esplicitamente. Perché? A causa di
questo tipo di errore:
void process(Reader r) throws IOException {
...
Tracker.hold(this);
...
int c = r.read();
...
Tracker.release(this);
...
}
In questo caso, il codice influenza lo stato globale (tramite Tracker.hold). Se viene emessa
l'eccezione, Tracker.release non verrà mai chiamato.
(Tenere presente che l'errore di rilascio probabilmente non avrà alcuna conseguenza immediata). Il problema non sarà
visibile finché il processo non verrà chiamato nuovamente, quindi il tentativo di conservare l'oggetto per una seconda volta avrà esito negativo. Un buon articolo relativo a tali
difetti è stato scritto da Keith Stobie, "Testing for Exceptions". (Get Adobe Reader))
Questa particolare tecnica non verifica tutti i difetti associati alle chiamate al metodo. Ecco due tipi abbastanza
difficili da rilevare.
Argomenti non corretti
Considerare queste due linee di codice C, di cui la prima è sbagliata e la seconda è corretta.
... strncmp(s1, s2, strlen(s1)) ...
... strncmp(s1, s2, strlen(
s2)) ...
strncmp confronta due stringhe e restituisce un numero inferiore a 0 se il primo è dal punto di
vista lessicografico inferiore rispetto al secondo (viene prima in un dizionario). Restituisce "0" se sono uguali.
Restituisce un numero maggiore di 0 se il primo è, dal punto di vista lessicografico, superiore. Tuttavia, confronta
solo il numero di caratteri forniti dal terzo argomento. Il problema è che la lunghezza della prima stringa viene
utilizzato per limitare il confronto, mentre dovrebbe essere la lunghezza del secondo.
Questa tecnica richiederebbe tre test, uno per ogni differente valore di restituzione. Eccone tre che è possibile
utilizzare:
s1
|
s2
|
risultato previsto
|
risultato effettivo
|
"a"
|
"bbb"
|
<0
|
<0
|
"bbb"
|
"a"
|
>0
|
>0
|
"foo"
|
"foo"
|
=0
|
=0
|
Il difetto non viene rilevato perché nessun elemento in questa tecnica forza il terzo argomento ad assumere un
valore particolare. Ciò che è necessario è uno scenario di test come questo:
s1
|
s2
|
risultato previsto
|
risultato effettivo
|
"foo"
|
"food"
|
<0
|
=0
|
Sebbene esistano tecniche adatte alla rilevazione di tali difetti, esse vengono utilizzate raramente nella pratica.
L'impegno di test viene impiegato probabilmente meglio in una serie di test sostanziosa che si rivolge a diversi tipi
di difetti (e che si spera rilevi questo tipo come effetto collaterale).
Risultati indistinti
Vi è un rischio quando si effettua una codifica e una verifica, metodo per metodo. Di seguito ne viene riportato un
esempio. Esistono due metodi. Il primo, connect, intende stabilire una connessione di rete:
void connect() {
...
Integer portNumber = serverPortFromUser();
if (portNumber == null) {
// visualizza un messaggio a comparsa relativo al numero di porta non valido
return;
}
Quando necessita di un numero di porta, chiama serverPortFromUser . Tale metodo restituisce due valori
distinti. Restituisce un numero di porta scelto dall'utente se il numero scelto è valido (1000 o superiore). Altrimenti
restituisce null. Se viene restituito null, il codice testato visualizza un messaggio di errore ed esce.
Quando connect è stato testato, ha funzionato nel modo previsto: un numero di porta valido ha
consentito di stabilire la connessione mentre uno non valido ha provocato la visualizzazione di un messaggio a
comparsa.
Il codice per serverPortFromUser è leggermente più complicato. Prima visualizza una finestra a
comparsa che richiede una stringa e dispone dei pulsanti standard OK e ANNULLA. In base alle azioni dell'utente,
esistono quattro casi:
-
Se l'utente immette un numero valido, tale numero viene restituito.
-
Se il numero è troppo basso (inferiore a 1000), viene restituito null (in questo modo verrà visualizzato il
messaggio relativo al numero di porta non valido).
-
Se il numero non ha il formato corretto, viene restituito nuovamente null (e lo stesso messaggio).
-
Se l'utente fa clic su ANNULLA, viene restituito null.
Questo codice funziona anche come previsto.
Tuttavia, la combinazione delle due parti di codice ha una conseguenza negativa: l'utente preme ANNULLA e riceve un
messaggio su un numero di porta non valido. L'intero codice funziona come previsto, ma l'effetto generale è comunque
sbagliato. E' stato testato in modo ragionevole, ma è stato trascurato un difetto.
Il problema, in questo caso, è che null è un risultato che rappresenta due significati
distinti ("valore scorretto" e "utente cancellato"). Nulla in questa tecnica obbliga l'utente a notare tale problema
con la progettazione di serverPortFromUser.
Tuttavia, la verifica può aiutare. Quando serverPortFromUser viene testato in isolamento, giusto
per verificare se restituisce il valore previsto in ognuno di questi quattro casi, il contesto dell'uso viene perso.
Invece, si supponga che è stato testato utilizzando connect. Sarebbero presenti quattro test che
verificano entrambi i metodi contemporaneamente:
input
|
risultato previsto
|
processo previsto
|
l'utente immette "1000"
|
la connessione alla porta 1000 è aperta
|
serverPortFromUser restituisce un numero, che viene utilizzato.
|
l'utente immette "999"
|
messaggio a comparsa relativo a numero di porta non valido
|
serverPortFromUser restituisce null, che provoca la visualizzazione di una
finestra a comparsa
|
l'utente immette "i99"
|
messaggio a comparsa relativo a numero di porta non valido
|
serverPortFromUser restituisce null, che provoca la visualizzazione di una
finestra a comparsa
|
gli utenti fanno clic su ANNULLA
|
si consiglia di annullare l'intero processo di connessione
|
serverPortFromUser restituisce null, attendere un attimo perché ciò
non ha alcun senso...
|
Come avviene spesso, la verifica in un contesto di dimensioni maggiori rivela problemi di integrazione che non vengono
considerati dalla verifica su scala minore. Inoltre, una progettazione del test attenta e ragionata rivela il problema
prima dell'esecuzione del test. (Ma, qualora il difetto non venisse colto, verrà rilevato durante l'esecuzione del
test).
|