Torna alla pagina di Ingegneria del Software
:: Ingegneria del Software - Appunti del 5 Maggio 2009 ::
La lezione di oggi è stata un po' caotica e poco organizzata. Il suo scopo è stato quello di presentare una serie di concetti introduttori rispetto alle pratiche di testing. Per questo motivo ci sono tanti argomenti che continuano a riprendersi l'un l'altro.
Concetti iniziali
Requisiti
I requisiti sono di due tipi: funzionali e non funzionali. Quindi, dovremo avere intuitivamente due tipi di test: dei test funzionali e dei test non funzionali.
Questi due tipi di test si differenziano subito, perché:
- i test funzionali si fanno in fase di analisi => si verifica la funzionalità del software
- i test non funzionali si fanno in fase di codifica => si verifica la correttezza del software
Granularità
Un altro concetto importante è la granularità del testing. Riprendiamo un attimo i nostri bei diagrammi UML: ho le classi, i componenti, i sottosistemi, il sistema, l'utente. Insomma, ho una scala gerarchica che parte dal singolo metodo di una classe ed arriva ad un sistema installato su di una rete.
Il processo di test in generale consiste in:
- preparare un caso di test
- somministrarlo in qualche modo al software
- valutarne i risultati
Tutti questi passaggi vanno ripetuti a tutti i livelli di granularità, e ovviamente ci saranno differenze sul come preparare e somministrare i casi di test. Un'abilità tipica del software engineer è quella di saper preparare piani di test ad ogni livello di granularità.
Copertura
La copertura può essere vista come un criterio per valutare un test.
Un test controllerà un certo numero di requisiti: quanti ne controlla è appunto la copertura del test rispetto ai requisiti.
Un test controlla un certo numero di valori di input di un metodo: quanti ne controlla è la copertura del test rispetto ai valori di input.
Vediamo quindi che il concetto di copertura dipende dal livello di granularità a cui stiamo operando: se parlo di classi, allora coprirò gli input, se parlo di sistema allora coprirò i requisiti funzionali e così via.
Regressione
Esistono anche i test di regressione. Nei processi di sviluppo iterativi, ad ogni giro si riprende in mano il tutto e lo si modifica. Ma ogni volta che si modifica del codice, potrebbe essere possibile alterare il comportamento del software, in modo tale che un test che prima mi diceva che tutto andava bene, ora mi dirà il contrario. Posso quindi alterare la capacità del mio software di soddisfare i requisiti.
Teoricamente dovrei ripetere tutti i test ad ogni giro, ma questo oltre che dispendioso è inutile: se so dove sono state eseguite le modifiche, allora posso andare a ripetere solamente quei test che interessano il codice modificato.
Modelli
Come dicevamo la lezione scorsa, non posso pretendere che il rispetto di un modello garantisca l'assenza di errori nel mio software.
C'è gente che ha provato a scrivere modelli in un linguaggio matematico, il quale, tradotto poi in codice sorgente, permetteva di dimostrare a priori sul modello la funzionalità e la correttezza del codice.
Purtroppo questa via, oltre ad essere ancora oggetto di ricerca, è difficilmente praticabile, perché è oggettivamente difficile poter modellare tutti i requisiti in un diagramma, e soprattutto così facendo il modello assumerebbe delle proporzioni leviataniche.
Paradosso del testing
Qualsiasi metodo utilizzato per individuare certi fault lascerà un residuo di fault per i quali il mio metodo non è efficace.
È paradossale, ma è logico: dal momento che faccio un test per cercare di individuare un certo tipo di errori, ce ne saranno degli altri che per forza di cose dovrò lasciare fuori, dal momento che di test esaustivi non posso farne.
Inoltre, potrebbe essere considerata un paradosso anche la definizione di test, secondo la quale un test ha successo se evidenzia una failure. Lo scopo è dimostrare di aver sbagliato, e se lo facciamo siamo contenti.
In effetti, il punto di vista umano è diametralmente opposto. Il capo direbbe: "Se siete programmatori così bravi come dite di essere, non fareste tutti questi errori!". E il programmatore direbbe: "Fare test su del codice così semplice è fuori discussione, lo considero un'offesa personale!". Date queste premesse socio-psicologiche, ci rendiamo conto che il testing è indispensabile.
Vocabolarietto
Il testing ha un uso di parole tutte sue. Dovremo vederne un bel po', man mano che si va avanti.
- Fault = errore nel codice, il quale può causare un errore.
- Error = stato del sw in cui il proseguimento dell'esecuzione porta ad una failure
- Failure = deviazione tra il comportamento osservato del software e quello desiderato
La catena logica degli eventi è quindi Fault => Errore => Failure. Tuttavia, il test procede all'incontrario: fa di tutto per provocare una Failure; fatto questo, spetta a noi identificare il codice di errore che provoca il comportamento scorretto, e poi scovare il punto nel codice in cui tutto ciò è stato originato.
E teniamo anche a mente che non è per niente detto che un fault si traduca automaticamente in una failure. Se per esempio il mio codice presenta dei difetti solo con certi valori di input, e quei valori di input non vengono mai immessi, allora non mi accorgerò mai di questo difetto.
Rifacendoci alla simpatica legge di Pareto, possiamo anche inferire che i primi fault è facile scovarli: saltano fuori all'inizio e quindi il tempo ed il denaro spesi per trovarli sono pochi. Ma il brutto nasce quando sono rimasti pochi fault: ci metterò una quantità di tempo più che lineare per trovarli, perché sarà difficile far sì che il programma generi una failure!
La reliability è invece l'inverso della probabilità di avere una failure.
Tipi di testing
I tipi di testing possono essere divisi in due categorie, a seconda di come li si effettuano.
Execution-based testing = sono tutti quei test che vengono effettuati, a qualsiasi livello di granularità, eseguendo effettivamente il codice con certi input. Ci sono tutta una serie di tecniche e di tools che permettono di testare singole classi, componenti etc.
Non-execution-based testing = sono quei test che si verificano senza eseguire il codice. È possibile effettuarli, anche se il limite ovvio è che il codice non viene effettivamente eseguito. Ecco i tre tipi principali:
- Walkthrough = comincio a leggere il codice per vedere qualche errore. Un essere umano si crea nella propria mente un modello, ogni volta che legge un metodo, ed è un buon modo per controllare che il codice sia sensato o meno. Il problema è che se per leggere 100 righe ci metto un'ora, non è per niente detto che per leggerne il doppio ci metta il doppio del tempo: la quantità di tempo necessaria sale più che linearmente.
- Cleanroom = si modella il codice con qualche metodoformale, man mano che lo si esegue lo si testa, e poi si fanno analisi statistiche mirate per far sì che non sia rimasto indietro nulla. Tutto questo sui diagrammi.
- Verifica di correttezza = prendo il codice, faccio reverse engineering e ne traggo un modello, e poi guardando il modello controllo che la sua logica sia quella da me richiesta.
Possiamo anche guardare a questa distinzione tra execution-based e non-execution-based testing tramite i concetti di blackbox e whitebox.
- il blackbox testing è quello che non guarda COME è fatto il codice dentro, ma si limita a dargli in pasto degli input per osservarne il comportamento. È l'approccio dell'execution-based testing. Funziona quindi meglio con i requisiti funzionali.
- il whitebox testing invece parte dall'interno, cioè da come è fatto il codice, e da lì tenta di inferirne proprietà: è quindi l'approccio del non-execution-based testing. Si presta meglio ai requisiti non funzionali.
Il perché dei nomi blackbox e whitebox si spiega facilmente: se una cosa è black, non posso guardarci dentro. Il contrario di black è white, e quindi posso guardarci dentro. Non che il bianco sia trasparente, ma tra whitebox e transparentbox sicuramente whitebox suona meglio.
I metodi blackbox sono più fattibili di quelli whitebox. Analizzare del codice è un'operazione costosa e richiedente molto tempo. Inventare casi di test, cioè valori di input, secondo certi criteri è invece più ragionevole. Vedremo più in là quali siano questi criteri.
Altre distinzioni
C'è un altro tipo di distinzione delle tipologie di testing, in base a quale parte del software si sta guardando:
- whitebox testing = detto anche test strutturale
- blackbox testing = detto anche test funzionale
- testing statistico = si simulano le condizioni di carico reali che il mio sistema andrà ad affrontare
- testing mutazionale = è quello che ha a che fare con le mutazioni del codice durante il suo sviluppo, ad esempio i test di regressione
- object oriented testing = testo gli automi delle classi stateful
Categorie (divisione per granularità)
Nelle grandi aziene in generale i vari test sono divisi in categorie che derivano dal livello gerarchico che si va ad analizzare. Ogni categoria userà un tipo diverso di testing.
- Unit, Module e Component testing = analizzano unità, moduli e componenti di codice. Si usano il whitebox ed il blackbox testing, e li fanno direttamente i programmatori.
- Integration testing = è il test di integrazione, ovvero di corretta comunicazione tra i vari componenti. È infatti ben possibile l'eventualità che un componente, preso da solo, funzioni, ma insieme agli altri no. Anche questo lo può fare direttamente il programmatore
- System Testing = testo il sistema nella sua interezza, al fine di catturare questi aspetti:
- funzionalità
- performance
- installazione
In generale lo fa un tester (anche se spesso, come dicevamo, non esiste una figura separata), perché occorre una visione più globale del tutto.
- User Acceptance Testing = alla fine, occorre che l'utente finale, o il cliente, sia soddisfatto del prodotto: devo farglielo provare.
Ancora vocabolario
A questo punto, possiamo ampliare il nostro vocabolario con altri termini del mondo del testing.
- Test case = abbiamo capito che è un insieme di dati in input da somministrare al nostro sw
- Test suite = un insieme di casi di test
- Stub = abbozzo di un metodo. Supponiamo di voler provare la classe PincoPallo, che tuttavia necessita del metodo Ukulele() che non abbiamo ancora scritto. Ebbene: ne scriviamo un abbozzo, appunto, che magari ritorna sempre lo stesso valore, in modo che comunque riesco a far funzionare PincoPallo per il test che mi serve
- Driver = pilota per un test. La classe PincoPallo è stata progettata per essere inserita nel componente Personaggi. Tuttavia, non ho ancora scritto il componente e voglio comunque provare la classe. Scrivo pertanto un driver, che fingerà di essere il componente e farà girare PincoPallo.
Stub e Driver sono due aspetti della stessa medaglia: l'obiettivo è provare qualcosa che dovrebbe vivere in un contesto, ma al momento il contesto è incompleto. Lo stub è un completamento provvisorio verso il basso, cioè verso la granularità più fine. Il driver è un completamento provvisorio verso l'alto, cioè verso granularità più grossa.
- Test harness = è l'ambiente in cui viene eseguito un programma al fine di testarlo. È in quest'ambiente che invento i driver e gli stub
- Coverage = come dicevamo sopra, è la quantità di test che devo eseguire per coprire un certo numero di requisiti
- Adequacy = misure di efficienza e di efficacia di una test suite.
- Efficacia di una test suite = quanti bachi riesce a trovare
Nel caso della programmazione ad oggetti, si usano i mock object, che sono l'equivalente dei driver e degli stub ma orientati agli oggetti. In teoria il sito www.mockobject.com dovrebbe contenere una spiegazione della differenza tra i mock object e i driver e gli stub, ma il dominio è attualmente in vendita.
Categorie di fault
Ci sono diversi tipi di fault, ovvero diversi tipi di errori che possono essere commessi nello scrivere il codcie. Poter catalogare i fault è utile, perché sapendo dove il programmatore in genere sbaglia, so anche che tipo di test fare per far saltar fuori la magagna.
- fault algoritmici = il programma non fa quello che intendevamo quando lo scrivevamo. Casi frequenti sono i loop che vengono eseguiti su di un intervallo che non è corretto (eg da 0 a 100 invece che da 0 a 99 o robe simili), oppure errori di conversione di tipo.
- fault di computazione o precisione = dovuti all'intervallo di rappresentazione dei tipi. Magari pretendiamo troppa precisione da un float o roba del genere
- fault di requisiti o documentazione = abbiamo sbagliato a scrivere i requisiti, e abbiamo scritto codice funzionalmente sbagliato
- fault di performance = beh, si capisce
- fault di recovery = quando il sistema crasha, il file su cui lavoravamo non è più recuperabile
E quando finiscono i test?
Mai!
Dal momento che dovrei testare tutti gli input, e non posso farlo, allora in linea di principio non posso mai terminare i test.
Ci sono anche tecniche che permettono stocasticamente di predire il rischio di lasciare fault in un software, dato un programma e un caso di test non esaustivo. Ma è ancora area di ricerca, e a queste cose noi pragmatici unicremaschi preferiamo i criteri di copertura, che vedremo nelle prossime lezioni o, per chi è curioso, qui.
Valori critici e valori tipici
Cerchiamo di immaginare spazialmente lo spazio dei valori dei tipi che il mio codice accetta in input. All'interno di questo spazio, immaginiamo di definire un'area finita che rappresenta il range di valori che il mio codice si aspetta.
L'esperienza insegna che i valori critici sono i valori che stanno ai limiti di quest'area. Se il mio programma calcola fatture, che vanno da 0 a 1 milione di euro, è più probabile che ci saranno errori nel caso di 0 € e nel caso di 1.000.000 €. Anglofonicamente parlando, i valori critici sono quelli che stanno sul boundary.
Tuttavia, non possiamo accontentarci di considerare solo i valori critici. Dobbiamo sicuramente dare importanza ad essi, ma non dobbiamo affatto dimenticarci dei valori tipici, ovvero quei valori sui quali tipicamente il mio software lavorerà.
Pertanto, un qualsiasi metodo per generare casi di test dovrà generarli in modo che la maggior parte della copertura riguardi i casi critici, ma non dovrà nemmeno trascurare i casi tipici.
Torna alla pagina di Ingegneria del Software