cerca
Ingegneria del Software - 5 giugno
modifica cronologia stampa login logout

Wiki

UniCrema


Materie per semestre

Materie per anno

Materie per laurea


Help

Ingegneria del Software - 5 giugno

Torna alla pagina di Ingegneria del Software

 :: Ingegneria del Software - 5 giugno ::

Testing Black-Box

All'esame ci sarà un esercizio che avrà una di queste due forme:

  • ci dà l'interfaccia della funzione, ovvero i parametri che prende in ingresso;
  • ci dà i requisiti di una data funzione.

Trattandosi di test Black-Box non avremo il codice della funzione stessa, e dovremo invece partizionare lo spazio di input in modo astuto. Occorre testare sia i valori validi che quelli non validi, e per determinare chi è valido e chi no possiamo basarci su diversi ragionamenti, che poi vedremo.

Ad ogni modo, il procedimento è sempre quello:

  1. partiziono lo spazio di input in base ad un qualche criterio (valori ammissibili, pre- o post-condizioni);
  2. per ogni partizione, scelgo i valori per il test.

Ricordiamo che per qualsiasi scelta che facciamo il professore vuole che la documentiamo. Questo significa che:

  1. dobbiamo descrivere i motivi che ci hanno indotto a scegliere quel modo di partizionare l'input;
  2. dobbiamo descrivere i motivi che ci hanno indotto, all'interno di ogni singola partizione, a scegliere quei determinati valori di input.

Esempio 1

Ho una funzione che prende in input un intero, e sappiamo che i valori in ingresso vanno da 10.000 a 99.999. Non sappiamo niente su quello che fa la funzione, tranne che restituisce un intero. Abbiamo a disposizione 10 casi di test.

La prima osservazione è che non scegliamo i valori a caso nell'intervallo di input. Si potrebbe anche fare, nessuno lo vieta, ma scegliere a caso vuol dire in genere non garantire la copertura uniforme delle partizioni. Al contrario, è possibile, all'interno di una partizione, scegliere a caso, fatti salvi i criteri del boundary e dei valori critici.

Ma quali sono le partizioni, in sto caso? Se il valore in ingresso fosse stato un intero e basta, allora il valore minimo sarebbe stato MIN_INT e quello massimo MAX_INT. Avremmo quindi diviso l'intervallo [MIN_INT, MAX_INT] in 10 parti (perché abbiamo a disposizione 10 casi di test), e per ciascuna di queste 10 parti avremmo scelto un valore (anche casuale).

Nel nostro caso invece i valori di input leciti sono [10.000, 99.999]. Questi valori sono dei limiti che ci aiuteranno a partizionare.
ATTENZIONE: il tipo in input è sempre un int, e quindi le partizioni vanno fatte su tutti gli interi! La differenza rispetto a prima è che qui ho informazioni ulteriori riguardanti l'input, che mi serviranno appunto a creare delle partizioni.

Un esempio di partizione dell'input per questo esercizio potrebbe essere:

 [MIN_INT - 9999], [10.000 - 50.000], [50.001 - 99.999], [99.999 - MAX_INT]

Poi prendo gli estremi di ciascuna partizione, oppure i punti centrali di ogni partizione, purché io copra tutte le partizioni.

E se avessi avuto stringhe?

Nel caso delle stringhe, è difficile che ci siano delle condizioni sull'input. Tuttavia, noi sappiamo che è comunque possibile ordinare le stringhe lessicograficamente. Questo significa che utilizzeremo l'ordinamento per stabilire delle partizioni.

Il discorso vale in generale per tutti quei tipi per i quali non si sa che pesci pigliare: se è possibile ordinarli, allora si usa l'ordinamento del tipo per metterli in un certo ordine, e poi si divide questa "scala graduata" nel numero di partizioni desiderato.

Esempio 2: pre-condizioni e post-condizioni

Una funzione serve per cercare una chiave in un array. Se lo trova, restituisce in un booleano il valore true, e restituisce anche il valore della chiave. Se non la trova, restituisce false nel booleano.

Ecco le condizioni che devono valere per questa funzione:

  • pre-condizione: l'array ha almeno un elemento
  • post-condizione: se l'elemento da cercare c'è, lo trova
  • post-condizione: se l'elemento da cercare non c'è, mi dice false

Il discorso che si può fare in generale è che le pre-condizioni sono lì apposta a partizionare lo spazio di input, mentre le post-condizioni no. Però c'è almeno un caso in cui anche le post-condizioni servono per partizionare lo spazio di input, e quel caso si ha quando la post-condizione è in realtà una disgiunzione, ovvero un OR di due condizioni.

Che cosa significa? Vuol dire che la post-condizione può essere verificata da due diverse cause, che sono appunto quelle messe in OR.

Per chiarire la roba, guardiamo al nostro caso.

  1. array con 0 elementi
  2. array con >= 1 elementi
    1. l'elemento c'è
    2. l'elemento non c'è

Se guardiamo alle specifiche della funzione, leggiamo che se l'elemento è presente, mi deve dire true e restituire l'indice. Se l'elemento non è presente, mi deve restituire false. Quindi la precondizione è duplice, è una disgiunzione di due comportamenti diversi, e quindi mi serve ad indurre partizionamento sullo spazio di input.

Ecco quindi come potrei partizionare l'input:

  1. array vuoto
  2. array con 1 elemento, chiave presente
  3. array con 1 elemento, chiave assente
  4. array con >1 elemento, il primo è la chiave cercata
  5. array con >1 elemento, l'ultimo è la chiave cercata
  6. array con >1 elemento, quello in mezzo è la chiave cercata
  7. array con >1 elemento, non c'è la chiave

Come si presentano i casi di test

La forma generale per presentare un caso di test è questa:

Input Output atteso
Valore in input Output atteso

Le colonne Input devono essere tante quanti i valori in input passati alla funzione, così come le colonne in output devono essere tante quante i valori in output. Nel caso della funzione che cerca nell'array, quindi, avremmo avuto una tabella così:

Input Input Output atteso Output atteso
Valore in input Valore in input Output atteso Output atteso

Infatti, in input abbiamo due robe diverse: l'array e la chiave da ricercare nell'array. Abbiamo anche due output: il booleano che dice se ha trovato o meno la chiave, e la chiave stessa in caso di successo.

Testing White-Box

Attenzione: questa parte della lezione è più confusa e disorganizzata rispetto a quella prima.

Partiamo subito con un esempio: la ricerca binaria. Qui abbiamo la precondizione che l'array sia ordinato. Infatti, la ricerca binaria consiste nel cercare un elemento in un array ordinato, partendo dalla metà. Se il numero è superiore, si prende la metà della metà successiva, altrimenti la metà della metà inferiore e così via.

In questo caso, la "binarietà" induce una partizione dello spazio di input del 50% e 50%. Però, se nell'esercizio ci mette il codice sorgente, allora occorre fare del white-box testing, e le cose cambiano un po'.

Code coverage

La copertura del codice è la misura di quante righe di codice una suite di casi di test esegue. In genere la domanda è: fammi il code coverage, e dimmi se è possibile arrivare al 100%. Arrivare al 100% non è sempre possibile: il codice può essere scritto in modo da avere del dead code, cioè del codice irraggiungibile, sepolto da condizioni che non si avvereranno mai. Rimandiamo alle lezioni in aula per verificare questa faccenda.

Qualsiasi sia il tipo di test, è in genere consigliabile disegnare il grafo di flusso della porzione di codice, in modo che si abbiano sott'occhio tutte le istruzioni ed i percorsi, dettati dalle condizioni, che portano ad esse.

Edge Coverage

L'edge coverage vuole coprire ogni arco del nostro diagramma di flusso. L'arco collega due nodi, e noi vogliamo coprirli tutti.

Da non confondere con il...

Path coverage

che ha a che fare con la complessità ciclomatica di McCabe.

Un cammino esecutivo è un percorso nel mio grafo di flusso. Due cammini sono indipendenti quando differiscono per almeno un'istruzione. Due cammini sono invece non indipendenti quando sono ripetizione di stesso codice, ad esempio l'interno di un ciclo for.

Notiamo subito che avere la copertura di tutti i cammini indipendenti è possibile, ma coprire tutti i cammini non indipendenti praticamente non lo è. L'esempio del ciclo for serve per chiarire questa affermazione. Se un ciclo viene eseguito 10 volte, allora ho coperto 10 cammini indipendenti. Ma se in teoria lo posso eseguire 1000000 di volte, allora dovrei avere valori in input che lo fanno eseguire 1000000 di volte. E se invece il limite al numero di volte che il for viene ripetuto è MAX_INT, dovrei dare in input valori che lo facciano ripetere MAX_INT volte. Se poi un for è innestato ad un altro, è finita. Quindi, lasciamo perdere ed occupiamoci solo della copertura dei cammini indipendenti.

  • DOMANDA: quanti sono i cammini indipendenti?
  • RISPOSTA: numero di if + 1

Per rispondere a questa domana, oltre a guardare il codice, può essere molto utile disegnare il diagramma di flusso della nostra funzione, così che con un colpo d'occhio ci rendiamo conto dei cammini esistenti in modo "concreto".

Nel disegnare il grafo, teniamo presente queste avvertenze:

  1. le condizioni AND sono rappresentate da due istruzioni, una in sequenza all'altra
  2. le condizioni OR sono rappresentate da due istruzioni, una in parallelo all'altra
  3. tutte le altre strutture di controllo si possono ricondurre ad un if (come spiegava Tettamanzi ai tempi...:( )

Condition coverage

Tutte le condizioni delle strutture di controllo sono valutate almeno una volta.

Ad esempio, c'è una riga così:

 if (c > 0 or d == 0) then BLA else BLO

Se avessimo voluto del code coverage, trattandosi di un OR, sarebbe bastato (in)soddisfare una sola di queste due proprietà:

  • c > 0
  • oppure d == 0

Per esempio, una volta prendiamo c > 0 ed eseguiamo BLA, poi prendiamo c == 0 ed eseguiamo BLO.

Ma volendo avere invece la condition coverage, occorre notare che qui le condizioni sono in realtà 2: c > 0 e d == 0. Pertanto, dovrò avere questi casi di test:

  • un caso in cui c > 0
  • un caso in cui c <= 0
  • un caso in cui d == 0
  • un caso in cui d != 0

Inoltre, notiamo anche che la valutazione di d == 0 è subordinata al fallimento di c > 0.

Riassumendo, per fare condition coverage devo avere almeno un caso di test per ogni componente atomico delle condizioni.

UML

Le domande di UML saranno più o meno di questo tipo:

  • Questo è il diagramma delle classi, fammi il diagramma delle sequenze
  • Questo è il diagramma delle sequenze, traine il diagramma delle classi

ATTENZIONE: i due diagrammi devono essere compatibili! Se nel diagramma delle sequenze c'è una classe, ma nel diagramma delle classi essa non compare, allora è grave! Le regole di compatibilità sono queste:

  1. ogni oggetto di un diagramma deve apparire anche nell'altro;
  2. quando un oggetto chiama un altro oggetto, il CHIAMATO deve avere un metodo che ha lo stesso nome del messaggio che gli arriva.

La regola numero due tuttavia ammette una deroga, riguardante gli oggetti GUI (Graphical User Interface), ovvero le interfacce utente. Per esempio, quando io premo il pulsante OK, nel diagramma delle sequenze metterò la freccia etichettata "premiOK", ma ovviamente non esisterà un vero metodo chiamato "premiOK()".

COCOMO

Le domande sul COCOMO saranno di questi due tipi:

  1. ci viene data la dimensione di un programma (in KLOC) e ci chiede il costo;
  2. ci viene data la dimensione e la dead line (la scadenza entro la quale il software va consegnato) e ci chiede quanti programmatori impiegheremmo.

In entrambe le tipologie di domande, la prima cosa da fare è dedurre l'effort a partire dalla dimensione. Occorre usare la formula COCOMO:

 Effort = A * DimensioniB * M

dove:

  • A = variabile che esprime l'organizzazione aziendale
  • B = variabile che esprime la non proporzionalità tra dimensioni del codice e impestatezza dello stesso, ed è la variabile più importante
  • M = variabile che esprime la capacità della gente che metto al lavoro, ma anche la bontà dei processi industriali che applico. A volte la si chiama C.

La linearità nel rapporto dimensione/effort esprime un principio: per fare un programma di dimensione 100, occorre 10 volte il tempo che serve per fare un programma di dimensione 10.

Purtroppo questo assunto non è vero, ed ecco perché introduciamo la B, che serve appunto a dar conto della non-linearità del mondo reale.

Il risultato è dato in MESI / UOMO. Se ci chiede il costo complessivo, allora ci darà anche il costo del mese uomo, eg 5000 €, e poi facciamo le dovute moltiplicazioni.

Quando otteniamo l'Effort, ad esempio 10 mesi uomo, possiamo dire con buona certezza che per consegnare il software in un mese mi occorrono 10 programmatori, e che il costo sarà quindi di 5000 * 10 = 50.000 €.

C'è però da ricordare anche che dividere l'effort totale per numero di programmatori è a sua volta un'ipotesi di linearità: 2 persone ci mettono la metà del tempo, a fare la stessa roba, che ci avrebbe impiegato una persona sola. Noi in realtà sappiamo che più la gente è tanta, più c'è overhead di comunicazione tra di essi (un po' come le economie e le diseconomie di scala).

Una formula per tener conto di ciò potrebbe essere:

  • Effort = (Effort singolo - overhead dovuto alla presenza di troppa gente) * numero di singoli

dove con Effort singolo intendo dire l'Effort che una singola persona riesce a produrre.

Se la variabile B è attorno ad 1, allora posso assumere che il progetto sia molto lineare, e quindi l'overhead è nullo. Se invece B comincia a salire, allora occorre ipotizzare che la produttività del singolo diminuirà, e la deadline diventerà un incubo ricorrente per tutti gli impiegati.

La regola del pollice per questa faccenda è la seguente: se Effort e Deadline sono dello stesso ordine di grandezza, allora va tutto bene ed assumo ipotesi di linearità nella produttività del singolo. Se invece l'Effort è di un ordine di grandezza superiore, allora sarà il caso di preoccuparsi ed aggiungere un programmatore.

NOTA del trascrittore: in realtà la faccenda è nebulosa, perché più gente aggiungo più si crea casino e meno produco. Boh. Questo è ciò che ha detto...


Torna alla pagina di Ingegneria del Software