Testing strutturale

La maggior parte dei problemi che si verificano durante lo sviluppo di un progetto sono causati da problemi di comunicazione. Ci possono essere incomprensioni quando le informazioni passano da una figura all’altra, come quando ci si interfaccia tra cliente, analista e programmatore. Il programmatore dovrà adattare il proprio linguaggio per farsi comprendere dal cliente prestando maggiore attenzione alla formalità e alla chiarezza della comunicazione con il passare del tempo. Più i concetti sono spiegati chiaramente, più è difficile incorrere in problemi successivi: è quindi necessario fare attenzione alla terminologia utilizzata.

Partiamo quindi dalle basi: quando un programma si definisce corretto?

Considerando un generico programma \(P\) come una funzione da un insieme di dati \(D\) (dominio) a un insieme di dati \(R\) (codominio) allora:

  • \(P(d)\) indica l’esecuzione di \(P\) su un certo input \(d \in D\),
  • il risultato \(P(d)\) è corretto se soddisfa le specifiche, altrimenti è scorretto,
  • \(\operatorname{ok}(P, \, d)\) indica la correttezza di \(P\) per il dato \(d\)

quindi

\[\boxed{P \text{ è } \textit{corretto} \Longleftrightarrow \forall d \in D \:, \text{ } \operatorname{ok}(P, \, d)}\]

A parole, un programma è corretto quando per ogni dato del dominio vale \(\operatorname{ok}(P, \, d)\).

Per indicare la correttezza di programma \(P\) si utilizza la notazione \(\operatorname{ok}(P, \, D)\), che appunto indica che \(P\) è corretto per qualunque \(d \in D\).

Definizione di test

Durante l’attività di testing ciò che viene fatto è sottoporre il programma a una serie di stimolazioni per saggiarne il comportamento in tali circostanze. Eseguire un test vuole quindi dire eseguire il programma con una serie di input appartenenti al suo dominio e confrontare i risultati ottenuti con il risultato atteso secondo le specifiche.

Volendone dare una definizione più rigorosa, un test è un sottoinsieme del dominio dei dati e un singolo caso di test è un elemento di esso. Un test sono quindi più stimolazioni, mentre un caso di test è una singola stimolazione.
Matematicamente:

  • un test \(T\) per un programma \(P\) è un sottoinsieme del suo dominio \(D\);
  • un elemento \(t\) di un test \(T\) è detto caso di test;
  • l’esecuzione di un test consiste nell’esecuzione del programma \(\forall t \in T \subseteq D\).

Un programma \(P\) supera (o passa) un test \(T\) se:

\[\operatorname{ok}(P, \, T) \Longleftrightarrow \forall t \in T \:, \text{ } \operatorname{ok}(P, \, t)\]

Quindi, un programma è corretto per un test quando per ogni caso di test esso è corretto.

Lo scopo dei test è però ricercare comportamenti anomali nel programma per permetterci di correggerli. Diciamo quindi che un test \(\, T\) ha successo se rileva uno o più malfunzionamenti presenti nel programma \(P\):

\[\operatorname{successo}(T, \, P) \Longleftrightarrow \exists t \in T \: | \: \lnot \operatorname{ok}(P, \, t)\]

Test ideale

Se un test non rileva alcun malfunzionamento non significa che il programma sia corretto: come visto nella lezione precedente, il test è un’attività ottimistica e normalmente il passaggio di un test non garantisce l’assenza di anomalie. Questo smette però di essere vero nel caso di test ideali.

Un test \(T\) si definisce ideale per \(P\) se e solo se

\[\operatorname{ok}(P, \, T) \Rightarrow \operatorname{ok}(P, \, D)\]

ovvero se il superamento del test implica la correttezza del programma.

Purtroppo però in generale è impossibile trovare un test ideale, come ci suggerisce la seguente ipotesi universalmente accettata:

Tesi di Dijkstra:

Il test di un programma può rilevare la presenza di malfunzionamenti ma non dimostrarne l’assenza.

Non esiste quindi un algoritmo che dato un programma arbitrario \(P\) generi un test ideale finito
(il caso \(T = D\) non va considerato).

Notiamo come la tesi escluda esplicitamente il test esaustivo \(T = D\), restringendosi a considerare i test finiti (mentre il dominio \(D\) potrebbe anche essere infinito). Per capire il perché di questa distinzione è sufficiente osservare il seguente esempio:

static int sum(int a, int b) {
    return a + b;
}

In Java un int è espresso su 32 bit, quindi il dominio di questa semplice funzione somma ha cardinalità \(2^{32} \cdot 2^{32} = 2^{64} \sim 2 \cdot 10^{19}\). Considerando quindi un tempo di esecuzione ottimistico di 1 nanosecondo per ogni caso di test, un test esaustivo che provi tutte le possibili combinazioni di interi impiegherebbe più di 600 anni per essere eseguito per intero.

Il test esaustivo è quindi impraticabile.

Criteri di selezione

Assodato che un test ideale è impossibile da realizzare, come possiamo scegliere un sottoinsieme del dominio che approssimi il più possibile un test ideale?
Esistono una serie di criteri di selezione che hanno proprio lo scopo di guidare la selezione dei casi di test all’interno del dominio in modo da massimizzare la probabilità che il test abbia successo. Prima però di illustrarne alcuni, vediamo quali caratteristiche dovrebbero avere questi criteri.

Proprietà

Affidabilità

Un criterio di selezione \(C\) si dice affidabile se presi due test \(T_1\) e \(T_2\) in base al criterio allora
o entrambi hanno successo o nessuno dei due ha successo
.

\[\boxed{ \operatorname{affidabile}(C, \, P) \Longleftrightarrow \left ( \forall T_1 \in C, \, \forall T_2 \in C \:, \text{ } \operatorname{successo}(T_1, \, P) \Leftrightarrow \operatorname{successo}(T_2, \, P) \right ) }\]

Validità

Un criterio di selezione si dice valido se qualora \(P\) non sia corretto, allora esiste almeno un test \(T\) selezionato in base al criterio \(C\) che ha successo e quindi rileva uno o più malfunzionamenti per il programma \(P\):

\[\boxed{ \operatorname{valido}(C, \, P) \Longleftrightarrow \left ( \lnot \operatorname{ok}(P, \, D) \Rightarrow \exists T \in C \: | \operatorname{successo}(T,\,P) \right ) }\]

Esempio

Si consideri il seguente codice.

static int raddoppia(int par) {
    int risultato;
    risultato = (par * par);
    return risultato;
}

Un criterio che seleziona:

  • “i sottoinsiemi di \(\{0, \, 2\}\)” è affidabile, perché il programma funziona sia con \(0\) sia con \(2\), ma non valido, perché sappiamo che il programma non è corretto e non esiste un test che trovi malfunzionamenti;
  • “i sottoinsiemi di \(\{0, \, 1, \, 2, \, 3, \, 4\}\)” è non affidabile, perché i risultati dei casi di test non sono tutti coerenti (e quindi il test \(T1=\{0,1\}\) non ha successo mentre \(T2=\{0, 3\}\) sì), ma valido perché esiste un test che rileva i malfunzionamenti.
  • “i sottoinsieme finiti di \(D\) con almeno un valore maggiore di \(18\)” è affidabile, perché i risultati dei casi di test sono tutti coerenti, e valido perché rileva i malfunzionamenti.

In questo caso la ricerca di un criterio valido e affidabile era semplice perché conoscevamo già l’anomalia. Tuttavia, lo stesso non si può dire di un qualunque programma \(P\) in quanto non si conoscono i malfunzionamenti a priori e dunque è molto più difficile trovare criteri validi e affidabili.

Conclusione

L’obiettivo sarebbe quindi quello di trovare un criterio valido e affidabile sempre. Tuttavia ciò è purtroppo impossibile in quanto un criterio di questo tipo selezionerebbe test ideali, che sappiamo non esistere.

Immaginiamo infatti di avere un criterio valido e affidabile e che un test selezionato da esso non abbia successo. Sapendo che:

  • non avendo successo allora non sono stati trovati errori,
  • essendo il criterio affidabile allora tutti gli altri test selezionati da quel criterio non troveranno errori,
  • essendo il criterio valido allora se ci fosse stato un errore almeno uno dei test lo avrebbe trovato

allora il programma è corretto, ovvero abbiamo trovato un test che quando non ha successo implica la correttezza del programma: in poche parole, un test ideale. Esiste quindi un altro modo per implicare la correttezza di un programma:

\[\boxed{ \operatorname{affidabile}(C, \, P) \land \operatorname{valido}(C, \, P) \land T \in C \land \lnot\operatorname{successo}(T, \, P) \Longrightarrow \operatorname{ok}(P, \, D) }\]

In conclusione, trovare un criterio che sia contemporaneamente affidabile e valido significherebbe trovare un criterio che selezioni test ideali che sappiamo non esistere per la tesi di Dijkstra. Dovremo dunque accontentarci di criteri che garantiscano solo una delle due caratteristiche.

Utilità di un test

Abbandonata la vana speranza di un criterio di selezione universalmente valido che permetta di testare alla perfezione qualunque programma vediamo ora cosa significa utilizzare un criterio di selezione per costruire un test. Come sappiamo un test altro non è che un insieme di casi di test, specifici input appartenenti al dominio del programma: un criterio di selezione governa dunque quanti e quali casi di test vengono aggiunti al test che si sta costruendo.

Possiamo quindi ora farci una domanda: quali sono le caratteristiche che rendono utile un caso di test, ovvero che rendono “possibile” o “probabile” che il caso di test evidenzi un malfunzionamento causato da un’anomalia? Ebbene, un caso di test utile deve:

  • eseguire il comando che contiene l’anomalia – non è altrimenti possibile che il malfunzionamento si manifesti;
  • l’esecuzione del comando che contiene l’anomalia deve portare il sistema in uno stato scorretto, o per meglio dire inconsistente;
  • lo stato inconsistente deve propagarsi fino all’uscita del codice in esame in modo da produrre un output diverso da quello atteso;

Un buon criterio di selezione dovrà quindi selezionare test contenenti casi di test utili: ma quanti dovrebbe contenerne? Per capire ciò si può utilizzare un metro di misura legato alle caratteristiche del codice: a ogni criterio è infatti possibile associare una metrica che misuri la copertura del test che si sta costruendo e che ci permetta di decidere quando smettere di aggiungere casi di test, quali casi di test è opportuno aggiungere e di confrontare la bontà di test diversi. Aggiungeremo infatti solo casi di test che permettano di aumentare la metrica di copertura, e test in grado di garantire una copertura maggiore saranno inerentemente migliori di test con una copertura minore.

Criteri noti

Esploriamo quindi ora una serie di criteri di selezione, elencandone pro e contro, esplicitandone la metrica di copertura utilizzata e infine confrontandoli tra di loro per comprenderne le relazioni.

Criterio di copertura dei comandi

Un test \(\ T\) soddisfa il criterio di copertura dei comandi se e solo se ogni comando eseguibile del programma è eseguito in corrispondenza di almeno un caso di test \(t \in T\).
La metrica è dunque la frazione di comandi eseguibili su quelli eseguiti dall’intero test.

Consideriamo per esempio il seguente programma in pseudocodice:

Esempio 1: copertura dei comandi
Pseudocodice Diagramma di flusso di esecuzione
01  void main(){
02      float x, y;
03      read(x);
04      read(y);
05      if (x != 0)
06          x = x + 10;
07      y = y / x;
08      write(x);
09      write(y);
10  }

È possibile ricostruire un diagramma di flusso di esecuzione del codice trasformando ogni comando in un nodo del diagramma: coprire tutti i comandi significa quindi visitare tutti i nodi raggiungibili.

Applicare il criterio di copertura dei comandi significa quindi trovare un insieme di casi di test in cui per ogni nodo esiste un caso di test che lo attraversa.

Nel nostro esempio il singolo caso di test \(\langle 3, \, 7 \rangle\) risulterebbe quindi sufficiente, dato che la sua esecuzione permette di coprire tutti i comandi del programma. Tuttavia, pur massimizzando la metrica di copertura dei comandi tale test non è in grado di rilevare l’anomalia alla riga 7, in cui viene fatta una divisione senza prima controllare che il divisore sia diverso da zero.

Soddisfare il criterio di copertura dei comando non garantisce dunque la correttezza del programma. Come sappiamo infatti un’anomalia non sempre genera un malfunzionamento, per cui eseguire semplicemente tutte le righe di codice raggiungibili non assicura di rilevare eventuali errori.

Criterio di copertura delle decisioni

Un test \(\ T\) soddisfa il criterio di copertura delle decisioni se e solo se ogni decisione (effettiva) viene resa sia vera che falsa in corrispondenza di almeno un caso di test \(t \in T\).
La metrica è quindi la frazione delle decisioni totali possibili presenti nel codice che sono state rese sia vere che false nel test.

Dovendo attraversare ogni possibile flusso di controllo il criterio di copertura delle decisioni implica il criterio di copertura dei comandi. Estraendo il codice in un diagramma di flusso, infatti, è possibile coprire tutte le decisioni se e solo se ogni arco (e quindi ogni nodo) viene attraversato. Non è invece vero l’inverso.

Esempio 2: copertura delle decisioni
Pseudocodice Diagramma di flusso di esecuzione
01  void main(){
02      float x, y;
03      read(x);
04      read(y);
05      if (x != 0 && y > 0)
06          x = x + 10;
07      else
08          y = y / x
09      write(x);
10      write(y);
11  }

Dall’esempio sopra, un test che soddisfi il suddetto criterio potrebbe includere \(\{ \langle 3, \, 7 \rangle, \, \langle 3, \, -2 \rangle \}\). Nonostante sia un criterio “migliore” del precedente, la copertura delle decisioni non garantisce la correttezza del programma: nell’esempio il caso \(\langle 0, \, 5 \rangle\) eseguirebbe comunque una divisione per zero.

Criterio di copertura delle condizioni

Un test \(\ T\) soddisfa il criterio di copertura delle condizioni se e solo se ogni singola condizione (effettiva) viene resa sia vera che falsa in corrispondenza di almeno un caso di test \(\ t \in T\).
Similmente ai criteri precedenti, la metrica è quindi la percentuale delle condizioni che sono state rese sia vere che false su quelle per cui è possibile farlo.

Sebbene simile, si tratta di un criterio diverso da quello di copertura delle decisioni: in caso di condizioni composte, come per esempio x != 0 && y < 3, la copertura delle decisioni imporrebbe che l’intera condizione sia resa sia vera che falsa, mentre la copertura delle condizioni richiede di rendere vere e false le singole condizioni atomiche x != 0 e y < 3 in almeno un caso di test.
Come vedremo nell’esempio, ciò non impone quindi di seguire tutti i percorsi sul diagramma di flusso e fa sì che questo criterio non implica il soddisfacimento di nessuno dei precedenti.

Esempio 3: copertura delle condizioni
Pseudocodice Diagramma di flusso di esecuzione
01  void main(){
02      float x, y;
03      read(x);
04      read(y);
05      if (x != 0 || y > 0)
06          y = y / x;
07      else
08          y = (y + 2) / x
09      y = y / x;
10      write(x);
11      write(y);
12  }

Nell’esempio sopra, il test \(\{ \langle 0, \, 5 \rangle , \, \langle 5, \, -5 \rangle \}\) soddisfa il criterio di copertura della condizioni
(x != 0 è falsificato da \(\langle 0, \,5 \rangle\) e verificato da \(\langle 5, \, -5 \rangle\), mentre y > 0 è verificato da \(\langle 0, \, 5 \rangle\) e falsificato da \(\langle 5, \, -5 \rangle\)), ma la decisione è sempre vera.

Sono infatti presenti anomalie alla riga 6 (possibile divisione per zero) e alla riga 8 (overflow e divisione per zero), ma i comandi contenuti nella riga 8 non sono coperti. In questo caso più che mai, quindi, la copertura delle condizioni non garantisce la correttezza del programma.

Criterio di copertura delle decisioni e condizioni

Un test \(\ T\) soddisfa il criterio di copertura delle decisioni e delle condizioni se e solo se ogni decisione vale sia vero che falso e ogni condizione che compare nelle decisioni del programma vale sia vero che falso per diversi casi di test \(\ t \in T\).

È – intuitivamente – l’intersezione del criterio di copertura delle decisioni con il criterio di copertura delle condizioni, per cui il soddisfacimento di questo criterio implica sia il criterio di copertura delle condizioni che quello di copertura delle decisioni (e quindi dei comandi).

Nell’esempio 3, il test \(\{ \langle 0, \, -5 \rangle, \, \langle 5, \, 5 \rangle \}\) soddisfa il criterio di copertura delle decisioni e condizioni e rileva l’anomalia alla riga 8 ma non quella alla riga 6. Non garantisce quindi neanche in questo caso la correttezza del programma.

Criterio di copertura delle condizioni composte

Un test \(\ T\) soddisfa il criterio di copertura delle condizioni composte se e solo se ogni possibile composizione delle condizioni base vale sia vero che falso per diversi casi di test \(\ t \in T\).

Viene cioè testata ogni possibile combinazione di valori delle condizioni atomiche quando queste sono aggregate in condizioni composte: riprendendo per esempio la condizione x != 0 && y < 3, vengono testati separatamente i casi \(\langle V, V\rangle\), \(\langle V, F\rangle\), \(\langle F, V\rangle\) e \(\langle F, F\rangle\).
È quindi facile notare che questo criterio implica il precedente (criterio di copertura delle decisioni e condizioni), implicando a sua volta il criterio di copertura delle decisioni, delle condizioni e dei comandi.

Data la natura combinatoria di questo criterio, all’aumento del numero di condizioni di base il numero di casi di test cresce però troppo rapidamente, motivo per cui il soddisfacimento di questo criterio è considerato non applicabile in pratica. Inoltre, dato che le condizioni di base potrebbero non risultare indipendenti tra loro, potrebbero esistere combinazioni non fattibili che non avrebbe alcun senso testare.

Criterio di copertura delle condizioni e delle decisioni modificate

Non volendo testare tutte le combinazioni di condizioni, ci si rende presto conto che certe combinazioni sono più rilevanti di altre: se modificando una sola condizione atomica si riesce a modificare l’esito della decisione, allora è molto significativa – indipendentemente dalla sua dimensione. Se invece l’esito della decisione non varia, allora la modifica può essere considerata neutra o meno significativa.
Il criterio così ottenuto prende il nome di criterio di copertura delle condizioni e delle decisioni modificate.

Si dà quindi importanza nella selezione delle combinazioni al fatto che la modifica di una singola condizione base porti a modificare l’esito della decisione. Per ogni condizione base devono quindi esistere due casi di test che modificano il valore di una sola condizione base e che portino a un diverso esito della decisione: in questo modo, inoltre, il criterio implica quello di copertura delle condizioni e delle decisioni.

Si può dimostrare che se si hanno \(N\) condizioni base sono sufficienti \(N+1\) casi di test per soddisfare questo criterio, decisamente meno di quelli richiesti dal criterio delle condizioni composte.

Implicazioni tra criteri di copertura

Ecco dunque uno schema delle implicazioni tra i vari criteri di copertura. Come si vede, il criterio delle condizioni composte va considerato troppo oneroso e quindi non applicabile, mentre gli altri criteri possono invece essere utilizzati anche nell’ambito di progetti di dimensioni reali.

Altri criteri

I criteri visti finora non considerano i cicli e possono essere soddisfatti da test che percorrono ogni ciclo al più una volta. Molti errori però si verificano durante iterazioni successive alla prima, come per esempio quando si superano i limiti di un array.

Occorre quindi sviluppare dei criteri che tengano conto anche delle iterazioni e stimolino i cicli un numero di volte sufficiente.

Esempio 4: copertura delle iterazioni
Pseudocodice Diagramma di flusso di esecuzione
01  void main() {
02      float a, b, x, y;
03      read(x);
04      read(y);
05      a = x;
06      b = y;
07      while (a != b) {
08          if (a > b)
09              a = a - b;
10          else
11              b = b - a;
12      }
13      write(a);
14  }

Criterio di copertura dei cammini

Un test \(\ T\) soddisfa il criterio di copertura dei cammini se e solo se ogni cammino del grafo di controllo del programma viene percorso per almeno un caso di \(t \in T\).
La metrica è quindi il rapporto tra i cammini percorsi e quelli effettivamente percorribili.

Questo criterio è molto generale ma è spesso impraticabile, anche per programmi semplici: la presenza di cicli imporrebbe infatti di testare tutti gli infiniti cammini che li attraversano un numero arbitrario di volte. Il criterio è quindi considerato non applicabile in pratica.

Criterio di \(n\)-copertura dei cicli

Un test \(\ T\) soddisfa il criterio di \(\bf{\it{n}}\)-copertura se e solo se ogni cammino del grafo contenente al massimo un numero d’iterazioni di ogni ciclo non superiore a \(n\) viene percorso per almeno un caso di test \(\ t \in T\).

La definizione sopra non significa che il test deve eseguire \(n\) volte un ciclo, ma che per ogni numero \(k\) compreso tra 0 e \(n\) deve esistere un caso di test che esegue tale ciclo \(k\) volte. Si sta quindi limitando il numero massimo di percorrenze dei cicli.
Di conseguenza, al crescere di \(n\) il numero di test aumenta molto rapidamente. Inoltre, fissare \(n\) a livello di programma può non essere un’azione così semplice: il numero d’iterazioni che necessita un ciclo per essere testato a fondo può essere molto differente a seconda del caso.

Per cercare di minimizzare il numero di test spesso il criterio applicato è quello di \(\bf{2}\)-copertura dei cicli. Si tratta infatti del numero minimo che permette comunque di testare tutte le casistiche principali:

  • zero iterazioni;
  • una iterazione;
  • più di una iterazione.

Il caso \(n = 2\) è cioè il minimo per considerare casistiche non banali: dando uno sguardo all’esempio sopra, infatti, con \(n = 1\) il ciclo (while) sarebbe stato indistinguibile da una semplice selezione (if); testando due iterazioni si incominciano a testare le vere caratteristiche del ciclo. Esso permette cioè di testare non solo i comandi che compongono il ciclo, ma anche sue le pre/post-condizioni ed eventuali invarianti.

A differenza del criterio di copertura dei cammini, il criterio di \(n\)-copertura è considerato applicabile a programmi reali.

Mappa finale delle implicazioni tra criteri di selezione

Aggiungendo i criteri di copertura che considerano esplicitamente i cicli si ottiene il seguente schema di implicazione tra tutti i criteri di selezione:

Analisi statica

Come abbiamo detto nella lezione precedente, il testing dell’esecuzione del programma non è però l’unica cosa che possiamo fare per aumentare la fiducia nostra e del cliente nella correttezza del programma. Un’altra importante iniziativa in tal senso è l’ispezione tramite varie tecniche del codice del programma, attività che prende il nome di analisi statica.

L’analisi statica si basa cioè sull’esame di un insieme finito di elementi (le istruzioni del programma), contrariamente all’analisi dinamica che invece considera un insieme infinito (gli stati delle esecuzioni). È un’attività perciò meno costosa del testing, poiché non soffre del problema dell’“esplosione dello spazio degli stati”.

Considerando solamente il codice “statico” del programma, questa tecnica non ha la capacità di rilevare anomalie dipendenti da particolari valori assunti dalle variabili a runtime. Si tratta nondimeno di un’attività estremamente utile, che può aiutare a individuare numerosi errori e inaccortezze.

Compilatori

Prima di trasformare il codice sorgente in eseguibile, i compilatori fanno un’attività di analisi statica per identificare errori sintattici (o presunti tali) all’interno del codice.

Il lavoro dei compilatori si può dividere solitamente in quattro tipi di analisi (gli esempi sono presi dal compilatore di Rust, caratteristico per la quantità e qualità di analisi svolta durante la compilazione):

  • analisi lessicale: identifica i token appartenenti o meno al linguaggio, permettendo di individuare possibili errori di battitura;

    error: expected one of `!`, `.`, `::`, `;`, `?`, `{`, `}`, or an operator, found `ciao`
    --> src/main.rs:2:9
      |
    2 |     BRO ciao = "mondo";
      |           ^^^^ expected one of 8 possible tokens
    
  • analisi sintattica: controlla che i token identificati siano in relazioni sensate tra di loro in base alla grammatica del linguaggio, scovando così possibili errori di incomprensione del linguaggio;

    error: expected `{`, found keyword `for`
    --> src/main.rs:2:14
      |
    2 |     if !expr for;
      |              ^^^ expected `{`
      |
    
  • controllo dei tipi: nei linguaggi tipizzati, individua violazioni di regole d’uso dei tipi ed eventuali incompatibilità tra tipi di dati;

    error[E0308]: mismatched types
    --> src/main.rs:2:24
      |
    2 |     let name: String = 42;
      |               ------   ^^- help: try using a conversion method: `.to_string()`
      |               |        |
      |               |        expected struct `String`, found integer
      |               expected due to this
    
    For more information about this error, try `rustc --explain E0308`.
    
  • analisi flusso dei dati: si cercano di rilevare problemi relativi all’evoluzione dei valori associati alle variabili, come per esempio porzioni di codice non raggiungibili.

    error: equal expressions as operands to `!=`
    --> src/main.rs:2:8
      |
    2 |     if 1 != 1 {
      |        ^^^^^^
      |
    

Se i primi tre tipi di analisi sono abbastanza facili da comprendere, l’ultimo merita una maggiore attenzione, motivo per cui gli dedichiamo il prossimo paragrafo.

Analisi Data Flow

Nata nell’ambito dell’ottimizzazione dei compilatori, che per migliorare le proprie performance ricercavano porzioni di codice non raggiungibile da non compilare, l’analisi del flusso di dati è stata più avanti imbracciata dall’ingegneria del software per ricercare e prevenire le cause di errori simili.
Parlando di flusso dei dati si potrebbe pensare a un’analisi prettamente dinamica come il testing, ma l’insieme dei controlli statici che si possono fare sul codice per comprendere come vengono utilizzati i valori presenti nel programma è invece particolarmente significativo.

È possibile infatti analizzare staticamente il tipo delle operazioni eseguite su una variabile e l’insieme dei legami di compatibilità tra di esse per determinare se il valore in questione viene usato in maniera semanticamente sensata all’interno del codice.
Nello specifico, le operazioni che possono essere eseguite su un dato sono solamente di tre tipi:

$$ \require{color} % Regular operations \def\op#1{ \fcolorbox{black}{white}{$\vphantom{d} \sf{#1}$} } \def\d{\op{d} \,} \def\a{\op{a} \,} \def\u{\op{u} \,} % Erroneous operations \def\opR#1{ \fcolorbox{black}{orangered}{$\vphantom{d} \color{white}{\sf{#1}}$} } \def\dR{\opR{d} \,} \def\aR{\opR{a} \,} \def\uR{\opR{u} \,} % Subscript operations \def\Op#1#2{ \fcolorbox{black}{white}{$\vphantom{d_6} \sf{#1}_{#2}$} } \def\D#1{\Op{d}{#1} \,} \def\A#1{\Op{a}{#1} \,} \def\U#1{\Op{u}{#1} \,} % Warning subscript operations \def\OpW#1#2{ \fcolorbox{black}{orange}{$\vphantom{d_6} \sf{#1}_{#2}$} } % Green subscript operations \def\OpG#1#2{ \fcolorbox{black}{lightgreen}{$\vphantom{d_6} \sf{#1}_{#2}$} } \def\DG#1{\OpG{d}{#1} \,} \def\AG#1{\OpG{a}{#1} \,} \def\UG#1{\OpG{u}{#1} \,} % Error \def\Err{ \color{red}{\sf{ERROR}} } \def\err{ \, \Err } $$
  • \(\op{d}\) (definizione): il comando assegna un valore alla variabile; anche il passaggio del dato come parametro ad una funzione che lo modifica è considerata un’operazione di (ri)definizione;

  • \(\op{u}\) (uso): il comando legge il contenuto di una variabile, come per esempio l’espressione sul lato destro di un assegnamento;

  • \(\op{a}\) (annullamento): al termine dell’esecuzione del comando il valore della variabile non è significativo/affidabile. Per esempio, dopo la dichiarazione senza inizializzazione di una variabile e al termine del suo scope il valore è da considerarsi inaffidabile.

Dal punto di vista di ciascuna variabile è possibile ridurre una qualsiasi sequenza d’istruzioni (ovvero un cammino sul diagramma di flusso) a una sequenza di definizioni, usi e annullamenti.

Regole

Fatta questa semplificazione è allora possibile individuare la presenza di anomalie nell’uso delle variabili definendo alcune regole di flusso: alcune di queste devono essere necessariamente rispettate in un programma corretto (1 e 3), mentre altre hanno più a che fare con la semantica dell’uso di un valore (2).

  1. L’uso di una variabile deve essere sempre preceduto in ogni sequenza da una definizione senza annullamenti intermedi.

    \[\a\u\err\]
  2. La definizione di una variabile deve essere sempre seguita da un uso, prima di un suo annullamento o nuova definizione.

    \[\d\a\err \\ \d\d\err\]
  3. L’annullamento di una variabile deve essere sempre seguito da una definizione, prima di un uso o altro annullamento.

    \[\a\a\err\]

Riassumendo, \(\a\op{u}\), \(\d\op{a}\), \(\d\op{d}\) e \(\a\op{a}\) sono sequenze che identificano situazioni anomale, anche se non necessariamente dannose: se per esempio usare una variabile subito dopo averla annullata rende l’esecuzione del programma non controllabile, un annullamento subito dopo una definizione non crea nessun problema a runtime, ma è altresì indice di un possibile errore concettuale.

\(\a\) \(\d\) \(\u\)
\(\a\) \(\Err\) \(\Err\)
\(\d\) \(\Err\) \(\Err\)
\(\u\)

Esempio

Consideriamo la seguente funzione C con il compito di scambiare il valore di due variabili:

void swap(int &x1, int &x2) {
    int x1;
    x3 = x1;
    x3 = x2;
    x2 = x1;
}

Analizzando il codice, le sequenze per ogni variabile sono le seguenti:

Variabile Sequenza Anomalie
x1 \(\aR\uR\u\a\) x1 viene usata 2 volte senza essere stata prima definita
x2 \(\dots \d\u\op{d} \dots\) Nessuna
x3 \(\dots \d\dR\opR{d} \dots\) x3 viene definita più volte senza nel frattempo essere stata usata

Come si vede, in un codice sintatticamente corretto l’analisi Data Flow ci permette quindi di scovare un errore semantico osservando le sequenze di operazioni sulle sue variabili.

Sequenze Data Flow

Abbiamo accennato più volte al concetto di “sequenza” di operazioni su una variabile. Più formalmente, definiamo sequenza di operazioni per la variabile \(\mathtt{a}\) secondo il cammino \(p\) la concatenazione della tipologia delle istruzioni che coinvolgono tale variabile, e la indichiamo con \(\operatorname{P}(p, \, \mathtt{a})\).

Considerando per esempio il seguente programma C:

01  void main() {
02      float a, b, x, y;
03      read(x);
04      read(y);
05      a = x;
06      b = y;
07      while (a != b)
08          if (a > b)
09              a = a - b;
10          else
11              b = b - a;
12      write(a);
13  }

possiamo dire che:

\[\begin{align*} &\operatorname{P}([1, 2, 3, 4, 5, 6, 7, 8, 9, 7, 12, 13], \, \mathtt{a}) \\ &= \A{2} \D{5} \U{7} \U{8} \U{9} \D{9} \U{7} \U{12} \A{13} \end{align*}\]

Eseguendo questo tipo di operazione su tutte le variabili e per tutti i cammini del programma si potrebbe verificare la presenza eventuali anomalie, ma come sappiamo i cammini sono potenzialmente infiniti quando il programma contiene cicli e decisioni: per scoprire quali percorsi segue effettivamente l’esecuzione del programma dovremmo eseguirlo e quindi uscire dal campo dell’analisi statica.

Espressioni regolari

Tuttavia non tutto è perduto: un caso di cammini contenenti cicli e decisioni è possibile rappresentare un insieme di sequenze ottenute dal programma \(P\) utilizzando delle espressioni regolari. Con \(\operatorname{P}([1 \rightarrow], \, \mathtt{a})\) si indica infatti l’espressione regolare che rappresenta tutti i cammini che partono dall’istruzione \(1\) per la variabile \(\mathtt{a}\).

Questo perché nelle espressioni regolari è possibile inserire, oltre che una serie di parentesi che isolano sotto-sequenze, anche due simboli molto particolari:

  • la pipe (|), che indica che i simboli (o le sotto-sequenze) alla propria destra e alla propria sinistra si escludono a vicenda: una e una sola delle due è presente;
  • l’asterisco (*), che indica che il simbolo (o la sotto-sequenza) precedente può essere ripetuto da 0 a \(n\) volte.

Grazie a questi simboli è possibile rappresentare rispettivamente decisioni e cicli. Prendendo per esempio il codice precedente, è possibile costruire \(\operatorname{P}([1 \rightarrow], \, \mathtt{a})\) come:

\[\begin{align*} &\A{2} \D{5} & & &&& && && & & \\ &\A{2} \D{5} &\U{7} &\Big( &\phantom{\U8} &&\textit{while body} &&\phantom{\U{7}} &&\Big)* &\quad \quad \U{12} &\A{13} \\ &\A{2} \D{5} &\U{7} &\Big( &\U{8} &&\textit{if body} &&\phantom{\U{7}} &&\Big)* &\quad \quad \U{12} &\A{13} \\ &\A{2} \D{5} &\U{7} &\Big( &\U{8} &&\Big(\, \U{9} \D{9} \Big | \: \U{11} \Big) && &&\Big)* &\quad \quad \U{12} &\A{13} \\ &\A{2} \D{5} &\OpW{u}{7} \, &\Big( \, &\U{8} &&\Big(\, \U{9} \D{9} \Big | \: \U{11} \Big) &&\OpW{u}{7} \, &&\Big)* &\quad \quad \U{12} &\A{13} \end{align*}\]

Osserviamo come \(\OpW{u}{7}\) si ripeta due volte: questo può rendere fastidioso ricercare errori, per via della difficoltà di considerare cammini multipli. Comunque sia, una volta ottenuta un’espressione regolare è facile verificare l’eventuale presenza di errori applicando le solite regole (nell’esempio non ce n’erano).

Bisogna però fare attenzione a un’aspetto: le espressioni regolari così costruite rappresentano tutti i cammini possibili del programma, ma non tutti e i soli! Trattandosi di oggetti puramente matematici, infatti, le espressioni regolari sono necessariamente più generali di qualunque programma: esse non tengono infatti conto degli effetti che le istruzioni hanno sui dati e delle relative proprietà che si possono inferire.
Riprendendo a esempio l’espressione regolare di cui sopra, essa contiene la sequenza nella quale il ciclo viene eseguito infinite volte, ma osservando il programma è facile indovinare che tale comportamento non sia in realtà possibile: diminuendo progressivamente \(\mathtt{a}\) e \(\mathtt{b}\) a seconda di chi sia il maggiore si può dimostrare che prima o poi i due convergeranno allo stesso valore permettendo così di uscire dal ciclo.

In effetti, uno stesso programma può essere rappresentato tramite un numero infinito di espressioni regolari valide. Si potrebbe addirittura argomentare che l’espressione regolare

\[\Big ( \, \u \Big | \: \d \Big | \: \a \Big)*\]

possa rappresentare qualsiasi programma.
Allontanandosi però dai casi estremi, si dimostra essere impossibile scrivere un algoritmo che dato un qualsiasi programma riesca a generare un’espressione regolare che rappresenti tutti e soli i suoi cammini possibili senza osservare i valori delle variabili. Bisogna dunque accontentarsi di trovare espressioni regolari che rappresentino al meglio l’esecuzione del programma, ovvero con il minor numero di cammini impossibili rappresentati.

Nell’analisi Data Flow tramite espressioni regolari è quindi necessario tenere conto che il modello generato è un’astrazione pessimistica: se viene notificata la presenza di un errore non si può essere certi che esso ci sia veramente, in quanto esso potrebbe derivare da un cammino non percorribile.

Analisi statica e Testing

Oltre ad essere un processo utile di per sé per il rilevamento di potenziali errori, l’analisi statica può anche contribuire a guidare l’attività di testing.
Per capire come, osserviamo che a partire dall’analisi statica è possibile fare le seguenti osservazioni:

  • perché si presenti un malfunzionamento dovuto a una anomalia in una definizione, deve esserci un uso che si serva del valore assegnato;
  • un ciclo dovrebbe essere ripetuto (di nuovo) se verrà usato un valore definito alla iterazione precedente.

L’analisi statica può quindi aiutare a selezionare i casi di test basandosi sulle sequenze definizione-uso delle variabili, costruendo cioè dei nuovi criteri di copertura.

Terminologia

Per rendere più scorrevole la spiegazione dei prossimi argomenti introduciamo innanzitutto un po’ di terminologia.

Dato un nodo \(i\) del diagramma di flusso (un comando/riga del programma), chiamiamo \(\operatorname{def}(i)\) l’insieme delle variabili definite in \(\bf{i}\).

Data invece una variabile \(x\) e un nodo \(i\), chiamiamo \(\operatorname{du}(x, \, i)\) l’insieme dei nodi \(j\) tali che:

  • \(x \in \operatorname{def}(i)\), ovvero la variabile \(x\) è definita in \(i\);
  • \(x\) è usata nel nodo \(j\);
  • esiste un cammino da \(i\) a \(j\) libero da definizioni di \(x\), ovvero che se seguito non sovrascrive il valore di \(x\).

Si tratta cioè dell’insieme di nodi \(\bf{j}\) che potrebbero usare il valore di \(\bf{x}\) definito in \(\bf{i}\).

Criteri di copertura derivati dall’analisi statica

Criterio di copertura delle definizioni

Un test \(\ T\) soddisfa il criterio di copertura delle definizioni se e solo se per ogni nodo \(i\) e ogni variabile \(x \in \operatorname{def}(i)\), \(T\) include un caso di test che esegue un cammino libero da definizioni da \(i\) ad almeno uno degli elementi di \(\operatorname{du}(i, x).\)

Ci si vuole cioè assicurare di testare tutte le definizioni, assicurandosi che funzionino osservando almeno un uso del valore da loro assegnato. Matematicamente si può dire che:

\[\begin{align*} T \in C_{def} \Longleftrightarrow& \forall i \in P, \ \forall x \in \operatorname{def}(i), \ \exists j \in \operatorname{du}(i, \, x) \:, \\ & \: \exists t \in T \ \text{che esegue un cammino da $i$ a $j$ senza ulteriori definizioni di $x$}. \end{align*}\]

Riconsideriamo l’esempio già visto in precedenza, considerando la variabile \(\mathtt{a}\):

01  void main() {
02      float a, b, x, y;
03      read(x);
04      read(y);
05      a = x;
06      b = y;
07      while (a != b)
08          if (a > b)
09              a = a - b;
10          else
11              b = b - a;
12      write(a);
13  }

Partiamo definendo gli insiemi dei nodi degli usi \(\operatorname{du}(i, \, \mathtt a)\):

  1. \(\operatorname{du}(5, \, \mathtt a)\) = \(\{7, \, 8, \, 9, \, 11, \, 12\}\);
  2. \(\operatorname{du}(9, \, \mathtt a)\) = \(\{7, \, 8, \, 9, \, 11, \, 12\}\).

È solo un caso il fatto che in questo esempio tali insiemi siano uguali.
Comunque sia, l’obiettivo è per ognuna delle due definizioni ottenere un uso di tale definizione:

  1. Per la prima definizione la soluzione è banale, a riga 7 la variabile \(\mathtt a\) viene letta sempre: \(\D{5}\U{7}\).
  2. Per la seconda, invece, è necessario scegliere un valore tale per cui il flusso di esecuzione entri almeno una volta nel ciclo ed esegua almeno una volta la riga 9: \(\D{9}\U{7}\).

Un test che soddisfa totalmente il criterio può essere il seguente:

\[T = \{ \langle 8, \, 4 \rangle \}.\]

Come si vede, il criterio di copertura delle definizioni non copre tutti i comandi e di conseguenza non implica il criterio di copertura dei comandi.

Criterio di copertura degli usi

Un test \(\ T\) soddisfa il criterio di copertura degli usi se e solo se per ogni nodo \(i\) e ogni variabile \(x\) appartenente a \(\operatorname{def}(i)\), \(T\) include un caso di test che esegue un cammino libero da definizioni da \(i\) ad ogni elemento di \(\operatorname{du}(i, \, x).\)

Sembra simile al precedente, con la differenza che ora bisogna coprire tutti i potenziali usi di una variabile definita. Questo appare ancora più chiaro osservando la formula matematica:

\[\begin{align*} T \in C_{path} \Longleftrightarrow& \forall i \in P, \ \forall x \in \operatorname{def}(i), \ \forall j \in \operatorname{du}(i, \, x) \:, \\ & \: \exists t \in T \ \text{che esegue un cammino da $i$ a $j$ senza ulteriori definizioni di $x$}. \end{align*}\]

Si noti però che il criterio di copertura degli usi non implica il criterio di copertura delle definizioni, perché nel caso in cui non esistano \(j \in \operatorname{du}(i, \, x)\) l’uso del \(\forall\) è più “permissivo” del \(\exists\) del criterio precedente: quest’ultimo richiedeva infatti che per ogni definizione esistesse almeno un uso, mentre il criterio di copertura degli usi non pone tale clausola (se non ci sono usi il \(\forall\) è sempre vero). Viene quindi da sé che questo criterio non copre neanche il criterio di copertura dei comandi.

Riconsideriamo nuovamente il programma in C visto in precedenza come esempio:

01  void main() {
02      float a, b, x, y;
03      read(x);
04      read(y);
05      a = x;
06      b = y;
07      while (a != b)
08          if (a > b)
09              a = a - b;
10          else
11              b = b - a;
12      write(a);
13  }

Come prima, consideriamo la variabile \(\mathtt a\) e i relativi insieme dei nodi degli usi per ogni sua definizione:

  1. \(\operatorname{du}(5, \, \mathtt a)\) = \(\{7, \, 8, \, 9, \, 11, \, 12\}\);
  2. \(\operatorname{du}(9, \, \mathtt a)\) = \(\{7, \, 8, \, 9, \, 11, \, 12\}\).

Per ogni definizione occorre coprire tutti gli usi:

\(\operatorname{du}(5, \, \mathtt a)\) \(\operatorname{du}(9, \, \mathtt a)\)

\(\D{5}\UG{7}\UG{8}\UG{11}\U{7}\UG{12}\)

\(\dots \, \D{9} \UG7 \UG8 \UG9 \dots\)

\(\dots \, \D5 \U7 \U8 \UG9 \dots\)

\(\dots \, \D9 \U7 \U8 \UG{12} \dots\)

\(\dots \, \D9 \U7 \U8 \UG{11} \dots\)

Un test che soddisfa totalmente il criterio può essere il seguente:

\[T = \{ \langle 4, \, 8 \rangle, \, \langle 12, \, 8 \rangle, \, \langle 12, \, 4 \rangle \}.\]

Questo esempio permette di notare qualcosa sulla natura dei cicli: dovendo testare ogni percorso al loro interno è necessario fare almeno due iterazioni. Può quindi sorgere un dubbio: è meglio che le due iterazioni siano fatte nello stesso caso di test o in casi test separati? Ovvero, è meglio minimizzare i casi di test o le iterazioni per caso?
Opinione diffusa è quella secondo cui è preferibile minimizzare le iterazioni: partizionando le casistiche in diversi casi di test è possibile rilevare con più precisione gli errori, riducendo il tempo di debug. In alcune situazioni però aumentare il numero di iterazioni può diminuire il tempo di esecuzione totale dei test, in quanto dovendo riavviare il programma per ciascun caso di test la somma dei tempi di startup può diventare significativa per software molto massicci.

Criterio di copertura dei cammini DU

Nel criterio precedente si richiedeva di testare un cammino da ogni definizione ad ogni suo uso, ma come sappiamo i cammini tra due istruzioni di un programma possono essere molteplici. Potrebbe dunque sorgere l’idea di testarli tutti: da questa intuizione nasce il criterio di copertura dei cammini DU.

\[\begin{align*} T \in C_{pathDU} \Longleftrightarrow& \forall i \in P, \ \forall x \in \operatorname{def}(i), \ \forall j \in \operatorname{du}(i, \, x), \\ &\forall \text{ cammino da $i$ a $j$ senza ulteriori definizioni di $x$} \\ & \exists t \in T \ \text{che lo esegue}. \end{align*}\]

Questo criterio può essere utile da ipotizzare, ma a causa dell’esplosione combinatoria del numero dei cammini è considerato impraticabile (“sopra la barra rossa”).

Oltre le variabili

L’analisi del flusso dati si può estendere anche su altri “oggetti”, non solo variabili.
Per esempio, è possibile prevedere le seguenti operazioni su un file:

  • \(\op{a}\) (apertura): specializzata in per lettura o per scrittura;
  • \(\op{c}\) (chiusura);
  • \(\op{l}\) (lettura);
  • \(\op{s}\) (scrittura).

Date queste operazioni si possono individuare una serie di regole, come per esempio:

  1. \(\op{l}\), \(\op{s}\) e \(\op{c}\) devono essere precedute da \(\op{a}\) senza \(\op{c}\) intermedie;
  2. \(\op{a}\) deve essere seguita da \(\op{c}\) prima di un’altra \(\op{a}\);
  3. legami tra tipo di apertura (per lettura o per scrittura) e relative operazioni.

È interessante notare il legame tra l’attività di analisi del flusso di dati e i diagrammi UML a stati finiti: un oggetto risponde a una certa tipologia di eventi, può essere in diversi stati e in certi stati non sono ammesse alcune operazioni. Si noti come nessuna delle due discipline entra comunque nel merito del valore delle variabili, relegato ad un’analisi a runtime.

Criterio di copertura del budget

Molto spesso nei contesti reali l’unico criterio applicato è quello di copertura del budget: si continuano a creare casi di test finché non sono finite le risorse (tempo e soldi). Questa tecnica ovviamente non fornisce alcuna garanzia sull’efficacia dei test, ed è sicuramente sconsigliata.

Tecniche di review

Finora abbiamo esplorato tecniche più o meno automatiche per la ricerca di errori, che stimolavano il programma con specifici input o ne analizzavano il codice per individuare potenziali anomalie.
Tuttavia, alcuni tipi di errori non possono essere rilevati con questi metodi: si tratta soprattutto errori legati a incomprensione delle specifiche. Del resto, attività come il testing richiedono che il programmatore fornisca l’output “corretto” che si aspetta dal programma che viene confrontato con quello effettivo per scovare eventuali differenze: se chi scrive il codice non comprende in primo luogo cosa dovrebbe fare il suo software non c’è modo di individuare l’errore.

Per questo motivo molto spesso il codice viene sottoposto ad un’attività di review, in cui un operatore umano ne analizza la struttura e il funzionamento: egli sarà chiaramente in grado di cogliere una serie di errori semantici che sfuggono alla comprensione dei tool automatici di test. Spesso questa mansione viene svolta da un team di testing separato dal team di sviluppo: non solo questo promuove l’effettiva ricerca di errori (mentre gli sviluppatori avrebbero tutto l’interesse di non trovarne nessuno), ma sottopone il software a uno sguardo esterno più critico e imparziale.

Anche per la review esistono una serie di tecniche: vediamone quindi le principali.

Bebugging

Talvolta può capitare che il team di testing non trovi errori nel programma sotto osservazione. Oltre ad essere scoraggiante per chi esegue la review questo è spesso indice del fatto che tale attività non viene svolta in maniera corretta, poiché raramente un programma è effettivamente corretto al 100% dopo la prima scrittura.

Un metodo efficace per risolvere questo problema è il cosiddetto bebugging, una tecnica secondo la quale gli sviluppatori inseriscono deliberatamente \(\bf{n}\) errori nel codice prima di mandarlo in analisi al team di testing, a cui viene comunicato il numero \(n\) di errori da trovare. L’ovvio vantaggio di questa tecnica è l’incentivo per il team di testing a continuare a cercare errori, facendo sì che durante la ricerca ne vengano scovati molti altri non ancora noti.

La metrica utilizzata per valutare l’efficacia del testing tramite questa tecnica è dunque la percentuale di errori trovati tra quelli inseriti artificialmente, che può fornire un’indicazione della frazione di errori che il team di testing è in grado di trovare. Se per esempio il team di sviluppo ha aggiunto 10 bug “artificiali” e durante il testing ne vengono trovati 8 più 2 non noti, si può supporre che il team di review riesce a trovare l’80% degli errori e che quindi ce ne è ancora un altra porzione di errori reali da scovare.
Bisogna però essere molto cauti nel fare considerazioni di questo tipo: è possibile che gli errori immessi artificialmente siano troppo facili o troppo difficili da trovare, per cui conviene sempre prendere tutto con le pinze.

Analisi mutazionale

Una evoluzione del bebugging è l’analisi mutazionale. Dato un programma \(P\) e un test \(T\) (insieme di casi di test), viene generato un insieme di programmi \(\Pi\) simili al programma \(P\) in esame: tali programmi prendono il nome di mutanti.
Si esegue poi il test \(T\) su ciascun mutante: se \(P\) era corretto i programmi in \(\Pi\) devono essere sbagliati, ovvero devono produrre un risultato diverso per almeno un caso di test \(t \in T\). Se così non fosse, infatti, vorrebbe dire che il programma \(P\) non viene opportunamente testato nell’aspetto in cui si discosta dal mutante che non ha sollevato errori, per cui non si può essere sicuri della sua correttezza. Non viene cioè testata la correttezza del programma, ma piuttosto quanto il test è approfondito.

Si può quindi valutare la capacità di un test di rilevare le differenze introdotte nei mutanti tramite un nuovo criterio di copertura, che prende il nome di criterio di copertura dei mutanti.

Criterio di copertura dei mutanti

Un test \(\ T\) soddisfa il criterio di copertura dei mutanti se e solo se per ogni mutante \(\pi \in \Pi\) esiste almeno un caso di test \(t \in T\) la cui esecuzione produca per \(\pi\) un risultato diverso da quello prodotto da \(P\).

La metrica di valutazione di questo criterio è la frazione di mutanti \(\pi\) riconosciuta come diversa da \(P\) sul totale di mutanti generati. Se non tutti i mutanti vengono scovati sarà necessario aggiungere dei casi di test che li riconoscano.

I tre passi da seguire per costruire un test tramite l’analisi mutazionale sono quindi:

  1. analisi delle classi e generazione dei mutanti;
  2. selezionare dei casi di test da aggiungere a \(T\), in base alla metrica di cui sopra;
  3. esecuzione dei casi di test sui mutanti, pensando anche alle performance;

Analizziamo ciascuno di tali step in maggior dettaglio.

Generazione dei mutanti

Idealmente i mutanti generati dovrebbero essere il meno differenti possibile dal programma di partenza, ovvero dovrebbe esserci un mutante per ogni singola anomalia che sarebbe possibile inserire nel programma.

Questo richiederebbe però di generare infiniti mutanti, mentre per mantenere la suite di test eseguibile in tempi ragionevoli il numero di mutanti non dovrebbe essere troppo elevato: un centinaio è una buona stima, ma un migliaio sarebbe auspicabile.
Visto il numero limitato è necessario dunque concentrarsi sulla “qualità” dei mutanti generati, dove i mutanti sono tanto più buoni quanto più permettono di scovare degli errori. Per questo motivo vengono creati degli specifici operatori che dato un programma restituiscono dei mutanti utili.

Operatori mutanti

Come già accennato, gli operatori mutanti sono delle funzioni (o piccoli programmi) che dato un programma \(P\) generano un insieme di mutanti \(\Pi\). Essi operano eseguendo piccole modifiche sintattiche che modifichino la semantica del programma senza però causare errori di compilazione.

Tali operatori si distinguono in classi in base agli oggetti su cui operano:

  • costanti e variabili, per esempio scambiando l’occorrenza di una con l’altra;
  • operatori ed espressioni, per esempio trasformando < in <=, oppure true in false;
  • comandi, per esempio trasformando un while in if, facendo così eseguire il ciclo una sola volta.

Alcuni operatori possono essere anche specifici su alcuni tipi di applicazioni, come nel caso di:

  • operatori per sistemi concorrenti: operano principalmente sulle primitive di sincronizzazione – come eseguire una notify() invece che una notifyAll();
  • operatori per sistemi object-oriented: operano principalmente sulle interfacce dei moduli.

Poiché la generazione dei mutanti è un’attività tediosa, il compito di applicare questi operatori viene spesso affidato a tool automatici. Esistono però numerosi problemi di prestazioni, in quanto per ogni mutante occorre modificare il codice, ricompilarlo, controllare che non si sovrapponga allo spazio di compilazione delle classi di altri mutanti e fare una serie di altre operazioni che comportano un pesante overhead. Per questo motivo i tool moderni lavorano spesso sull’eseguibile in sé (sul bytecode nel caso di Java): sebbene questo diminuisca il lavoro da fare per ogni mutante è possibile che il codice eseguibile così ottenuto sia un programma che non sarebbe possibile generare tramite compilazione. Si espande quindi l’universo delle possibili anomalie anche a quelle non ottenibili, un aspetto che bisognerà tenere in considerazione nella valutazione della metrica di copertura.

High Order Mutation

Normalmente i mutanti vengono generati introducendo una singola modifica al programma originale. Nella variante HOM (High Order Mutation) si applicano invece modifiche a codice già modificato.

La giustificazione per tale tecnica è che esistono alcuni casi in cui trovare errori dopo aver applicato più modifiche è più difficile rispetto ad applicarne solo una. Può essere che un errore mascheri parzialmente lo stato inconsistente dell’altro rendendo più difficile il rilevamento di malfunzionamenti, cosa che porta a generare test ancora più approfonditi.

Automatizzare l’analisi mutazionale

Generalmente nel testing gli unici due outcomes sono risultato corretto o non corretto e la metrica è una misura della correttezza del programma. Il discriminante delle tecniche di analisi mutazionale è invece il numero di casi di test che forniscono un risultato diverso da quello di \(P\), indipendentemente dalla correttezza (di entrambi).

Come già detto, trovare errori con queste tecniche (specialmente l’HOM) misura quindi il livello di approfondimento dei casi di test e non la correttezza del programma di partenza.
Prescindere dalla correttezza dei risultati ha però un aspetto positivo: per eseguire l’analisi mutazionale non è necessario conoscere il comportamento corretto del programma, eliminando la necessità di un oracolo che ce lo fornisca. Si può quindi misurare la bontà di un insieme casi di test automatizzando la loro creazione: come già detto precedentemente, occorre però vigilare sulla proliferazione del numero di esecuzioni da effettuare per completare il test – un caso di test dà infatti origine a \(n+1\) esecuzioni, dove \(n\) è il numero di mutanti.

Il seguente diagramma di flusso visualizza quindi l’attività facilmente automatizzabile di analisi mutazionale:

Benché semplice, questo algoritmo non garantisce la terminazione per una serie di motivi:

  • quando si estrae un caso di test casuale, c’è sempre il rischio di estrarre sempre lo stesso;
  • si potrebbe essere particolarmente sfortunati e non trovare un caso di test utile in tempo breve;
  • esistono infinite varianti di programmi funzionalmente identici ma sintatticamente diversi, ovvero che svolgono la stessa funzione anche se sono diversi: una modifica sintattica potrebbe non avere alcun effetto sul funzionamento del programma, come per esempio scambiare < con <= in un algoritmo di ordinamento. In tal caso, nessun nuovo caso di test permetterebbe di coprire il mutante, in quanto esso restituirebbe sempre lo stesso output del programma originale.

Spesso viene quindi posto un timeout sull’algoritmo dipendente sia dal tempo totale di esecuzione, sia dal numero di casi di test estratti.

Per verificare la validità del test, è necessario controllare il numero di mutanti generati: se questo numero è elevato, il test non era affidabile. In alternativa, è possibile “nascondere” i mutanti, a patto che non sia richiesta una copertura totale. In questo modo, è possibile analizzare programmi che sono funzionalmente uguali ma sintatticamente diversi, al fine di dimostrarne l’equivalenza o scoprire casi in cui essa non è valida.

Object-oriented testing

Finora abbiamo trattato i programmi come funzioni matematiche da un dominio di input a un dominio di output. Questo è tutto sommato vero per quanto riguarda i linguaggi procedurali: un programma in tale paradigma è composto da un insieme di funzioni e procedure che preso un dato in ingresso ne restituiscono uno in uscita. A meno di eventuali variabili globali condivise (il cui uso è comunque sconsigliato), tali funzioni sono indipendenti l’una dall’altra, e possono quindi essere testate indipendentemente come fossero dei piccoli sotto-programmi.

La situazione cambia per quanto riguarda invece i linguaggi object oriented (OO), che introducono i concetti di classe e istanza: in tali linguaggi gli oggetti sono l’unione di metodi e stato. Le tecniche di testing viste finora smettono quindi di funzionare: la maggior parte delle volte testare i metodi isolatamente come funzioni da input ad output perde di senso, in quanto non si considera il contesto (lo stato dell’oggetto associato) in cui essi operano.

Bisogna dunque sviluppare una serie di tecniche di test specifiche per i linguaggi orientati agli oggetti, in cui l’unità testabile si sposti dalle procedure alle classi.

Ereditarietà e collegamento dinamico

Prima di capire come è possibile testare un’intera classe, affrontiamo due punti critici che derivano dal funzionamento intrinseco dei linguaggi a oggetti: l’ereditarietà e il collegamento dinamico.

Partiamo dal primo e immaginiamo di avere una classe già completamente testata. Creando ora una sottoclasse di tale classe originale può sorgere un dubbio: visto che i metodi ereditati sono già stati testati nella classe genitore ha senso testarli nella classe figlia? Un quesito simile sorge nel caso di metodi di default appartenenti a un’interfaccia: ha senso testare i metodi di default direttamente nell’interfaccia o è meglio testarli nelle classi concrete che implementano tale interfaccia?
Il consenso degli esperti è di testare nuovamente tutti i metodi ereditati: nelle sottoclassi e nelle classi che implementano delle interfacce con metodi di default tali metodi opereranno infatti in nuovi contesti, per cui non vi è alcuna certezza che funzionino ancora a dovere. Inoltre, a causa del collegamento dinamico non è nemmeno sicuro che eseguire lo stesso metodo nella classe base significa eseguire le stesse istruzioni nella classe ereditata.
In generale dunque non si eredita l’attività di testing, ma si possono invece ereditare i casi di test e i relativi valori attesi (l’oracolo): è perciò opportuno rieseguire i casi di test anche nelle sottoclassi.

Un altro motivo per cui il testing object-oriented differisce fortemente da quello per linguaggi funzionali è la preponderanza del collegamento dinamico, attraverso il quale le chiamate ai metodi vengono collegate a runtime in base al tipo effettivo degli oggetti. Dal punto di vista teorico, infatti, tale meccanismo rende difficile stabilire staticamente tutti i possibili cammini di esecuzione, complicando la determinazione dei criteri di copertura.

Testare una classe

Entriamo ora nel vivo della questione. Per testare una classe:

  • la si isola utilizzando più classi stub possibili per renderla eseguibile indipendentemente dal contesto;
  • si implementano eventuali metodi astratti o non ancora implementati (stub);
  • si aggiunge una funzione per permettere di estrarre ed esaminare lo stato dell’oggetto e quindi bypassare l’incapsulamento;
  • si costruisce una classe driver che permetta di istanziare oggetti e chiamare i metodi secondo il criterio di copertura scelto.

Ebbene sì, sono stati progettati dei criteri di copertura specifici per il testing delle classi. Vediamo dunque di cosa si tratta.

Copertura della classe

I criteri classici visti precedentemente (comandi, decisioni, …) continuano a valere ma non sono sufficienti. Per testare completamente una classe occorre considerare lo stato dell’oggetto: in particolare, è comodo utilizzare una macchina a stati che rappresenti gli stati possibili della classe e le relative transazioni, ovvero le chiamate di metodi che cambiano lo stato.

Tale rappresentazione potrebbe esistere nella documentazione o essere creato specificatamente per l’attività di testing. Il seguente diagramma rappresenta per esempio una macchina a stati di una classe avente due metodi, \(\mathtt{m1}\) e \(\mathtt{m2}\).

Ottenuta una rappresentazione di questo tipo, alcuni criteri di copertura che si possono ipotizzare sono:

  • coprire tutti i nodi: per ogni stato dell’oggetto deve esistere almeno un caso di test che lo raggiunge;
  • coprire tutti gli archi: per ogni stato e per ogni metodo deve esistere almeno un caso di test che esegue tale metodo trovandosi in tale stato;
  • coprire tutte le coppie di archi input/output: per ogni stato e per ogni coppia di archi entranti e uscenti deve esistere almeno un caso di test che arriva nello stato tramite l’arco entrante e lo lascia tramite l’arco uscente (consideriamo anche come siamo arrivati nello stato);
  • coprire tutti i cammini identificabili nel grafo: spesso i cammini in questione sono infiniti, cosa che rende l’applicazione di questo criterio infattibile (“sopra la linea rossa”).

Tipo di testing: white o black box?

Abbiamo assunto che il diagramma degli stati facesse parte delle specifiche del progetto. Se così fosse, allora il testing appena descritto assume una connotazione black box: il diagramma rappresenta sì la classe ma è ancora una sua astrazione, che non considera il codice effettivo che rappresenta lo stato o che implementa uno specifico metodo ma solo le relazioni tra i vari stati.

In caso il diagramma degli stati non sia però fornito, il testing delle classi è comunque possibile! Attraverso tecniche di reverse engineering guidate da certe euristiche (che operano ad un livello di astrazione variabile) è possibile ad estrarre informazioni sugli stati di una classe già scritta; spesso tali informazioni non sono comprensibili per un essere umano, motivo per cui esse vengono piuttosto utilizzate da vari tool di testing automatico. In questo caso, però, il testing assume caratteristiche white box, in quanto il codice che implementava la classe era già noto prima di iniziare a testarlo.

Testing funzionale

Introduciamo ora una nuova attività di testing che parte da presupposti completamente diversi rispetto a quelli del test strutturale.

Il test funzionale è infatti un tipo di test che si concentra sulla verifica del comportamento del programma dal punto di vista dell’utente finale, senza considerare il suo funzionamento interno. In altre parole, il test funzionale è un approccio black box in cui non si ha (o non comunque non si sfrutta) la conoscenza del codice sorgente.

Talvolta questo può essere l’unico approccio possibile al testing, come nel caso di validazione del lavoro di un committente esterno; altre volte invece si decide volontariamente di farlo, concentrandosi sul dominio delle informazioni invece che sulla struttura di controllo.
Il test funzionale, che prende in considerazione le specifiche (e non i requisiti) del progetto per discriminare un comportamento corretto da uno scorretto, permette infatti di identificare errori che non possono essere individuati con criteri strutturali, come per esempio funzionalità non implementate, flussi di esecuzione dimenticati o errori di interfaccia e di prestazioni.

Tecniche

Le tecniche di test funzionale si possono raggruppare in:

  • metodi basati su grafi: oltre alle tecniche già viste in precedenza, si può per esempio lavorare anche sui diagrammi di sequenza;
  • suddivisioni del dominio in classi di equivalenza: si possono raggruppare i valori del dominio che causano lo stesso comportamento in classi d’equivalenza, così da testare tutti i comportamenti distinti piuttosto che tutti i possibili valori del dominio. Occorre fare attenzione a non fare l’inverso, ovvero a concentrarsi sui soli valori appartenenti ad una classe di equivalenza ignorando il resto;
  • analisi dei valori limite (test di frontiera): si testano, tra tutti i possibili valori del dominio, quelli “a cavallo” tra una categoria e l’altra, in quanto essi possono più facilmente causare malfunzionamenti;
  • collaudo per confronto: si confronta la nuova versione del programma con la vecchia, assicurandosi che non siano presenti regressioni. Non solo si possono confrontare gli eseguibili, ma anche specifiche formali eseguibili che rappresentino le caratteristiche importanti del software;

Non tutte le metodologie di testing funzionale ricadono però in una di queste categorie, e la più notevole è sicuramente il testing delle interfacce, di cui diamo un assaggio prima di passare a parlare di classi di equivalenza.

Testing delle interfacce

Questa tecnica mira a testare come i vari sotto-sistemi del programma dialoghino e collaborino tra loro: per “interfacce” non si intendono infatti le interface Java o le signature, ma l’insieme di funzionalità che permettono l’interoperabilità dei componenti.
Esistono in particolare diversi tipi di interfacce:

  • a invocazione di parametri;
  • a condivisione di memoria;
  • a metodi sincroni;
  • a passaggio di messaggi.

Le interfacce aderenti a ciascuna categoria possono essere analizzate in modi diversi alla ricerca di anomalie. Gli sbagli più comuni sono per esempio errori nell’uso dell’interfaccia, come il passaggio di parametri in ordine o tipo errato oppure assunzioni sbagliate circa ciò che le funzionalità richiedono (precondizioni), ed errori di tempistica o di sincronizzazione tra componenti.

Classi di equivalenza

La tecnica delle classi di equivalenza si pone l’obiettivo di dividere il dominio del programma in classi di dati, ovvero gruppi di valori di input che dovrebbero stimolare il programma nella stessa maniera. Non si tratta quindi di classi di equivalenza degli output, ovvero valori che dati in pasto al programma forniscono lo stesso risultato, quanto piuttosto valori che dati in pasto al programma forniscono un risultato diverso ma prodotto nello stesso modo.

Una volta individuate le classi di dati l’obiettivo sarebbe quindi di estrarre da esse casi di test in modo da testare il funzionamento del programma in tutti suoi funzionamenti standard.
In realtà, dunque, si cercano di individuare casi di test che rivelino eventuali classi di equivalenza di errori, ovvero insiemi di valori che generano malfunzionamenti per lo stesso motivo. Classi di equivalenza di questo tipo sono solitamente più “stabili” rispetto alle normali classi di equivalenza in quanto il risultato ottenuto, ovvero l’errore, è spesso lo stesso o molto simile.

Volendo dare una definizione più formale, una classe di equivalenza rappresenta un insieme di stati validi o non validi per i dati in input e un insieme di stati validi per i dati di output, dove per dato si intendono valori, intervalli o insiemi di valori correlati.
È importante comprendere anche i possibili stati non validi in quanto bisogna testare che il programma reagisca bene all’input mal formattato. Ogni dominio avrà quindi almeno due classi di equivalenza:

  • la classe degli input validi
  • la classe degli input non validi

Per fare un esempio si può considerare un programma che chiede in input un codice PIN di 4 cifre. Il suo dominio può quindi essere suddiviso in due semplici classi di equivalenza:

  1. PIN corretto;
  2. tutti i numeri di 4 cifre diversi dal PIN.

Volendo fare un altro esempio, se ci si aspetta che i valori in input ricadano in un intervallo, per esempio \([100, \, 700]\)), si possono definire la classe di equivalenza valida \(x \in [100, 700]\) e la classe di equivalenza non valida \(x \notin [100, 700]\). Per voler aumentare la granularità si può però spezzare la classe degli input non validi in due, ottenendo una classe valida e due non valide:

  1. \(x \in [100, 700]\);
  2. \(x < 100\);
  3. \(x > 700\).

Come si vede, la scelta delle classi di equivalenza da considerare non è univoca, e richiede un minimo di conoscenza di dominio. Alternativamente esistono delle tecniche standard di individuazione delle classi di equivalenza a partire dalle specifiche che prendono il nome di category partition.

Test di frontiera

La tecnica dei test di frontiera è complementare a quella delle classi di equivalenza. Partendo dal presupposto che gli errori tendono ad accumularsi sui casi limite, ovvero quelli la cui gestione è più particolare, questa tecnica suggerisce di selezionare come casi di test non valori a caso all’interno delle classi di equivalenza, ma i valori presenti al confine tra di loro.

Category partition

La tecnica di category partition è un metodologia che permette di caratterizzare e identificare le classi di equivalenza del dominio di un problema a partire dalle sue specifiche. Può essere utilizzata a vari livelli a seconda che si debbano realizzare test di unità, test di integrazione e o test funzionali.

Il metodo è composto da una serie di passi in sequenza:

  1. analisi delle specifiche: in questa fase vengono identificate le unità funzionali individuali che possono essere verificate singolarmente; non necessariamente sono un’unica classe, è sufficiente che siano componenti facilmente separabili dal resto, sia a livello di testing che concettuale. Per ogni unità vengono quindi identificate delle caratteristiche (categorie) dei parametri e dell’ambiente in cui opera;
  2. scegliere dei valori: per ogni categoria, occorre scegliere quali sono i valori sensati su cui fare riferimento;
  3. determinare eventuali vincoli tra le scelte, che non sono sempre indipendenti;
  4. scrivere test e documentazione.

Per capire meglio ciascuna di tali fasi vediamo un’esempio di utilizzo della tecnica di category partition prendendo come soggetto il comando find della shell Linux.

PASSO 1 – analizzare le specifiche

Per prima cosa analizziamo le specifiche del comando:

Syntax: find <pattern> <file>

The find command is used to locate one or more instances of a given pattern in a file. All lines in the file that contain the pattern are written to standard output. A line containing the pattern is written only once, regardless of the number of times the pattern occur in it.

The pattern is any sequence of characters whose length does not exceed the maximum length of a line in the file. To include a blank in the pattern, the entire pattern must be enclosed in quotes ("). To include a quotation mark in the pattern, two quotes ("") in a row must be used.

Vista la relativa semplicità, find è un’unità funzionale individuale che può essere verificata separatamente. Bisogna dunque individuarne i parametri: come è chiaro dalla sintassi essi sono due, il pattern da cercare e il file in cui farlo.

Ora, ciascuno di tali parametri può possedere determinate caratteristiche, ed è nostro compito in questa fase comprenderle ed estrarle.
Tali caratteristiche possono essere di due tipi: esplicite, ovvero quelle ricavabili direttamente dalla lettura specifiche, e implicite, ovvero quelle che provengono dalla nostra conoscenza del dominio di applicazione e che quindi non vengono specificate.

Tornando al nostro caso di studio possiamo per esempio ottenere la seguente tabella:

Oggetto Caratteristiche esplicite Caratteristiche implicite
pattern
  • lunghezza del pattern;
  • pattern tra doppi apici;
  • pattern contenente spazi;
  • pattern contenente apici.
  • pattern tra apici con/senza spazi;
  • più apici successivi inclusi nel pattern.
file
(nome)
(nessuna)
  • caratteri nel nome ammissibili o meno;
  • file esistente (con permessi di lettura) o meno.
file
(contenuto)
  • numero occorrenze del pattern nel file;
  • massimo numero di occorrenze del pattern in una linea;
  • massima lunghezza linea.
  • pattern sovrapposti;
  • tipo del file.

È importante esplicitare le caratteristiche implicite dei parametri dell’unità funzionale perché le specifiche non sono mai complete e solo così possiamo disporre di tutti gli elementi su cui ragionare nelle fasi successive.

Si presti poi attenzione alla distinzione fatta tra il nome del file e il suo contenuto: il primo infatti è un parametro che viene passato al comando per iniziarne l’esecuzione, mentre il secondo fa parte dell’ambiente in cui il comando opera ed è dunque soggetto ad una sottile distinzione concettuale.

ALPHA E BETA TESTING

Spesso, però, analizzare le specifiche non basta per comprendere tutte le variabili che entrano in gioco durante l’esecuzione di un programma. Bisogna infatti ricordare che ci sono moltissime altre caratteristiche d’ambiente che ancora non sono state considerate: la versione del sistema operativo, del browser, il tipo di architettura della macchina su cui gira il programma eccetera.

Spesso, quindi, la fase di testing funzionale si divide in due fasi:

  • alpha testing: l’unità funzionale viene testata in-house, ovvero su una macchina all’interno dello studio di sviluppo. In questa fase si considerano soprattutto le caratteristiche legate alle specifiche di cui sopra;
  • beta testing: per testare varie configurazioni d’ambiente una versione preliminare del programma viene distribuito in un ambiente variegato per osservare come esso si comporta sulle macchine di diversi utenti.

Per il momento, però, consideriamo solo la fase di alpha testing e le categorie ad essa relative.

PASSO 2 – scegliere dei valori

Individuate le caratteristiche dei parametri e delle variabili d’ambiente da cui l’unità funzionale dipende, che prendono il nome di categorie, si passa quindi alla seconda fase.

In questa fase si devono identificati tutti e i soli casi significativi per ogni categoria, ovvero quei valori della stessa che si ritiene abbia senso testare; poiché si tratta di un compito molto soggettivo è importante in questa fase avere esperienza (know-how) nel dominio d’applicazione.

Per capire meglio di cosa stiamo parlando ritorniamo al nostro esempio e consideriamo il parametro pattern. Per ciascuna delle sue categorie possono essere individuati vari casi significativi:

  • lunghezza del pattern: vuoto, un solo carattere, più caratteri, più lungo di almeno una linea del file;
  • presenza di apici: pattern tra apici, pattern non tra apici, pattern tra apici errati;
  • presenza di spazi: nessuno spazio nel pattern, uno spazio nel pattern, molti spazi nel pattern;
  • presenza di apici interni: nessun apice nel pattern, un apice nel pattern, molti apici nel pattern.

È interessante notare il mantra già visto del “nessuno, uno, molti”, spesso molto utile in questa fase.

PASSO 3 – determinare i vincoli tra le scelte

Trovati tutti i valori significativi delle categorie dell’unità funzionale come possiamo costruire i casi di test da utilizzare per verificarne la correttezza?

Si potrebbe pensare di testare tutte le combinazioni di valori significativi, facendo cioè il prodotto cartesiano tra le categorie. Nella pratica, però, ciò risulterebbe in un numero esagerato di casi di test: già solo nel nostro semplice esempio questi sarebbero ben 1944, decisamente troppi.

Nel tentativo di evitare quest’esplosione combinatoria ci si accorge però che spesso le anomalie sorgono dall’interazione di coppie di caratteristiche indipendentemente dal valore assunto da tutte le altre: per esempio, un problema potrebbe presentarsi se si usa il browser Edge sul sistema operativo Linux, indipendentemente da caratteristiche quali la dimensione dello schermo, l’architettura del processore eccetera.
Per ridurre il numero di casi di test si sviluppa quindi la tecnica del pairwise testing, che riduce l’insieme delle configurazioni da testare a tutte le combinazioni di coppie di valori. È quindi presente almeno un caso di test per ogni coppia ipotizzabile di valori: in rete e in Java sono presenti diversi strumenti che permettono di creare casi di test combinati con il metodo pairwise.

Un’ulteriore tentativo di ridurre il numero di casi di test prevede di definire una serie di vincoli per la generazione delle coppie, escludendo particolari combinazioni di caratteristiche: così, per esempio si potrebbe escludere la coppia “OS == MacOs” e “browser == Edge” perché sfruttando la conoscenza di dominio sappiamo che tale browser non è disponibile sul suddetto sistema operativo.
Volendo essere più precisi, la creazione di vincoli prevede un passaggio intermedio: vengono definite una serie di proprietà (es. NotEmpty o Quoted per l’esempio su find) e si creano dei vincoli logici a partire da esse. I vincoli seguono poi una struttura tra le seguenti:

  • se: si può limitare l’uso di un valore solo ai casi in cui è definita una proprietà. Per esempio, è inutile testare il caso “il file non esiste” se la proprietà NotEmpty si manifesta;
  • single: alcune caratteristiche prese singolarmente anche se combinate con altre generano lo stesso risultato. Per esempio, se il file non contiene occorrenze del pattern cercato il risultato del programma è indipendente dal tipo di pattern cercato;
  • error: alcune caratteristiche generano semplicemente errore, come per esempio se si omette un parametro.

PASSO 4 – scrivere i test

Fissati i vincoli e fatti i calcoli combinatori si procede ad enumerare iterativamente tutti i casi di test generati continuando ad aggiungere vincoli fino ad arrivare ad un numero ragionevole.

Ovviamente, i casi di test avranno poi bisogno di valori specifici per le caratteristiche: non basta dire “pattern con apici all’interno”, bisogna creare un pattern aderente a questa descrizione! Fortunatamente questa operazione è solitamente molto facile, anche con tool automatici.

Conclusioni

Per quanto intuitiva e utile, la tecnica di category partition presenta due criticità:

  • individuare i casi significativi delle varie caratteristiche può essere difficile e si può sbagliare, anche utilizzando mantra come “zero, uno, molti”;
  • una volta generati i casi di test serve comunque un “oracolo” che fornisca la risposta giusta, ovvero quella che ci si attende dall’esecuzione sul caso di test. L’attività non è dunque completamente automatizzabile.

Va però detto che esistono delle tecniche di property-based testing che cercano di eliminare la necessità di un oracolo considerando particolari proprietà che dovrebbero sempre valere durante l’esecuzione (invarianti) piuttosto che analizzare il risultato dell’esecuzione dei casi di test per determinare la correttezza del programma.

Object orientation e testing funzionale

Trattandosi di un approccio black box che ragiona sulle funzionalità e non sui dettagli implementativi, l’introduzione del paradigma a oggetti non dovrebbe cambiare nulla per quanto riguarda il testing funzionale. Se questa affermazione è vera per quanto riguarda la verifica di singole unità funzionali, lo stesso non si può dire nel caso di test di integrazione.

Nei linguaggi procedurali i test di integrazione sono infatti scritti secondo logiche alternativamente bottom-up o top-down: esiste cioè un punto di partenza dal quale partire ad aggregare le componenti, seguendo cioè una qualche forma di albero di decomposizione del programma.
Per quanto riguarda la programmazione a oggetti, invece, la situazione è molto più caotica: le relazioni tra le classi sono spesso cicliche e non gerarchiche (tranne per l’ereditarietà — la relazione meno interessante), in una serie di inter-dipendenze che rendono difficoltoso individuare un punto da cui partire a integrare.

Relazioni interessanti in questa fase sono infatti associazioni,aggregazioni o dipendenze, ma rendono complicato identificare il sottoinsieme di classi da testare. Per fare ciò si possono comunque utilizzare alcuni strumenti già visti:

  • si può partire dai diagrammi degli use cases e scenari per testare i componenti citati;
  • si possono osservare i sequence diagram per testare le classi protagoniste delle interazioni a scambio di messaggi descritte;
  • si possono infine usare gli state diagram nella modalità che abbiamo già descritto.

Software inspection

Un’altra classe di tecniche di verifica e convalida è la software inspection, ovvero tecniche manuali per individuare e correggere gli errori basati su una attività di gruppo in cui si analizza il codice insieme passo passo: si pensi per esempio alla tecnica di pair programming già ampiamente citata parlando di XP.

Le tecniche di software inspection sono molto interessanti in quanto hanno pochi requisiti e l’unico tool richiesto è un essere umano che si prenda la briga ispezionare il codice, spesso in riunioni di gruppo da 5-6 persone.

Trattandosi di una tecnica umana essa è molto flessibile: l’oggetto sotto ispezione può essere una porzione di codice non funzionante, una serie di specifiche formali o informali o direttamente l’eseguibile compilato. La software inspection può quindi essere eseguita durante tutte le fasi del ciclo di vita di un programma.

Fagan code inspection

La Fagan code inspection è una metodologia sviluppata da Michael Fagan alla IBM negli anni ‘70. La metodologia prevede che un gruppo di esperti esegua una serie di passi per verificare la correttezza del codice sorgente al fine di individuare eventuali errori, incongruenze o altri problemi.
È la più diffusa tra le tecniche di ispezione, nonché la più rigorosa e definita.

Ruoli

Essendo un’attività di gruppo, nella Fagan code inspection vengono identificati diversi ruoli:

  • Moderatore: è colui che coordina i meeting, sceglie i partecipanti e ha la responsabilità di far rispettare le regole di cui parleremo tra poco. È di solito una persona che lavora ad un progetto diverso da quello in esame in modo da evitare conflitti di interessi.
  • Readers e Testers: non sono persone diverse, semplicemente a seconda dei momenti i partecipanti possono coprire uno di questi due ruoli: i primi leggono il codice al gruppo, mentre i secondi cercano difetti al suo interno. La lettura del codice è una vera e propria parafrasi di esso, ovvero un’interpretazione del codice nella quale si spiega quello che fa ma seguendo comunque la sua struttura.
  • Autore: è colui che ha scritto il codice sotto ispezione; è un partecipante passivo che risponde solo a eventuali domande. È simile al ruolo del cliente nell’eXtreme Programming: pronto a rispondere a qualsiasi domanda per accelerare il lavoro degli altri.

Processo

Definiti i ruoli, secondo la tecnica Fagan di ispezione del codice il processo si articola come segue:

  1. Planning: in questa prima fase il moderatore sceglie i partecipanti, si definiscono i loro ruoli e il tempo da dedicare alla ispezione, pianificando anche i vari incontri.
  2. Overview: viene fornito a tutti i partecipanti materiale sul progetto per permettere loro di farsi un’idea del contesto in cui l’oggetto dell’ispezione si inserisce in ottica della riunione vera e propria.
  3. Preparation: i partecipanti “offline” comprendono il codice e la sua struttura autonomamente sulla base anche del materiale distribuito nella fase precedente;
  4. Inspection: la vera e propria fase di ispezione. In questa fase si verifica che il codice soddisfi le regole definite in precedenza e si segnalano eventuali problemi o anomalie. Durante l’ispezione, il gruppo di esperti esamina il codice riga per riga, confrontandolo con le specifiche e cercando di individuare errori, incongruenze o altri problemi.
  5. Rework: una volta individuati i problemi, l’autore del codice si occupa di correggere i difetti individuati.
  6. Follow-up: possibile re-ispezione del nuovo codice ottenuto dopo la fase precedente.

Se la maggior parte delle fasi è abbastanza autoesplicativa, è bene dare uno sguardo più approfondito all’attività di ispezione vera e propria.

Ispezione

Durante la fase di ispezione, l’obiettivo è trovare e registrare i difetti senza correggerli: la tentazione di correggere i difetti è sicuramente fortissima ma non è compito dei partecipanti alla riunione farlo. Ciascuno di loro potrebbe infatti avere le proprie idee e preferenze e metterli d’accordo potrebbe non essere facile: si preferisce quindi che sia l’autore del codice a correggere successivamente i problemi trovati.

Per evitare ulteriormente di perdere tempo sono previste al massimo 2 sessioni di ispezione di 2 ore al giorno, durante le quali lavorare approssimativamente a 150 linee di codice all’ora. Quest’ultimo vincolo è molto variable in quanto cambia in base al linguaggio, al progetto, all’attenzione ai dettagli richiesta e alla complessità.

Una possibilità prevista in questa fase è anche quella di fare “test a mano”: si analizza il flusso di controllo del programma su una serie di casi di test così da verificarne il funzionamento.

Ancora più prominente è però l’uso di una serie di checklist, di cui parliamo nel prossimo paragrafo.

Checklist

Rispetto all’attività di testing, che a partire dai malfunzionamenti permetteva di risalire ai difetti e dunque agli sbagli commessi, il thought-process per le checklist è inverso: si parte dagli sbagli che più frequentemente hanno portato ad inserire determinate anomalie nel codice e si controlla che nessuno di questi sia stato commesso nuovamente.

In letteratura è reperibile la conoscenza di tutto ciò che è meglio evitare poiché in passato ha portato più volte ad avere anomalie nel codice. Tale conoscenza è raccolta in checklist comuni per i vari linguaggi.

Inoltre, l’ispezione del codice funziona così bene anche perché tali checklist possono essere redatte internamente all’azienda, in base all’esperienza passata e alla storia di un determinato progetto.
Man mano che il progetto va avanti, l’individuazione di un nuovo sbaglio si traduce in un’evoluzione della checklist: dalla prossima ispezione si controllerà di non aver commesso lo stesso errore.

Esempio NASA

La NASA nel suo Software Formal Inspections Guidebook (1993) ha formalizzato circa 2.5 pagine di checklist per C e 4 per FORTRAN.

Sono divise in functionality, data usage, control, linkage, computation, maintenance e clarity.

Di seguito sono elencati alcuni esempi dei punti di tali checklist:

  • Does each module have a single function?
  • Does the code match the Detailed Design?
  • Are all constant names upper case?
  • Are pointers not typecast (except assignment of NULL)?
  • Are nested #include files avoided?
  • Are non-standard usage isolated in subroutines and well documented?
  • Are there sufficient comments to understand the code?

Struttura di incentivi

Perché l’ispezione del codice come è stata descritta funzioni bene, occorre prevedere una serie di dinamiche positive di incentivi al suo interno.

In particolare, è importante sottolineare che i difetti trovati non devono essere utilizzati per la valutazione del personale: questo evita che i programmatori nascondano i difetti nel proprio codice, minando la qualità del prodotto.

Dall’altro canto si possono invece considerare per la valutazione di readers e tester i difetti trovati durante l’ispezione, in modo che questi siano incentivati a fare l’ispezione più accurata possibile.

Variante: active design reviews

Purché il processo di ispezione funzioni al meglio le persone coinvolte devono partecipare, ma per come abbiamo descritto l’attività di Fagan Code Inspection nulla vieterebbe ai revisori non preparati di essere presenti ma non partecipare, rimanendo in silenzio e pensando ad altro.

Innanzitutto, per sopperire a questo problema i partecipanti andrebbero scelti tra persone di adeguata esperienza e sopratutto assicurando che nel team vi siano revisori per diversi aspetti nel progetto.

Qualora questo non bastasse, una variante del processo che prende il nome di active design review suggerisce che sia l’autore a leggere le checklist e sollevare questioni all’attenzione dei revisori, chiedendo diverse domande. Essendo presi direttamente in causa, i revisori saranno quindi costretti a partecipare.

Automazione

Sebbene l’ispezione del codice sia una tecnica manuale esistono diversi strumenti di supporto automatici in grado di velocizzare notevolmente il lavoro. Alcuni di questi tool possono aiutare con:

  • controlli banali, come la formattazione; in fase di ispezione manuale si controllerà poi il risultato del controllo automatico;
  • riferimenti: checklist e standard in formati elettronici facilmente consultabili e compilabili;
  • aiuti alla comprensione del codice: tool che permettono di navigare e leggere il codice con maggiore facilità e spesso utilizzati durante attività di re-engineering;
  • annotazione e comunicazioni del team, come l’email;
  • guida al processo e rinforzo: non permettere di chiudere il processo se non sono stati soddisfatti alcuni requisiti (come la necessità di approvazione prima del merge di una pull request).

Pro e contro

Finito di descrivere il processo di ispezione del software possiamo chiederci: funziona? Prove empiriche parrebbero suggerire che la risposta sia e evidenziano anche che tale tecnica è particolarmente cost-effective.

I vantaggi dell’uso di questa tecnica di verifica e convalida sono infatti numerosi:

  • Esiste un processo rigoroso e dettagliato;
  • Si basa sull’accumulo dell’esperienza, auto-migliorandosi con il tempo (vd. checklist);
  • Il processo integra una serie di incentivi sociali che spingono l’autore del codice ad analizzarlo in modo critico;
  • A differenza del testing è possibile per la mente umana astrarre il dominio completo dei dati, considerando quindi in un certo senso tutti i casi di test;
  • È applicabile anche a programmi incompleti.

La software inspection funziona così bene che è spesso utilizzata come baseline per valutare altre tecniche di verifica e convalida.

Questo non significa però che essa sia esente da limiti.
Innanzitutto il test può essere fatto solo a livello di unità in quanto la mente umana ha difficoltà a lavorare in situazioni in cui sono presenti molte informazioni contemporaneamente in assenza di astrazioni e indirettezze. Inoltre la software inspection non è incrementale: spesso infatti la fase di follow-up non è così efficace, in quanto il codice è cambiato talmente tanto che è necessario ricominciare l’ispezione da capo.

Ciò non toglie però che, come afferma la Legge di Fagan (L17):

Le ispezioni aumentano in maniera significativa la produttività, qualità e la stabilità del progetto.

Confronto tra tecniche di verifica e convalida

Numerosi studi hanno provato a confrontare l’efficacia di varie tecniche di testing, con particolare riferimento a testing strutturale, testing funzionale e software inspection. Un articolo del 2004 riporta in una tabella di confronto i risultati di alcuni di questi studi, considerando come metrica di valutazione delle tecniche di verifica e convalida la percentuale media di difetti individuati.

Come si può notare, a seconda dello studio appare più efficace l’una o l’altra tecnica; inoltre, la somma delle percentuali per ogni riga non è 100%, il che significa che alcuni difetti non possono essere rilevati da nessuna delle tre tecniche.
Nonostante ciò, si possono fare una serie di osservazioni: innanzitutto, l’efficacia di una o dell’altra tecnica dipende dalla tipologia del progetto su cui viene esercitata. Inoltre, non è detto che tecniche diverse trovino gli stessi errori: l’ispezione potrebbe aver trovato una certa tipologia di errore mentre il testing funzionale un’altra; le diverse tecniche controllano infatti diversamente aspetti differenti del programma, osservandolo da diversi punti di vista.

Confrontare le varie tecniche non è dunque necessariamente una perdita di tempo, mentre lo è sicuramente confrontare solo i numeri, come la varietà di risultati diversi ottenuti dai parte di studi diversi. Tra l’altro, dal riassunto della tabella si perdono informazioni sulle modalità di rilevazione dei dati, attribuendole ad espressioni generiche (come comunemente, in media, progetti junior, …).

In conclusione, non c’è una risposta semplice al confronto e non esiste una tecnica sempre migliore rispetto alle altre.

Combinare le tecniche

Una domanda che sorge spontanea è chiedersi quindi cosa può succedere se si combinano insieme diverse tecniche di verifica e convalida.

Diversi studi mostrano che applicando tutte e quattro le tecniche qui descritte — anche se solo in modo superficiale — il risultato è sicuramente più performante delle tecniche applicate singolarmente.

Anche se una certa percentuale di errori può essere rilevata senza alcuna tecnica formale di verifica e convalida, semplicemente usando il software, si può infatti notare ciascuna tecnica presa singolarmente migliora tale percentuale e che la combinazione di tecniche diverse la incrementa ulteriormente. Questo perché tendenzialmente ogni tecnica controlla aspetti differenti e le rispettive aree di controllo si sovrappongono poco: è dunque conveniente applicare superficialmente ciascuna tecnica piuttosto che una sola tecnica in modo molto approfondito.

In conclusione, come afferma la Legge di Hetzel-Meyer (L20):

Una combinazione di diversi metodi di V/C supera qualsiasi metodo singolo.

Gruppi di test autonomi

È convinzione comune che colui che ha sviluppato un pezzo di codice sia la persona meno adatta a testarlo, come afferma la Legge di Weinberg (L23):

Uno sviluppatore non è adatto a testare il suo codice.

Di conseguenza, si preferisce spesso che il testing sia affidato ad un gruppo di tester autonomi. Questo implica infatti una serie di vantaggi, sia tecnici che e psicologici:

  • Aspetti tecnici:
    • maggiore specializzazione: si evita così di richiedere che i propri sviluppatori siano anche esperti di testing;
    • maggiore conoscenze delle tecniche di verifica e convalida e dei relativi tool: chi fa il tester di lavoro acquisisce competenze specifiche sui tool e sugli strumenti di testing (spesso complessi), oltre che sui concetti di copertura e mutazioni.
  • Aspetti psicologici:
    • distacco dal codice: a causa dell’assenza di modelli mentali precedenti su come il software dovrebbe operare, un tester esterno pone maggiore attenzione agli aspetti spesso trascurati o dimenticati;
    • indipendenza nella valutazione: una persona che testa il proprio codice è incentivata a non trovare molti errori in quanto potrebbe suggerire un lavoro di dubbia qualità in fase di sviluppo. Un gruppo specializzato nel testing è invece incentivato a trovarne il più possibile per giustificare il loro impiego.

Ci sono tuttavia anche una serie di svantaggi legati all’avere un gruppo di tester autonomo. Innanzitutto, i problemi più ovvi sono legati all’aspetto tecnico: il fatto che i tester diventino specializzati nel testing significa che perderanno con il tempo la capacità di progettare e codificare, oltre a possedere una minore conoscenza dei requisiti del progetto.

Nell’analisi di Elisabeth Hendrickson denominata “Better testing — worse quality?” viene analizzata poi la tecnica sotto un punto di vista psicologico: come è possibile che un maggior investimento nel team di testing porti a un calo delle prestazioni in termini di numero di errori nel codice?

La risposta pare dipendere dal concetto di responsabilità: seppur vero che l’attività di testing è compito del tester, è anche vero che è lo sviluppatore stesso che ha il compito di fare test di unità del proprio codice — il team di testing dovrebbe occuparsi solo di quello funzionale o di integrazione. Spesso però, a fronte di un aumento del personale nel team di testing e specialmente quando una deadline è vicina, il team di sviluppo tende a spostare la responsabilità di trovare gli errori ai tester, abbassando la qualità del codice.
Il team di testing troverà sì gli errori, riconsegnando il codice agli sviluppatori per correggerli, ma questo passaggio ulteriore implica una notevole perdita di tempo e risorse.

Inoltre, la presenza di un team di testing dedicato può generare pressioni negative sul team di sviluppo: ogni sviluppatore potrebbe sentirsi sotto costante valutazione da parte del team di testing.

Possibili alternative

Una possibile soluzione alle criticità appena evidenziate consisterebbe nella rotazione del personale: una stessa persona potrebbe ricoprire il ruolo di sviluppatore per un progetto e di tester per un altro. Questo approccio mostra diversi vantaggi, tra cui:

  • evitare pressioni negative: ricoprendo diversi ruoli in diversi progetti, il personale non si dovrebbe sentire giudicato o giudicante;
  • evitare il progressivo depauperamento tecnico dovuto ad all’eccessiva specializzazione;
  • evitare lo svuotamento dei ruoli.

C’è però da considerare un certo aumento dei costi di formazione per via del raddoppio delle responsabilità individuali e un parallelo aumento della difficoltà di pianificazione: potrebbe succedere che la stessa persona debba lavorare a più progetti contemporaneamente, dovendo quindi dividere il proprio tempo e le proprie competenze.

Un’altra possibile alternativa consiste nella condivisione del personale, che prevede che siano gli stessi sviluppatori a occuparsi del testing: ciò permette di sopperire al problema di scarsa conoscenza del software in esame e del relativo dominio applicativo ma, oltre a far riemergere le criticità individuate precedentemente, aumenta le difficoltà nella gestione dei ruoli.

Modelli statistici di distribuzione degli errori

Negli ultimi tempi si stanno sviluppando una serie di modelli statistici sulla distribuzione degli errori nel codice che dovrebbero teoricamente aiutare l’attività di testing guidandola verso le porzioni di sorgente che più probabilmente potrebbero presentare difetti.

Tali modelli propongono infatti una correlazione statistica tra una serie di metriche quali la lunghezza del codice, il tipo di linguaggio, il grado massimo di indentamento etc. e:

  • la presenza di errori per categoria di errore;
  • il numero di errori per categoria di errore.

L’idea sarebbe quindi di predire la distribuzione e il numero di errori all’interno di uno specifico modulo del software in esame.

Occorre però fare attenzione alle conclusioni di queste statistiche. Utilizzare i risultati di tali modelli statistici come indicazioni sul fatto che su determinati moduli vada fatta più attività di testing rispetto ad altri potrebbe inizialmente sembrare la soluzione più logica. Tuttavia, tali risultati non considerano l’attività di testing già effettuata e le correzioni successive e quindi non cambiano: codice inizialmente “scritto male” secondo il modello rimarrà per sempre scritto male, anche se testato estensivamente.

Con ciò in mente, si cita spesso la Legge di Pareto/Zipf (L24):

Circa l’80% dei difetti proviene dal 20% dei moduli.

Sebbene tale affermazione è indubbiamente probabilmente vera, è difficile sfruttare questa nozione in quanto non sono conosciuti in principio i moduli particolarmente problematici, e il testing è comunque necessario anche in tutti gli altri.

Debugging

Il debugging è l’insieme di tecniche che mirano a localizzare e rimuovere le anomalie che sono la causa di malfunzionamenti riscontrati nel programma. Come già detto, esso non è invece utilizzato per rilevare tali malfunzionamenti.

Il debugging richiede una comprensione approfondita del codice e del funzionamento del programma e può essere un processo complesso e articolato. Tuttavia, può contribuire in modo significativo a migliorare la qualità e la stabilità del codice, oltre che a risolvere malfunzionamenti.

Trattandosi di ricerca delle anomalie che generano malfunzionamenti noti, l’attività è definita per un programma e un insieme di dati che causano malfunzionamenti. Essa si basa infatti sulla riproducibilità del malfunzionamento, verificando prima che non sia dovuto in realtà a specifiche in errate.

Si tratta di un’attività molto complicata, come fa notare Brian W. Kernighan nella sua famosa citazione:

“Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it”.

È dunque importante scrivere codice più semplice possibile in modo tale da poterne fare un altrettanto semplice debugging laddove necessario.

Perché è così difficile?

L’attività di debugging è particolarmente complessa soprattutto perché non è sempre possibile individuare con precisione la relazione anomalia-malfunzionamento. Non è un legame banale, in quanto potrebbero esserci anomalie che prima di manifestarsi sotto forma di malfunzionamenti abbiano avuto molte evoluzioni.

Inoltre, non esiste una relazione biunivoca tra anomalie e malfunzionamenti: non è detto che un’anomalia causi un unico malfunzionamento, ma nemmeno che un malfunzionamento sia causato da un’unica anomalia.

Un altro problema è dovuto al fatto che la correzione di anomalie non garantisce affatto un software migliore o con meno errori: per correggere un’anomalia è necessario per forza di cose anche modificare il codice sorgente, ma ogni volta che viene fatto si apre la possibilità di introdurre nuove anomalie nel codice stesso.

Tecnica naïve

La tecnica di debugging maggiormente utilizzata dai programmatori consiste nell’introdurre nel modulo in esame una serie di comandi di output (es. print) che stampino su console il valore intermedio assunto dalle sue variabili. Questo permetterebbe di osservare l’evoluzione dei dati e, si spera, di comprendere la causa del malfunzionamento a partire da tale storia.

Nonostante sia facile da applicare, si tratta in realtà di una tecnica molto debole: non solo essa richiede la modifica del codice (e quindi una rimozione di tali modifiche al termine), ma è poco flessibile in quanto richiede una nuova compilazione per ogni stato esaminato.
Bisogna inoltre considerare che questa tecnica testa un programma diverso da quello originale che presenta delle print aggiuntive solo apparentemente innocue e senza effetti collaterali.

L’unico scenario (irrealistico) in cui la tecnica potrebbe essere considerata sufficiente sarebbe nel caso in cui il codice sia progettato talmente bene e il modulo così ben isolato che basterebbe scrivere un’unica print per risalire all’anomalia.

Tecnica naïve avanzata

Un miglioramento parziale alla tecnica appena descritta si può ottenere sfruttando le funzionalità del linguaggio oppure alcuni tool specifici per il debug, come per esempio:

  • #ifdef e gcc -D per il linguaggio C;
  • librerie di logging (con diverso livello), che permettono peraltro di rimuovere i messaggi di log in fase di produzione del codice;
  • asserzioni, ovvero check interni al codice di specifiche proprietà: possono essere visti anche come “oracoli” interni al codice che permettono di segnalare facilmente stati illegali.

Ciò non toglie che la tecnica sia comunque naïve, in quanto si sta ancora modificando il codice in modo che fornisca informazioni aggiuntive.

Dump di memoria

Una tecnica lievemente più interessante è quella dei dump di memoria, che consiste nel produrre un’immagine esatta della memoria del programma dopo un passo di esecuzione: si scrive cioè su un file l’intero contenuto della memoria a livello di linguaggio macchina (nei sistemi a 32 bit, la dimensione dei dump può arrivare fino a 4GB).

Segmentation fault (core dumped)

Sebbene questa tecnica non richieda la modifica del codice, essa è spesso difficile da applicare a causa della differenza tra la rappresentazione astratta dello stato (legata alle strutture dati del linguaggio utilizzato) e la rappresentazione a livello di memoria di tale stato. Viene inoltre prodotta una enorme mole di dati per la maggior parte inutili.

Debugging simbolico

Il prossimo passo è invece il cosiddetto debugging simbolico, un’attività che utilizza tool specifici di debugging per semplificare la ricerca delle anomalie che causano il malfunzionamento in esame. Tali strumenti permettono di osservare in tempo reale l’esecuzione del programma, sollevando una cortina e rendendo possibile analizzare l’evoluzione del valore delle variabili passo per passo: questi tool non alterano il codice ma come esso è eseguito.

A tal proposito, i debugger simbolici forniscono informazioni sullo stato delle variabili utilizzando lo stesso livello di astrazione del linguaggio utilizzato per scrivere il codice: gli stati sono cioè rappresentati con stessi simboli per cui le locazioni di memoria sono state definite (stesse strutture dati), rendendo quindi utile e semplice l’attività di ispezione dello stato.

In aggiunta, i debugger simbolici forniscono ulteriori strumenti che permettono di visualizzare il comportamento del programma in maniera selettiva, come per esempio watch e spy monitor.

Per regolare il flusso del programma è poi possibile inserire breakpoint e watchpoint su certe linee di codice che ne arrestino l’esecuzione in uno specifico punto, eventualmente rendendoli dipendenti dal valore di variabili. Volendo poi riprendere l’esecuzione si può invece scegliere la granularità del successivo passo:

  • singolo: si procede alla linea successiva;
  • dentro una funzione: si salta al codice eseguito dalle funzioni richiamate sulla riga corrente;
  • drop/reset del frame: vengono scartate le variabili nel frame d’esecuzione ritornando ad una situazione precedente.

Debugging per prova

Molti debugging simbolici permettono non solo di visualizzare gli stati ottenuti, ma anche di esaminarli automaticamente in modo da verificarne la correttezza.

In particolare, utilizzando watch condizionali è possibile aggiungere asserzioni a livello di monitor, verificando così che certe proprietà continuino a valere durante l’intera esecuzione.
Così, per esempio, è possibile chiedere al monitor (l’esecutore del programma) di controllare che gli indici di un array siano sempre interni all’intervallo di definizione.

Altre funzionalità dei debugger

Ma non finisce qui! I debugger moderni sono strumenti veramente molto interessanti, che permettono per esempio anche di:

  • modificare il contenuto di una variabile (o zona di memoria) a runtime;
  • modificare il codice: nonostante non sia sempre possibile, può essere comodo per esempio dopo tante iterazioni di un ciclo;
  • ottenere rappresentazioni grafiche dei dati: strutture dinamiche come puntatori, alberi e grafi possono essere rappresentate graficamente per migliorare la comprensione dello stato.

Automazione

Visti tutti questi fantastici tool può sorgere una domanda: l’attività di debugging può essere automatizzata?

Andreas Zeller tratta questo argomento in maniera approfondita nel suo Debugging Book, proponendo alcune direzioni di sviluppo di ipotetici strumenti di debugging automatico.
I due concetti principali della trattazione sono i seguenti:

  • shrinking input: dato un input molto grande e complesso che causa un malfunzionamento, strumenti automatici possono aiutare a ridurlo il più possibile in modo da semplificare il debugging;
  • differential debugging: dato lo stesso input, in maniera automatica vengono esplorati gli stati del programma mutando ad ogni iterazione piccole porzioni di codice per individuare dove è più probabile che si trovi l’anomalia.

Purtroppo per il momento la prospettiva di debugger automatici è ancora lontana.
Tuttavia, esiste già qualcosa di simile, vale a dire il comando git bisect di Git: data una versione vecchia in cui il bug non è presente, una versione nuova in cui esso si è manifestato e un oracolo che stabilisce se il bug è presente o meno, Git esegue una ricerca dicotomica per trovare la versione che ha introdotto il problema. Sebbene non sia proprio la stessa cosa, si tratta sicuramente di uno strumento utile.