Test e Debugging⚓︎
Bug
Errore sintattico o semantico presente in un programma.
Debug
Processo di riconoscimento e rimozione dei bug.
Attenzione
I bug sono molto frequenti, anche in programmi semplici. Il debug è un'attività difficile, che richiede un tempo imprevedibile e occorre adottare tutte le tecniche che riducano la presenza di bug e il tempo del debug.
Test e Debugging sono due attività diverse, la prima avviene dopo la scrittura del codice, la seconda invece avviene durante la scrittura dello stesso. Entrambe mirano a cercare errori nel codice.
Il debugging non viene effettuato sistematicamente, a differenza del testing.
Il testing può essere effettuato anche dopo la stesura di una funzione o altro, non per forza va effettuato dopo la stesura dell'intero codice.
Origine della parola Bug
Il \(9\) settembre \(1947\) il tenente Grace Hopper ed il suo gruppo stavano cercando la causa del malfunzionamento di un computer Mark II quando, con stupore, si accorsero che una falena si era incastrata tra i circuiti. Dopo aver rimosso l'insetto (alle ore \(15\):\(45\), il tenente incollò la falena rimossa sul registro del computer e annotò: "\(1545\). Relay \(\#70\) Panel F (moth) in relay. First actual case of bug being found". Questo registro è conservato presso lo Smithsonian National Museum of American History\(\dagger\).
Il testing è una fase di verifica sistematica della correttezza di un software. È una stima della correttezza ed è parte integrante dei processi di sviluppo del software. Il testing è parte prevalente nelle metodologie agili.
Il debugging è un processo atto a scovare la causa di un errore.
E. Dijkstra
Il testing può solo dimostrare la presenza di bug, ma non la loro assenza.
Verifica delle condizioni limite⚓︎
La maggior parte dei bug si verificano in corrispondenza dei limiti:
- Cicli: cosa succede se il numero di cicli è \(0\)?
- Array: cosa succede se si tenta di colmare un array?
- Input: cosa succede se l'input acquisito è nullo?
- Stream: cosa succede se si accede a un file inesistente, un disco pieno, una connessione interrotta, etc?
Approccio: ogni volta che si scrive un blocco di codice significativo (ciclo, condizione, input), è necessario testarne le condizioni limite.
Occorre immaginare tutte le possibili condizioni limite e documentarle.
Esempio⚓︎
Il seguente programma tenta di leggere una sequenza di caratteri da un file e
li memorizza in un array fino a quando viene letta una newline o si raggiunge
la dimensione massima MAX
maggiore di zero:
C | |
---|---|
Quali sono le condizioni limite?
- l'input è vuoto (
'\n'
); MAX == 1
;- l'input ha una lunghezza pari a
MAX
; - l'input ha una lunghezza maggiore di
MAX
; - l'input non contiene una newline (se possibile).
Riscrivendo il codice usando uno stile più leggibile, si eviterebbe l'errore del codice precedente:
C | |
---|---|
Esercizio (?)
Programmazione difensiva⚓︎
Si tratta dell'aggiunta di codice per casi "impossibili":
C | |
---|---|
Non si può assumere che i dati ricevuti in input dal programma siano corretti, il più delle volte non lo sono. in questo modo, il programmatore si cautela da qualsiasi input venga inserito.
Controllo dei valori di errore restituiti⚓︎
Se una funzione restituisce dei valori di errore questi vanno verificati dal chiamante:
C | |
---|---|
La funzione non deve preoccuparsi di scrivere all'utente che sia sbagliato, etc, essa riceve un numero e trova la sua categoria. Il resto lo gestisce la funzione chiamante, altrimenti la funzione chiamante farà il suo normale lavoro.
È possibile che la funzione chiamante non gestisca direttamente l'errore ma restituisce un valore specifico e lo gestirà un'altra funzione ancora.
Una funzione dovrebbe avere un solo compito e un solo output, così se sbaglia è più facile capire chi ha sbagliato e dove, rispetto all'avere più compiti per la stessa funzione.
Testing sistematico⚓︎
Verifica incrementale⚓︎
In questa modalità il testing va di pari passo con l'implementazione. Il test viene effetuato su unità elementari, ovvero:
- una procedura o funzione;
- un blocco significativo di procedura.
Verifica bottom-up⚓︎
si tratta di testare prima le parti:
- (componenti/casi) più semplici
- più frequentemente utilizzate
Ricerca binaria
- in un array vuoto;
- in un array con un solo elemento;
- di un elemento minore di quello presente;
- di un elemento uguale a quello presente;
- di un elemento maggiore di quello presente:
- in un array con due elementi:
- \(5\) combinazioni possibili.
- in presenza di elementi ripetuti;
- in presenza di elementi contigui e non contigui.
Verifica dei risultati attesi⚓︎
Non è prevista la conoscenza dell'algoritmo.
Per ogni test, occorre conoscere il risultato atteso. Questo è ovvio per molti casi, ma non per tutti.
Ad esempio, per testare un compilatore si può compilare uno o più programmi formalmente corretti e testarli.
Per testare un programma di calcolo numerico, è possibile
- verificare i limiti dell'algoritmo;
- verificare proprietà note;
- testare problemi con risultati già noti;
- analisi statistiche.
Mentre per testare un programma grafico/multimediale:
- uso di strumenti di image editing;
- analisi statistiche.
Un esempio di testing di una funzione è il seguente:
stateDiagram-v2
direction LR
fx: f(x)
[*] --> fx: 5
[*] --> fx: -4
fx --> [*]: errore
oppure
stateDiagram-v2
direction LR
fx: f(x)
[*] --> fx: 7
[*] --> fx: 5
fx --> [*]: 12
Verificare la copertura dei test⚓︎
Il test di copertura è un po' più complicato poiché non si basa più soltanto sulla conoscienza dei dati di input.
I test devono garantire che ogni istruzione sia eseguita almeno una volta, questo equivale a testare:
- i rami
then..else
- tutti i case di una
switch
- che l'esecuzione dei cicli avvenga:
- \(0\), \(1\), \(n\), \(n - 1\) volte, con \(n\) massimo.
Nel caso di if
i rami sono due, all'aggiunta di un altro if
,
raddoppiano. Nel caso di un ciclo aumentano di molto.
Classi di equivalenza, ad esempio, se \(n \in ]\min,\; \max[\) allora bisogna verificare cosa sucede nei seguenti casi
L'analisi del codice può aiutare a individuare gli input che consentono di coprire tutto il codice. La scelta deve essere tale da far attraversare al dato un ramo specifico, in modo tale da verificare ogni possibilità.
Non è inoltre detto che ogni funzione debba essere testata con ogni tipo di test. Ad esempio, se non è presente molta elaborazione sarebbe meglio utilizzare il test di copertura. Raramente si hanno funzioni con sia molti input che molta elaborazione.
Procedure e Funzioni in C⚓︎
Sono istruzioni non primitive per risolvere parti specifiche di un problema:
i sottoprogrammi (o metodi). Sono realizzate mediante la definizione di
unità di programma (sottoprogrammi) distinte dal programma principale (main
).
Rappresentano nuove istruzioni/operatori che agiscono sui dati utilizzati dal
programma. Sono definite a partire da una sequenza di istruzioni primitive e
altre procedure/funzioni.
Tutti i linguaggi di alto livello offrono le possibilità di utilizzare funzioni e procedure mediante:
- costrutti per la definizione di sottoprogrammi;
- meccanismi per la chiamata a sottoprogrammi.
Nei linguaggi ad alto livello funzioni e procedure sono molto utili per raggiungere:
- Astrazione;
- Riusabilità;
- Modularità (strutturazione);
- Leggibilità.
Nella fase di definizione di una funzione o procedura si stabilisce:
- un identificatore del sottoprogramma (cioè il nome da usare per chiamare/invocare lo stesso);
- un corpo del sottoprogramma (cioè, l'insieme di istruzioni che sarà eseguito ogni volta che il sottoprogramma sarà chiamato);
- una lista di parametri formali (cioè come avviene la comunicazione tra l'unità di programma che usa il sottoprogramma ed il sottoprogramma stesso).
C | |
---|---|
- operatore non primitivo;
- permette di definire nuovi operatori complessi da affiancare a quelli primitivi;
- restituisce un valore di ritorno (mediante
return
).
C | |
---|---|
- istruzione non primitiva;
- è attivabile in un qualunque punto del programma in cui può comparire un'istruzione;
- non restituisce un valore di ritorno (mediante return).
I parametri costituiscono il mezzo di comunicazione tra unità chiamante e unità chiamata, supportano lo scambio di informazioni tra chiamante e sottoprogramma.
Si differenziano in:
- Parametri formali (specificati nella definizione);
- Parametri attuali (specificati nella chiamata).
Parametri attuali e formali devono corrispondersi in numero, posizione e tipo.
Sono quelli specificati nella definizione del sottoprogramma. Sono in numero prefissato e a ognuno di essi viene associato un tipo. Le istruzioni del corpo del sottoprogramma utilizzano i parametri formali.
Sono i valori effettivamente forniti dall'unità chiamante al sottoprogramma all'atto della invocazione.
Il passaggio di parametri può avvenire in due modi:
- per valore;
- per indirizzo (o riferimento).
Il C di default adotta il passaggio per valore:
- il valore dei parametri è copiato nello stack;
- il passaggio per riferimento si ottiene memorizzando nello stack l'indirizzo (puntatore) in cui è allocata una variabile;
- il passaggio per valore è anche più "sicuro".
Unit test⚓︎
È una tecnica di progetto e sviluppo del software e serve a ottenere evidenza che le singole unità software sviluppate siano corrette e pronte all'uso. In un linguaggio procedurale come il C una unità può essere un programma, una funzione, ecc.
Per effettuarlo si scrivono degli unit test (o casi di test) rappresentanti una sorta di "contratto scritto" che la porzione di codice testata deve soddisfare.
xUnit test framework⚓︎
In principio è stato creato JUnit per Java, da Kent Beck e Erich Gamma.
M. Fowler
Never in the field of software development have so many owed so much to so few lines of code.
È stato portato verso innumerevoli altri linguaggi (C/C++, C#, PHP, Python, JavaScript, Ruby, etc) dando vita all'ecosistema dei framework di tipo xUnit
Ha dato vita al Test-Driven Development (TDD, sviluppo guidato dal test).
CUnit⚓︎
È un framework di unit test per il linguaggio C la cui home page è la seguente http://cunit.sourceforge.net/index.html.
È una libreria che va inclusa in ogni progetto Eclipse CDT di chiunque intenda avvalersene. La guida di installazione è disponibile qui: http://collab.di.uniba.it/fabio/guide/
Struttura del framework⚓︎
Il framework esegue automaticamente tutte le test suite inserite nel test registry. Ogni test suite è composta da uno o più test method logicamente correlati (es. suite per testare tutti i metodi di un particolare modulo).
flowchart TD
testRegistry(Test Registry) --> suite1(Suite 1) & suiten(Suite n);
suite1 --> test1[Test 1] & testm[Test m];
suiten --> testn1[Test 1,n] & testnm[Test n,m];
Method under test⚓︎
Il programma da testare è costituito da diversi file .c
(detti moduli)
contenenti diverse funzioni e/o procedure (es. func_1()
, func_n()
o
proc_n()
, proc_m()
). Queste funzioni e procedure sono detti
methods under test. Per ciascun metodo da testare occorre scrivere almeno
un test method. Ciascun metodo di test va chiamato test_xyz()
(es.
test_funz_1()
, test_funz_n()
, o test_proc_1()
,
test_proc_m()
).
Un metodo di test verifica la presenza di errori nel corrispettivo metodo sotto test. Con errore si intende un comportamento diverso da quello atteso.
Attenzione
L'ordine di inserimento ha importanza! Le test suite sono eseguite nello stesso ordine di inserimento nel registry mentre i test method sono eseguiti nello stesso ordine di inserimento nella suite.
Ciclo di unit test⚓︎
Sequenza tipica di uso di un framework di unit test, incluso CUnit:
- Scrivi tutti i test method necessari;
- Crea il test registry;
- Crea la test suite e aggiungila al test registry;
- Aggiungi i test method alle test suite definite;
- Se necessario, ripeti i passi 3-4 per un'altra suite;
- Esegui il test registry;
- Pulisci il test registry.
Aggiungere le librerie CUnit a un progetto⚓︎
Guida di installazione e configurazione disponibile alla sezione dispense sul sito del corso: http://collab.di.uniba.it/fabio/guide/.
Quando si prepara il modulo con i metodi di test ricordate di includere i file header in questo modo:
Scrivere un test method⚓︎
Un metodo di test in CUnit si presenta sempre nella forma di procedura senza
parametri, ad esempio void test_xyz(void)
. Un metodo di test è un
contratto che stabilisce i vincoli che devono essere soddisfatti dal software:
- i vincoli sono stabiliti attraverso delle asserzioni;
- Un'asserzione in un linguaggio di programmazione è una funzione che verifica
una condizione logica e restituisce:
- Vero, se l'asserzione è rispettata;
- Falso, altrimenti.
Asserzioni di base (CUnit)⚓︎
Asserzione | Significato |
---|---|
CU_ASSERT(int espressione) CU_TEST(int espressione) |
Asserisce che espressione è TRUE (diverso da 0) |
CU_ASSERT_TRUE(valore) |
Asserisce che valore è TRUE (diverso da 0) |
CU_ASSERT_FALSE(valore) |
Asserisce che valore è FALSE (uguale a 0) |
CU_ASSERT_EQUAL(reale, atteso) |
Asserisce che reale == atteso |
CU_ASSERT_NOT_EQUAL(reale, atteso) |
Asserisce che reale != atteso |
CU_ASSERT_STRING_EQUAL(reale, atteso) |
Asserisce che le stringhe reale e atteso coincidono |
CU_ASSERT_STRING_NOT_EQUAL(reale, atteso) |
Asserisce che le stringhe reale e atteso differiscono |
Esempio di test method per la funzione max(a,b)
⚓︎
C | |
---|---|
Esempio di metodo di test per la funzione factorial(x)
⚓︎
C | |
---|---|
Il Test Registry⚓︎
Raccoglie tutte le test suite. Quando si esegue un Test Registry si eseguono tutte le suite al suo interno e, di conseguenza, tutti i test method all'interno delle suite.
flowchart TD
testRegistry(Test Registry) --> suite1(Suite 1) & suiten(Suite n);
suite1 --> test1[Test 1] & testm[Test m];
suiten --> testn1[Test 1,n] & testnm[Test n,m];
Inizializzazione del Test Registry⚓︎
L'inizializzazione del test registry è la prima operazione da effettuare:
C | |
---|---|
Test Suite⚓︎
Una test suite è definita da:
- una descrizione testuale;
- una procedura di inizializzazione (init);
- una procedura di pulitura (clean).
Le test suite definite vengono aggiunte al test registry (l'ordine è rilevante!)
C | |
---|---|
Di default inizializzazione e pulitura sono procedure vuote.
Inizializzazione e pulizia delle suite⚓︎
Le test suite devono essere inizializzate e ripulite prima e dopo l'uso. I metodi non sono forniti da CUnit ma devono essere scritti dal programmatore.
Perché?
Perché devono liberare le risorse allocate specificatamente per eseguire il caso di test (es. file, connessioni, etc.).
Inizializzazione e pulizia⚓︎
C | |
---|---|
Test method e test suite⚓︎
Un test method viene aggiunto ad una test suite specificando:
- il puntatore alla suite
- una descrizione testuale del test
- il puntatore al test method
C | |
---|---|
L'ordine dei test nelle suite è rilevante!
Registrazione ed esecuzione dei test⚓︎
La procedura CU_basic_run_tests()
esegue tutte le suite del registry e mostra
i risultati.
C | |
---|---|
È possibile impostare il livello di "verbosità" dell'output.
Pulire il registry⚓︎
Pulizia – dopo aver eseguito tutti i test nel registro. Procedura
void CU_cleanup_registry(void)
.
C | |
---|---|
Il main()
termina con la return dell'eventuale codice di errore di CUnit.
Esercitazione 1 CUnit⚓︎
Link all'esercitazione: http://goo.gl/VYfhsN
Implementazione di una serie di funzioni e testing di queste con l'utilizzo di CUnit template di CUnit disponibile a http://goo.gl/uevMHu.
Debugging⚓︎
Supporto del compilatore⚓︎
Molti compilatori emettono dei "warning", cioé dei messaggi di avvertimento:
if (a = 0)
;x = x
;- nessun return;
- codice orfano;
- condizioni tautologiche.
Backward reasoning⚓︎
Quando si scopre un bug, occorre "pensare al contrario". Partendo dal risultato, occorre risalire alla catena delle cause che lo hanno portato. Una delle cause della catena sarà errata.
Scrivere codice leggibile aiuta al backward reasoning e, quindi, a localizzare i bug.
Pattern familiari⚓︎
Riconoscere variazioni rispetto a "modelli" (pattern) di codice familiari.
L'uso di un corretto stile di programmazione aiuta a ridurre la presenza di bug.
Sviluppo incrementale⚓︎
Testare le procedure man mano che vengono sviluppate. Se i test all'istante \(t\) hanno successo ma falliscono all'istante \(t + 1\), allora molto probabilmente i bug si annidano nel codice sviluppato tra \(t\) e \(t + 1\).
La progettazione modulare del codice aiuta a individuare meglio la posizione dei bug.
Esaminare codice simile⚓︎
Se un bug è presente in una porzione di codice, allora è probabile che se ne annidi un altro in un codice simile. Problema del "copy-and-paste". Una buona progettazione del codice riduce la ridondanza e, quindi, la possibilità di bug duplicati
Non rimandare il debugging⚓︎
Se un bug è individuato, va eliminato subito. Il trasferimento di un bug nei passi successivi del ciclo di sviluppo di un software fa crescere il costo del debugging in termini esponenziali.
D. Wilner, 1997 IEEE Real-Time Systems Symposium
The Mars Pathfinder mission was widely proclaimed as "flawless" in the early days after its July \(4\)th, \(1997\) landing on the Martian surface. [...] But a few days into the mission, not long after Pathfinder started gathering meteorological data, the spacecraft began experiencing total system resets, each resulting in losses of data.
Leggere e spiegare il codice⚓︎
Leggere il codice e comprenderne il significato. Il codice è un pezzo di conoscenza che deve essere compreso dalla macchina e da chi la programma. La leggibilità del codice è fondamentale.
Spiegare ad altri il codice aiuta a ridurre "bias" cognitivi.
Rendere riproducibile un bug⚓︎
Individuare tutte le condizioni che portano alla manifestazione di un bug
- Input e altri parametri;
- Condizioni della macchina;
- Seed di numeri casuali.
Divide et impera⚓︎
Individuare le condizioni minimali che rendono manifesto un bug (es. il più piccolo array, la stringa più breve). Il test dei casi limite è fondamentale.
Le condizioni minimali possono facilitare la localizzazione di un bug. Se il bug non si manifesta in un caso limite, provare mediante dimezzamenti successivi dell'input. Ricerca binaria sulla lunghezza dell'input.
Ricerca di regolarità⚓︎
Alcuni bug si presentano con regolarità, ma non sempre. In questo caso, occorre capire il modello (pattern) che genera la regolarità.
Ad esempio un editor di testi salta la visualizzazione di alcuni caratteri,
l'analisi del testo mostra che i caratteri saltati sono sempre intervallati da
\(1023\) caratteri stampati. Regolarità: un carattere saltato ogni \(1023\).
L'analisi del codice rivela che gli array che memorizzano le stringhe sono da
\(1024 \byte\) \(1023\) caratteri + ‘\0'
portano al bug.
Stampe ausiliarie⚓︎
Per seguire l'esecuzione di un programma può essere utile introdurre stampe ausiliarie. Valido soprattutto per situazioni che non possono essere tracciate da un debugger (es. sistemi distribuiti, programmi paralleli, etc.).
Le stampe ausiliarie devono necessariamente essere eliminate dopo aver scovato il bug. Rischio di violazione delle specifiche. Possono essere commentate anziché eliminate.
Per situazioni complesse, si possono usare strumenti di logging.
Altre tecniche⚓︎
Esse sono:
- visualizzazioni grafiche;
- test statistici;
- strumenti di analisi di testo, come:
- grep;
- diff;
- etc.
Debugger⚓︎
Un debugger guarda "dentro" il programma durante l'esecuzione:
- tracing del programma;
- visualizzazione del contenuto delle variabili;
- valutazione dinamica di espressioni;
- breakpoint, anche condizionali;
- stack trace.
Sono strumenti molto sofisticati, abituarsi al loro uso può migliorare significativamente la produttività nella programmazione.
Compilazione per il debug⚓︎
Un debugger ha bisogno di informazioni aggiuntive nel codice compilato, link tra il codice compilato e il codice sorgente.
Per stabilire la corrispondenza tra codice compilato e codice sorgente, la compilazione per il debug non deve essere ottimizzata.
Due modalità di compilazione:
- Debug, meno efficiente, per il debug;
- Release, Ottimizzata.
Esecuzione passo-passo⚓︎
Il debugger consente di eseguire il programma un'istruzione alla volta. Al termine dell'esecuzione di una istruzione, il controllo passa al debugger, che può visualizzare lo stato della macchina (variabili, stack, etc.).
Per velocizzare il processo di debugging, si può optare per eseguire il programma fino a un'istruzione specifica, segnalata da un breakpoint.
Nome | Descrizione |
---|---|
Step into | Esegue l'istruzione corrente, e procede all'istruzione successiva che sarà effettivamente eseguita |
Step over | Esegue l'istruzione corrente, trattando le routine come istruzioni primitive |
Step return | continua l'esecuzione fino al termine della procedura |
Step Into⚓︎
Step into | |
---|---|
Step over⚓︎
Step return⚓︎
Passo iniziale | |
---|---|
Informazioni di debug⚓︎
Variabili⚓︎
Le variabili visibili nell'ambito dell'istruzione corrente sono visualizzate:
- Nome, tipo, valore.
Le variabili che cambiano valore sono evidenziate.
Espressioni⚓︎
Un'espressione è un pezzo ben formato di codice (snippet) che può essere valutato per produrre un risultato.
Stack trace⚓︎
Visualizza la pila delle chiamate, Si può selezionare un elemento della pila per conoscerne lo stato corrente.
Breakpoint⚓︎
Un breakpoint interrompe il flusso di esecuzione su una linea selezionata
- i breakpoint possono essere inseriti o rimossi;
- i breakpoint inseriti possono essere attivati o disattivati.
Resume & Terminate⚓︎
- Resume: esegue le istruzioni fino al prossimo breakpoint oppure al termine del programma;
- Terminate: interrompe l'esecuzione del programma. Utile quando:
- il programma va in loop infinito;
- si scova un programma.
Attenzione: i programmi non terminati rimangono in esecuzione per il sistema operativo:
- occupazione inutile di memoria;
- problemi per la ricompilazione.
Breakpoint condizionali⚓︎
I breakpoint possono interrompere l'esecuzione solo quando una condizione diventa vera. Condizione: espressione booleana.