đ Approche structurelle
1. Présentation
L’approche structurelle considĂšre comme point de dĂ©part le code source du logiciel testĂ©. Le but est de mettre au point suffisament de scĂ©narios de test pour couvrir efficacement le code source, selon diffĂ©rents critĂšres (instructions, conditions, chemins, etc.). On qualifie aussi cette mĂ©thode de boĂźte blanche car elle nĂ©cessite lâanalyse du code source du logiciel.
Cette mĂ©thode ne peut donc s’appliquer que si on a accĂšs au code source, et si on en possĂšde une comprĂ©hension suffisante.
Le point de dĂ©part de cette mĂ©thode est la modĂ©lisation du logiciel sous la forme d’un graphe de flĂŽt de contrĂŽle (CFG), et de s’en servir pour trouver les meilleurs donnĂ©es d’entrĂ©e permettant d’obtenir la meilleure couverture.
| Cette mĂ©thode repose tout de mĂȘme sur la spĂ©cification du logiciel pour la dĂ©finition des oracles des scĂ©narios de test. |
2. Modélisation en graphe de flot de contrÎle
2.1. Définition et exemples
Un graphe de flot de contrĂŽle (en anglais, control flow graph, ou CFG) modĂ©lise l’ensemble des chemins potentiels qui existent au sein d’un programme, afin de pouvoir notamment formaliser des mĂ©triques ou des critĂšres de couverture basĂ©s sur ces chemins.
-
Le graphe de flot de contrĂŽle d’un programme est un graphe orientĂ© composĂ© :
-
d’un ensemble de sommets, chaque sommet pouvant reprĂ©senter au choix :
-
l'entrée du programme nommée E,
-
la sortie du programme nommée S,
-
un bloc Ă©lĂ©mentaire du programme, c’est Ă dire une sĂ©quence d’instructions et/ou de prĂ©dicats toujours exĂ©cutĂ©s ensemble â par convention on nommera chaque bloc Ă©lĂ©mentaire en fonction des lignes de code auquelles il se rapporte (ex.
1â3signifie le bloc Ă©lĂ©mentaire pour les lignes 1 Ă 3),
-
-
et d’un ensemble d'arcs pouvant reprĂ©senter au choix :
-
une prise de dĂ©cision, c’est Ă dire une des Ă©valuations possibles d’un prĂ©dicat d’une conditionnelle ou d’une boucle,
-
le passage automatique d’un bloc Ă©lĂ©mentaire Ă un autre.
-
-
| Pour factoriser graphiquement plusieurs arcs qui vont tous vers le mĂȘme nĆud, on peut dessiner un petit rond blanc de jonction qui ne correspond Ă aucun bloc Ă©lĂ©mentaire. ATTENTION : c’est juste une astuce graphique, cela n’ajoute pas rĂ©ellement de nĆud ni d’arc au CFG ! |
Avec un petit programme on obtient le CFG suivant, avec trois sommets correspondant aux blocs Ă©lĂ©mentaires, un sommet pour l’entrĂ©e du programme, un sommet pour la sortie du programme, deux arcs correpondants aux deux Ă©valuations possibles du prĂ©dicat a, et deux arcs correspondants aux passages automatiques vers la sortie :
|
|
On peut ainsi représenter graphiquement les CFGs des principales structures de contrÎle (if, while, for, etc.) :
Pour mieux comprendre, quelques exemples :
|
|
public static double divide(int x, int y) {
if (y == 0) {
throw new Exception("Cannot divide by zero!");
}
return (double)x / (double)y;
}
Une exception est donc juste un sommet avec une transition vers la sortie, comme un return.
| Cette fonction a Ă©tĂ© reprise de cette page de Rosetta Code. Il n’est pas demandĂ© de comprendre ce qu’elle fait : elle sert ici uniquement d’exercice de comprehension structurelle du code. |
public static void stringMatch(String one, String two) {
boolean match = false;
if (one.charAt(0) == two.charAt(0)) {
System.out.println(match = true);
} else {
System.out.println(match);
}
for (int i = 0; i < two.length(); i++) {
int temp = i;
for (int x = 0; x < one.length(); x++) {
if (two.charAt(temp) == one.charAt(x)) {
System.out.println(match = true);
i = two.length();
}
}
}
int num1 = one.length() - 1;
int num2 = two.length() - 1;
if (one.charAt(num1) == two.charAt(num2)) {
System.out.println(match = true);
} else {
System.out.println(match = false);
}
}
On peut noter qu’il est nĂ©cessaire de dĂ©couper la dĂ©claration de chaque boucle for en trois parties :
-
L’initialisation, (init) qui n’a lieu qu’une seule fois :
-
L’Ă©valuation de la condition (cond), qui a lieu au dĂ©but puis Ă chaque tour de boucle :
-
L’incrĂ©mentation (incr), qui a lieu Ă chaque tour de boucle.
On peut voir que des appels de la méthode charAt sont utilisés dans ce code.
Or, il est possible que charAt envoie une exception si l’indice passĂ© en argument est invalide.
Ce CFG est donc lĂ©gĂšrement incomplet, et nous allons voir juste aprĂšs comment reprĂ©senter les envois d’exception.
|
2.2. Appels de méthodes pouvant émettre des exceptions
Dans un langage comme Java, il est possible qu’un appel de mĂ©thode entraĂźne un envoi d’exception, et interrompe par consĂ©quent le flot d’exĂ©cution. On peut distinguer principalement deux cas :
-
l’appel de mĂ©thode est rĂ©alisĂ© au sein d’une construction
try/catch, ainsi si la mĂ©thode envoie une exception on poursuit l’exĂ©cution dans le bloccatchassociĂ©, -
l’appel de mĂ©thode est rĂ©alisĂ© en dehors d’une construction
try/catch, ainsi si la méthode envoie une exception on sort de la méthode (presque comme unreturn).
Par exemple, si on considÚre la méthode suivante :
divide qui envoie une exception/**
* Divide two numbers.
* @param dividend the dividend
* @param divisor the divisor
* @return the result of the division
* @throws ArithmeticException if divisor is zero
*/
public static double divide(double dividend, double divisor) throws ArithmeticException { ⊠}
On peut voir qu’il est clairement annoncĂ© qu’une exception ArithmeticException sera envoyĂ©e en cas de diviseur Ă©gal Ă zĂ©ro.
Par consĂ©quent, si on Ă©crit le code suivant qui utilise cette mĂ©thode au sein d’un try/catch :
divide dans un try/catchpublic void compute1(double x, double y) {
double z = 0;
try {
z = divide(x, y);
} catch (ArithmeticException e) {
System.out.println("A problem occurred");
}
return z + 10;
}
On peut représenter ce code en CFG de la maniÚre suivante :
compute1Et si on écrit le code suivant qui utilise cette méthode sans try/catch :
divide sans try/catchpublic void compute2(double x, double y) throws ArithmeticException {
double z = divide(x, y);
return z + 10;
}
On peut représenter ce code en CFG de la maniÚre suivante :
compute2Il peut cependant arriver qu’un appel de mĂ©thode ne puisse pas entraĂźner d’envoi d’exception, par exemple si une vĂ©rification est rĂ©alisĂ©e en amont de l’appel.
Par conséquent, si on écrit le code suivant :
divide sans envoi possible d’exceptionpublic void compute3(double x, double y) throws ArithmeticException {
if (y == 0) {
return -1;
}
double z = divide(x, y);
return z + 10;
}
Ici il est strictement impossible que divide envoie une exception, car y ne pourra jamais ĂȘtre Ă©gal Ă zĂ©ro.
On peut alors reprĂ©senter ce code en CFG de maniĂšre simplifiĂ©e, sans prendre en compte le possible envoi d’une ArithmeticException lors de l’appel Ă divide :
compute3Objects.requireNonNull dans une mĂ©thode sumAu sein de la classe utilitaire java.util.Objects, la librairie standard Java fournit la mĂ©thode suivante permettant de vĂ©rifier qu’un objet ne soit pas null :
requireNonNull (reformulé depuis la documentation Java)/**
* Checks that the specified object reference is not null.
*
* @param obj the object reference to check for nullity
* @return obj if not null
* @throws NullPointerException if obj is null
*/
public static Object requireNonNull(Object obj) throws NullPointerException { ⊠}
On fait usage de requireNonNull dans la méthode suivante :
sum qui utilise requireNonNullpublic void sum(List<Integer> values) throws NullPointerException {
Objects.requireNonNull(values);
int result = 0;
for (int value : values){
result = result + value;
}
return result;
}
On peut reprĂ©senter ce code en CFG de la maniĂšre suivante, en prenant bien en compte l’envoi d’exception possible de la part de requireNonNull :
sum| Ă la simple lecture du code, il n’est pas toujours facile de prĂ©voir qu’une exception est susceptible de se produire lors d’un appel de mĂ©thode. Il est donc nĂ©cessaire d’arbitrer au cas par cas chaque appel de mĂ©thode, lors de la rĂ©alisation d’un CFG. |
2.3. Décomposition des conditionnelles
Il arrive frĂ©quemment dans un programme qu’une conditionnelle prenne la forme :
-
d’une disjonction : x1 âš x2 âš x3 (typiquement Ă©crit en code
x1 || x2 || x3), -
ou d’une conjonction : x1 â§ x2 â§ x3 (typiquement Ă©crit en code
x1 && x2 && x3).
Ainsi, si un programme prend la forme suivante :
if (x1 || x2 || x3) {
do();
}
On serait tenté de représenter le graphe de flot de contrÎle suivant :
Cependant, deux inconvénients principaux apparaissent avec cette tentative.
D’abord, la structure de graphe ne fait pas apparaĂźtre le fait qu’il existe plusieurs maniĂšre de passer du sommet 1 au sommet 2, du fait de la disjonction : soit via la condition x1, soit via la condition x2, soit via la condition x3 Le mĂȘme problĂšme apparaĂźt dans le cas d’une conjonction, car le CFG ne ferait pas apparaĂźtre qu’il y a plusieurs maniĂšres de ne pas passer du sommet 1 au sommet 2.
Ensuite, la structure de graphe ne reproduit pas fidĂšlement la rĂ©alitĂ© lorsque le calcul de la conditionnelle est effectuĂ© avec "court-circuit" â ce qui est le cas dans un grand nombre de langages de programmation, notamment Java. L’Ă©valuation avec court-circuit, signifie qu’une disjonction/conjonction est toujours Ă©valuĂ©e de maniĂšre Ă arrĂȘter le calcul dĂšs que le rĂ©sultat est assurĂ©ment vrai ou faux.
-
Dans le cas d’une disjonction Ă©crite x1 âš x2 âš x3, on regarde d’abord la valeur de x1.
-
Si x1 est vrai, le calcul s’arrĂȘte sans regarder x2 ou x3.
-
Si x1 est faux, on regarde la valeur de x2, et ainsi de suite.
-
-
Dans le cas d’une conjonction Ă©crite x1 â§ x2 â§ x3, on regarde d’abord la valeur de x1.
-
Si x1 est faux, le calcul s’arrĂȘte sans regarder x2 ou x3.
-
Si x1 est vrai, on regarde la valeur de x2, et ainsi de suite.
-
Ainsi, pour rĂ©gler ces deux inconvĂ©nients, une solution est de faire apparaĂźtre explicitement l’Ă©valuation avec court-circuit de la maniĂšre suivante :
Ce graphe fait apparaĂźtre une rend visible qu’il existe diffĂ©rentes maniĂšres d’exĂ©cuter ce programme, en fonction des valeurs de x1, x2 et x3.
Pour une conjonction, le résultat est similaire mais inversé :
if (x1 && x2 && x3) {
do();
}
/**
* Calcule le PGCD de deux nombres strictement positifs.
* @param p le premier nombre
* @param q le second nombre
* @return le PGCD de p et q
* @throws IllegalArgumentException si p ou q ne sont pas strictement positifs
*/
public static int pgcd(int p, int q) {
if (p < 1 || q < 1) {
throw new IllegalArgumentException("p et q doivent ĂȘtre strictement positifs");
}
while (p != q) {
if (p > q) {
p = p - q;
} else {
q = q - p;
}
}
return p;
}
| On peut noter l’utilisation graphique d’une jonction aprĂšs les nĆuds 14 et 16, pour Ă©viter de tirer deux flĂšches vers le nĆud 12. Attention, c’est juste une astuce graphique, car en vĂ©ritĂ© on doit bien compter qu’il existe un arc entre 14 et 12, ainsi qu’un arc entre 16 et 12 ! |
isPalindromeOn s’intĂ©resse au code qui implĂ©mente l’opĂ©ration principale de PALINDROME, Ă savoir isPalindrome :
public class BasicPalindrome implements Palindrome {
@Override
public boolean isPalindrome(String word) throws IllegalArgumentException, NullPointerException {
Objects.requireNonNull(word);
if (word.isEmpty() || !Pattern.matches("[\\p{Alpha}]*", word)) {
throw new IllegalArgumentException();
}
for (int i = 0; i< word.length() / 2; i++ ) {
char f = word.charAt(i);
char l = word.charAt(word.length() - i - 1);
if (f != l) {
return false;
}
}
return true;
}
En prenant en compte la spécification de requireNonNull vue dans un précédent exemple, on en déduit le CFG suivant :
isPalindrome
Bien que le CFG proposé prenne en compte la possibilité que requireNonNull émette une exception, on ne représente ici pas les exceptions que pourraient en principe envoyer les méthodes matches, length et charAt, car il est garanti que word ne soit jamais null dÚs la ligne 4, et que word ne soit jamais vide dÚs la ligne 8.
|
2.4. Décomposition des opérateurs ternaires
L'opĂ©rateur ternaire â gĂ©nĂ©ralement symbolisĂ© par ? â permet de dĂ©finir des expressions conditionnelles de maniĂšre compacte, sans nĂ©cessiter de conditionnelle complĂšte avec if.
Par exemple cette ligne :
String s = x ? "Yes" : "No";
signifie que s prend la valeur "Yes" si x est vrai, et prend la valeur "No" si x est faux.
En terme de graphe de flot de contrĂŽle, puisque cette expression occupe une seule ligne, on serait tentĂ© de la reprĂ©senter par un unique sommet. Cependant, cela ne ferait pas apparaĂźtre qu’il y structurellement deux maniĂšres diffĂ©rentes d’exĂ©cuter cette ligne â ou, dit autrement, que l’on peut exĂ©cuter une partie diffĂ©rente de cette ligne en fonction de la condition posĂ©e.
Ainsi, on dans un graphe de flot de contrÎle, on préférera décomposer un opérateur ternaire de la maniÚre suivante :
3. Ălaborer des chemins et leurs scĂ©narios de test
3.1. Chemins d’exĂ©cution
L’idĂ©e principale de cette mĂ©thode est d’exploiter le graphe de flot de contrĂŽle (CFG) pour choisir les donnĂ©es d’entrĂ©es des scĂ©narios de test. Pour cela, on raisonne sur les diffĂ©rentes maniĂšres de parcourir le CFG lorsque le code est
On appelle chemin d’exĂ©cution une suite dâarcs (ou une suite de sommets) rencontrĂ©s dans le graphe, en partant du sommet d’entrĂ©e, et en allant jusqu’au sommet d’arrivĂ©e.
DĂšs lors qu’un CFG possĂšde une boucle (du fait d’un while ou d’un for), alors le nombre de chemins thĂ©orique est infini.
En effet, il est toujours possible de définir un chemin supplémentaire qui fait un tour supplémentaire dans la boucle !
|
isPalindromeOn s’intĂ©resse au code de PALINDROME, et plus prĂ©cisĂ©ment sur la mĂ©thode isPalindrome implĂ©mentĂ©e dans la classe BasicPalindrome.
public class BasicPalindrome implements Palindrome {
@Override
public boolean isPalindrome(String word) throws IllegalArgumentException, NullPointerException {
Objects.requireNonNull(word);
if (word.isEmpty() || !Pattern.matches("[\\p{Alpha}]*", word)) {
throw new IllegalArgumentException();
}
for (int i = 0; i< word.length() / 2; i++ ) {
char f = word.charAt(i);
char l = word.charAt(word.length() - i - 1);
if (f != l) {
return false;
}
}
return true;
}
On réalise un graphe de flot de contrÎle :
isPalindromeDans ce CFG, quelques chemins possibles sont :
-
ch1: (E, 4, S)
-
ch2: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 8âincr, 8âcond, 15, S)
-
ch3: (E, 4, 5a, 5b, 8-init, 8-cond, 15, S)
3.2. ExĂ©cutabilitĂ© d’un chemin
Bien qu’un chemin puisse formellement exister dans un graphe de flot de contrĂŽle, cela ne signifie pas forcĂ©ment que ce chemin corresponde Ă une exĂ©cution possible du code correspondant.
Il ne sera par exemple jamais possible d’exĂ©cuter une affectation comme x=5;, puis de traverser l’intĂ©rieur d’un conditionnelle qui dĂ©bute par if (x==10), et cela mĂȘme si le CFG fera apparaĂźtre un chemin qui traverse ces deux lignes.
ConcrĂštement, un chemin est dit exĂ©cutable s’il existe des donnĂ©es d’entrĂ©e qui vont entraĂźner une exĂ©cution qui passe exactement par ce chemin. Un mĂȘme chemin pourra souvent ĂȘtre exĂ©cutĂ© Ă l’aide de diffĂ©rentes possibilitĂ©s de donnĂ©es d’entrĂ©e.
isPalindrome-
Choix de donnĂ©e d’entrĂ©e possible pour emprunter ch1:
word=null. -
Choix de donnĂ©e d’entrĂ©e possible pour emprunter ch2:
word="aa". -
Mais aucune valeur de
wordne permet d’emprunter ch3. En effet, ce chemin signifie ne jamais emprunter la bouclefor, ce qui nĂ©cessite quewordpossĂšde une taille Ă©gale Ă zĂ©ro. Or, une exception sera forcĂ©ment envoyĂ©e en ligne 6 siwordest vide. Il est donc impossible de sortir de la boucle sans avoir effectuĂ© au moins un tour de cette derniĂšre, ce qui signifie que ce chemin est non-exĂ©cutable.
| Comme en tĂ©moigne l’exemple, le fait qu’un chemin ne soit pas exĂ©cutable ne signifie pas qu’il y a un problĂšme dans le code. C’est simplement que l’algorithme n’est pas prĂ©vu pour passer par certaines lignes avant ou aprĂšs d’autres. |
Il est rare que tous les chemins d’un graphe soient exĂ©cutables. Malheureusement, dĂ©tecter les chemins non-exĂ©cutables est un problĂšme indĂ©cidable. Plus gĂ©nĂ©ralement, Ă©tant donnĂ© un chemin, trouver des donnĂ©es d’entrĂ©e permettant l’exĂ©cution de ce chemin est un problĂšme souvent difficile.
3.3. Mise au point de scénarios de test
Une fois le programme modĂ©lisĂ© sous forme de CFG, les chemins possibles identifiĂ©s, et les donnĂ©es d’entrĂ©e identifiĂ©es pour les chemins exĂ©cutables, il ne reste plus qu’Ă :
-
Choisir quels chemins exĂ©cutables doivent ĂȘtre testĂ©s Ă l’aide d’un critĂšre de couverture. C’est l’objectif des deux sections suivantes du chapitre.
-
Pour chaque chemin exĂ©cutable devant ĂȘtre testĂ©, mettre au point un scĂ©nario de test complet, et l’ajouter Ă la suite de tests.
Pour ce second point, comme nous avons dĂ©jĂ obtenu les donnĂ©es d’entrĂ©es nĂ©cessaires Ă un chemin devant ĂȘtre testĂ©, il ne manque plus qu’Ă dĂ©finir un oracle cohĂ©rent avec ce chemin d’exĂ©cution, qui permettra de vĂ©rifier que le logiciel s’est correctement comportĂ© sur ce chemin d’exĂ©cution.
Pour ce faire, il est nĂ©cessaire de reposer sur la spĂ©cification du logiciel testĂ©, qui est la seule source d’information dĂ©crivant quels sont les comportements valides du logiciel. Plus prĂ©cisĂ©ment, pour un lot de donnĂ©es d’entrĂ©es correspondant Ă un chemin Ă exĂ©cuter, on ira chercher dans la spĂ©cification quel comportement est censĂ© produire le logiciel avec de telles donnĂ©es d’entrĂ©e, et on dĂ©finira un oracle en consĂ©quence.
isPalindromePour pouvoir déterminer les oracles nécessaires, on regarde à nouveau la spécification de isPalindrome :
isPalindromeinterface Palindrome {
/**
* Verifies that a word is a palindrome.
* Ignores the character case.
*
* @return true if word is a palindrome, false otherwise.
* @throws NullPointerException if word is null
* @throws IllegalArgumentException if word is empty
Puis, à partir des chemins exécutables ch1 et ch2, on établit les scénarios de test suivants :
| ParamĂštres d’entrĂ©e | Oracle | |||
|---|---|---|---|---|
|
|
|
|
|
ch1 |
|
â |
â |
|
ch2 |
|
|
||
4. Le ciblage en approche structurelle
4.1. ProblĂšme : nombre trop important de chemins
Nous avons vu qu’il est possible de lister des chemins d’exĂ©cution possibles Ă partir d’un CFG, et que chaque chemin d’exĂ©cution Ă©laborĂ© peut potentiellement ĂȘtre traduit en scĂ©nario de test. Il est mĂȘme envisageable, lorsque la complexitĂ© est raisonnable, d’Ă©numĂ©rer tous les chemins possibles d’un CFG, ce qui permet de s’assurer que nous testerons bien toutes les possibilitĂ©s d’exĂ©cution.
On constate cependant que différents problÚmes se posent :
-
si un CFG possÚde au moins une boucle, alors le nombre de chemins possibles est théoriquement infini, et il est alors impossible de tous les énumérer ;
-
si un CFG est trop grand et trop complexe, il ne sera pas soutenable d’en Ă©numĂ©rer tous les chemins possibles, mĂȘme sans boucle. Il suffit par exemple d’avoir 5 conditionnelles (
ifâŠthenâŠelse) Ă la suite les unes des autres pour devoir Ă©numĂ©rer 2Ă2Ă2Ă2Ă2 = 32 chemins diffĂ©rents ! -
mĂȘme si on arrivait Ă Ă©numĂ©rer tous les chemins possibles, la suite de tests qui en rĂ©sulterait serait trĂšs volumineuse, ce qui reprĂ©sente un coĂ»t important (coĂ»t de dĂ©veloppement, coĂ»t de maintenance, temps d’exĂ©cution, etc.).
4.2. Solution : cibler des morceaux de CFG
Comme vu prĂ©cĂ©demment, pour rĂ©soudre ces problĂšmes, l’Ă©tape logique suivante est la gĂ©nĂ©ration de cibles pertinentes. Ces cibles vont permettre de se concentrer sur un sous-ensemble de chemins parmi l’ensemble des chemins possibles, Ă savoir les chemins capables de couvrir les dites cibles.
En approche structurelle, une cible donnĂ©e prend la forme d’un morceau de CFG. Un morceau pourra prendre diffĂ©rentes formes (sommets, arcs, chemins entiers). On considĂšre qu’un chemin (et par extension le scĂ©nario de test rĂ©sultant) couvre une telle cible si ce chemin contient l’intĂ©gralitĂ© du morceau dĂ©fini dans la cible.
isPalindome en approche structurelleEn se basant la modĂ©lisation en CFG rĂ©alisĂ©e prĂ©cĂ©demment pour isPalindrome, soit l’ensemble arbitraire de cibles suivantes :
-
Le sommet 5a â correspond Ă la ligne 5, condition
word.isEmpty() -
Le sommet 8-incr â correspond Ă la ligne 8, incrĂ©mentation de la boucle
-
L’arc 4âS â correspond au passage de la ligne 4 Ă la sortir de la fonction par une
NullPointerException -
L’arc 5bâ8-init â correspond au passage de la ligne 5 Ă l’initialisation de la boucle en ligne 8
-
L’arc 9-11â12 â correspond au passage de la ligne 11 Ă la ligne 12
-
Le chemin entier (E,5a,5b,8-init,8-cond,9-11,12,S) â correspond Ă une exĂ©cution avec
return falselors du premier passage dans la boucle
Soit une suite de tests obtenue Ă partir de l’ensemble des chemins exĂ©cutables [ch1, ch2] (Ă©tablis prĂ©cĂ©dement pour ce problĂšme de test). En terme de couverture, on peut constater que :
-
ch1 couvre la cible 3
-
ch2 couvre les cibles 1, 2, 4
Une telle suite de tests couvrirait donc les cibles {1,2,3,4}, soit ~67% des cibles de l’ensemble arbitraire considĂ©rĂ©.
Pour dĂ©terminer un ensemble intĂ©ressant de cibles, on peut reposer sur des critĂšres de couverture adaptĂ©s Ă l’approche structurelle. Dans ce chapitre, trois types de critĂšres sont prĂ©sentĂ©s : les critĂšres sur les sommets, les critĂšres sur les arcs, et les critĂšres sur les chemins.
5. CritĂšres de couverture
Il existe de nombreux critÚres de couverture, tous basés sur le CFG, notamment :
-
Couverture de toutes les instructions du programme, autrement dit de chaque sommet du CFG.
-
Couverture des enchaĂźnements d’instructions, autrement dit des arcs/chemins du CFG, avec diffĂ©rentes variantes :
-
tous les arcs du CFG,
-
tous les chemins : dans ce cas, si le programme possĂšde des boucles, nombre de chemins potentiellement infini.
-
tous les "n-chemins", cad tous les chemins mais limités à n passages maximum dans chaque boucle. En fonction de la valeur choisir de n, on parlera de critÚre 1-chemins, ou de critÚre 2-chemins, ou de critÚre 3-chemins, etc.
-
5.1. CritĂšre "tous les sommets"
Le critĂšre "tous les sommets" est le critĂšre le plus faible, aussi appelĂ© TER1 (Test Effectiveness Ratio 1). Si on exĂ©cute un ensemble de scĂ©narios de test dont les chemins d’exĂ©cution couvrent tous les sommets du CFG, cela signifie que toutes les instructions du programme ont Ă©tĂ© exĂ©cutĂ©es au moins une fois.
Pour ce critĂšre, on calcule le taux de couverture par le simple calcul suivant : nombre de sommets couverts Ă· nombre total de sommets.
| Ce critÚre de couverture formel (car basé sur le CFG du programme) est presque équivalent au critÚre de couverture plus "informel" et trÚs couramment utilisé, à savoir la couverture des lignes de code du programme. |
isPalindromeOn propose l’ensemble minimal de chemins exĂ©cutables suivant pour satisfaire le critĂšre "tous les sommets" :
-
ch2: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 8âincr, 8âcond, 15, S)
-
ch4: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 12, S)
-
ch5: (E, 4, 5a, 6, S)
| En jaune sont montrĂ©s les sommets qui ne sont pas couverts par les chemins prĂ©cĂ©dents. Ainsi on peut par exemple voir que, par rapport Ă ch2, ch4 ne sert qu’Ă couvrir le sommet 12. |
On en déduit les scénarios de test suivants :
| ParamĂštres d’entrĂ©e | Oracle | |||
|---|---|---|---|---|
|
|
|
|
|
ch2 |
|
|
||
ch4 |
|
|
||
ch1 |
|
â |
â |
|
5.2. CritĂšre "tous les arcs"
Le critĂšre "tous les arcs" est le deuxiĂšme critĂšre le plus faible, aussi appelĂ© TER2 (Test Effectiveness Ratio 2). Si on exĂ©cute un ensemble de scĂ©narios de test dont les chemins d’exĂ©cution couvrent tous les arcs du CFG, cela signifie que toutes les branches conditionnelles du programme ont Ă©tĂ© exĂ©cutĂ©es au moins une fois (autrement dit, chaque prĂ©dicat sera une fois "vrai" et fois "faux").
Pour ce critĂšre, on calcule le taux de couverture par le simple calcul suivant : nombre dâarcs couverts Ă· nombre total dâarcs.
| On notera que couvrir tous les arcs permet de couvrir tous les noeuds (lâinverse nâest pas vrai). |
isPalindromeOn propose l’ensemble minimal de chemins exĂ©cutables suivant pour satisfaire le critĂšre "tous les arcs" :
-
ch1: (E, 4, S)
-
ch2: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 8âincr, 8âcond, 15, S)
-
ch4: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 12, S)
-
ch5: (E, 4, 5a, 6, S)
-
ch6: (E, 4, 5a, 5b, 6, S)
| En jaune sont montrĂ©s les arcs qui ne sont pas couverts par les chemins prĂ©cĂ©dents. Ainsi on peut par exemple voir que ch5 et ch6 ne servent chacun Ă couvrir qu’un seul nouvel arc. |
On en déduit les scénarios de test suivants :
| ParamĂštres d’entrĂ©e | Oracle | |||
|---|---|---|---|---|
|
|
|
|
|
ch1 |
|
â |
â |
|
ch2 |
|
|
||
ch4 |
|
|
||
ch5 |
|
â |
â |
|
ch6 |
|
â |
â |
|
5.3. CritĂšre "tous les chemins"
Pour augmenter les chances de de trouver tous les dĂ©fauts d’un logiciel, on peut souhaiter explorer tous les chemins d’exĂ©cutions qui le composent. C’est l’objectif du critĂšre "tous les chemins", qui permet de couvrir tous les sommets, tous les arcs, mais aussi toutes les combinaisons de dĂ©cisions.
| Sur des programmes de taille "normale", le critĂšre "tous les chemins" peut ĂȘtre difficile Ă appliquer. En effet, il y a une explosion combinatoire du nombre de chemins dĂšs lors que le programme possĂšde un nombre important de conditionnelles. Ce critĂšre est en outre impossible Ă appliquer sur un programme contenant des boucles, puisque le nombre de chemins devient alors infini. |
Soit la méthode suivante :
/**
* Decides a new status based on the change of value.
* A negative value is considered identical to a value of zero.
* @param oldValue The previous value.
* @param newValue The new value.
* @return 1 if the value changed, 0 otherwise.
*/
public static int decideNewStatus(int oldValue, int newValue) {
if (oldValue < 0) {
oldValue = 0;
}
if (newValue < 0) {
newValue = 0;
}
int newStatus = oldValue != newValue ? 1 : 0;
return newStatus;
}
On dessine son graphe de flot de contrĂŽle :
On pourrait se contenter de couvrir tous les arcs, en considérant les deux chemins suivants :
-
ch1 : (E,9,10,12,13,15-cond,15b,16,S), avec
oldValue=-1etnewValue=-1, -
ch2 : (E,9,12,15-cond,15a,16,S), avec
oldValue=2etnewValue=4.
Mais on notera que cela ne permet mĂȘme pas de tester le cas oĂč deux valeurs sont Ă©gales sans ĂȘtre nĂ©gatives.
Pour faire mieux, on peut satisfaire le critĂšre "tous les chemins" avec l’ensemble suivant :
-
ch1 : (E,9,10,12,13,15-cond,15b,16,S),
-
ch2 : (E,9,12,15-cond,15a,16,S),
-
ch3 : (E,9,12,15-cond,15b,16,S),
-
ch4 : (E,9,10,12,15-cond,15a,16,S),
-
ch5 : (E,9,10,12,15-cond,15b,16,S),
-
ch6 : (E,9,10,12,13,15-cond,15a,16,S) â non exĂ©cutable, car des valeurs nĂ©gatives deviennent zĂ©ro et sont forcĂ©ment Ă©gales,
-
ch7 : (E,9,12,13,15-cond,15a,16,S),
-
ch8 : (E,9,12,13,15-cond,15b,16,S),
On en déduit les scénarios suivants :
| ParamĂštres d’entrĂ©e | Oracle | ||
|---|---|---|---|
|
|
|
|
ch1 |
-1 |
-1 |
0 |
ch2 |
2 |
4 |
1 |
ch3 |
3 |
3 |
0 |
ch4 |
-1 |
4 |
1 |
ch5 |
-1 |
0 |
0 |
ch7 |
2 |
-1 |
1 |
ch8 |
0 |
-1 |
0 |
isPalindromeComme isPalindrome possĂšde une boucle, elle possĂšde un nombre infini de chemins.
Il n’est donc pas possible de tous les Ă©numĂ©rer, et donc de satisfaire le critĂšre "tous les chemins".
5.4. CritĂšre "tous les n-chemins"
Le critĂšre "tous les n-chemins" est une limitation choisie du critĂšre "tous les chemins" afin de le rendre utilisable mĂȘme en prĂ©sence de boucles. Le nombre n spĂ©cifie ici le nombre maximal de passages par boucle (itĂ©rations) que l’on souhaite considĂ©rer dans un chemin d’exĂ©cution.
| En pratique, il est courant de considĂ©rer le critĂšre "tous les 1-chemins" voire Ă©ventuellement "tous les 2-chemins" lors de l’utilisation d’une approche structurelle. |
isPalindromeL’ensemble des 1-chemins de isPalindrome est le suivant :
-
ch1: (E, 4, S)
-
ch2: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 8âincr, 8âcond, 15, S)
-
ch3: (E, 4, 5a, 5b, 8-init, 8-cond, 15, S)
-
ch4: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 12, S)
-
ch5: (E, 4, 5a, 6, S)
-
ch6: (E, 4, 5a, 5b, 6, S)
| En jaune sont marqué les passages de boucles. On observe bien que les chemins ch2 et ch4 présentent chacun un seul tour de boucle. |
Cependant, comme dĂ©jĂ Ă©tabli, ch3 n’est pas exĂ©cutable. Si on retire ch3 de l’ensemble, on obtient le mĂȘme ensemble dĂ©terminĂ© pour le critĂšre "Tous les arcs", et donc les mĂȘmes scĂ©narios de test.
isPalindromeL’ensemble des 2-chemins de isPalindrome est le suivant :
-
ch1: (E, 4, S)
-
ch2: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 8âincr, 8âcond, 15, S)
-
ch3: (E, 4, 5a, 5b, 8-init, 8-cond, 15, S)
-
ch4: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 12, S)
-
ch5: (E, 4, 5a, 6, S)
-
ch6: (E, 4, 5a, 5b, 6, S)
-
ch7: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 8âincr, 8âcond, 9â11, 12, S)
-
ch8: (E, 4, 5a, 5b, 8-init, 8-cond, 9â11, 8âincr, 8âcond, 9â11, 8-incr, 8âcond, 15, S)
| En jaune sont marqué les passages de boucles. On observe bien que les nouveaux chemins ch7 et ch8 présentent chacun deux tours de boucle. |
On notera cependant Ă nouveau que ch3 n’est pas exĂ©cutable, on se retrouve donc avec 7 chemins. On en dĂ©duit les scĂ©narios suivants :
| ParamĂštres d’entrĂ©e | Oracle | |||
|---|---|---|---|---|
|
|
|
|
|
ch1 |
|
â |
â |
|
ch2 |
|
|
||
ch4 |
|
|
||
ch5 |
|
â |
â |
|
ch6 |
|
â |
â |
|
ch7 |
|
|
||
ch8 |
|
|
||
5.5. Comparaison des critĂšres
On peut observer qu’il existe une hierarchie formelle permettant de classer les critĂšres de couverture vus dans cette partie, Ă savoir :
« Tous les chemins » ℠« Tous les n-chemins » ℠« Tous les arcs » ℠« Tous les sommets ».
Cette hierarchie se lit de la maniÚre suivante : tout chemin couvert par un critÚre situé plus à droite, est forcément couvert par un critÚre situé plus à gauche.
6. Mesure automatisée de la couverture avec JaCoCo
Alors que pour l’approche combinatoire nous avions Ă©tudiĂ© un outil permettant de gĂ©nĂ©rer des propositions permettant de couvrir toutes les cibles considĂ©rĂ©es, pour l’approche structurelle la plupart des outils ne permettent que de mesurer le taux de converture d’une suite de test prĂ©alablement dĂ©finie. Autrement dit, on rĂ©alise une vĂ©rification en aval plutĂŽt qu’une gĂ©nĂ©ration automatisĂ©e de solutions.
6.1. Présentation de JaCoCo
JaCoCo (Java Code Coverage Library) est un logiciel capable d’Ă©tablir quelles parties du code source d’un logiciel Java a Ă©tĂ© couverte par une ou plusieurs exĂ©cutions du code. Pour ce faire, JaCoCo repose sur la possiblitĂ© offerte par la machine virtuelle Java d’y attacher ce qu’on appelle un agent, Ă savoir un composant logiciel qui peut transformer en mĂ©moire et Ă la volĂ©e le code source exĂ©cutĂ© (procĂ©dĂ© autrement appelĂ© l'instrumentation du code source). Cet agent ajoute des points observations partout dans le code, et permet Ă JaCoCo de savoir prĂ©cisĂ©ment comment il a Ă©tĂ© exĂ©cutĂ©.
| Les mesures de JaCoCo sont rĂ©alisĂ©es Ă l’aide d’un CFG gĂ©nĂ©rĂ© Ă partir du bytecode Java et non au niveau du code Java. Une consĂ©quence est que les nombres de sommets/arcs dĂ©couverts par JaCoCo sont diffĂ©rents de ce qui serait dĂ©couvert avec l’approche vue dans ce chapitre, mais permettent d’extraire des taux de couverture trĂšs similaires, voire Ă©quivalents. |
JaCoCo permet d’effectuer les mesures de couverture suivantes (dĂ©tails diponibles Ă cette page de la documentation) :
-
Instructions : proportion d’instructions bytecode exĂ©cutĂ©es â correspond approximativement au critĂšre de couverture tous les sommets.
-
Branches : proportion de branches bytecode exĂ©cutĂ©es â correspond approximativement au critĂšre de couverture tous les arcs.
-
Cyclomatic Complexity (Cxty) : pourcentage de chemins linĂ©airement indĂ©pendants exĂ©cutĂ©s â critĂšre qui repose sur la mĂ©trique de complexitĂ© cyclomatique (non Ă©tudiĂ©e) ;
-
Lines : proportion de lignes de code exĂ©cutĂ©es â ressemble Ă la mesure "Instructions" mais est moins prĂ©cis et moins intĂ©ressant car une mĂȘme ligne de code peut correspondres Ă plusieurs instructions de bytecode.
-
Methods : proportion de mĂ©thodes exĂ©cutĂ©es (mĂȘme partiellement).
-
Classes : proportion de classes exĂ©cutĂ©es (mĂȘme partiellement).
Ainsi, pour une suite de tests donnĂ©e, JaCoCo peut nous permettre de vĂ©rifier si nous avons bien appliquĂ© l’approche structurelle Ă©tudiĂ©e, au moins en ce qui concerne les critĂšres tous les sommets et tous les arcs. Cependant, on peut voir que JaCoCo ne permet pas de mesurer la couverture du critĂšre tous les n-chemins.
6.2. Ajouter le plugin Maven JaCoCo
Une maniĂšre d’utiliser JaCoCo est d’utiliser le plugin Maven officiel de l’outil, qui permet d’utiliser automatiquement l’agent JaCoCO lors de l’exĂ©cution d’une suite de tests JUnit prĂ©sente dans le projet Maven.
Pour ce faire, on peut ajouter la suivante Ă la partie <build> d’un projet Maven :
<build>
âŠ
<plugins>
âŠ
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.15</version>
<executions>
<!-- Active l'agent JaCoCo lors de l'exécution -->
<execution>
<id>prepare-agent-exe</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<!-- GénÚre un rapport HTML avant la génération du site -->
<execution>
<id>report-exe</id>
<phase>pre-site</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
Cela ajoute le plugin Maven JaCoCo et active deux de ses goals, Ă savoir prepare-agent qui prĂ©pare l’agent JaCoCo, et report qui gĂ©nĂšre le rapport HTML â ce dernier est explicitement attachĂ© Ă la phase pre-site pour s’assurer qu’il soit bien gĂ©nĂ©rĂ© lors d’un appel Ă mvn site.
On peut également ajouter la partie suivante dans la partie <reporting>, afin de lister le rapport JaCoCo dans la page Project Reports du site généré :
<reporting>
âŠ
<plugins>
âŠ
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<reportSets>
<reportSet>
<reports>
<report>report</report>
</reports>
</reportSet>
</reportSets>
</plugin>
</plugins>
</reporting>
Le rapport gĂ©nĂ©rĂ© est accessible via le fichier target/site/jacoco/index.html, et permet d’obtenir une synthĂšse des rĂ©sultats pour chaque package Java.
6.3. Lire les résultats produits par JaCoCo
On peut distinguées deux catégories de résultats produits dans le rapport JaCoCo :
-
Pour les critĂšres Instructions et Branches, les informations sont prĂ©sentĂ©es Ă la fois sous la forme d’un graphique (en vert, la proportion d’Ă©lĂ©ments couverts) et sous la forme d’un pourcentage d’Ă©lĂ©ments couverts.
-
Pour chaque autre critĂšre, on trouve Ă chaque fois deux colonnes qui se suivent : d’abord une colonne "Missed" qui indique le nombre d’Ă©lĂ©ments non couverts pour ce critĂšre, puis une colonne portant le nom du critĂšre qui indique le nombre d’Ă©lĂ©ments total Ă couvrir.
Si on clique sur une classe, on peut consulter une représentation visuelle et annotée de son code source :
-
Les lignes surlignĂ©es en vert ou en jaune sont celles qui ont Ă©tĂ© exĂ©cutĂ©es, tandis que les lignes en rouge sont celles qui n’ont pas Ă©tĂ© exĂ©cutĂ©es.
-
Les lignes vertes ont été exécutées "entiÚrement",
-
Les lignes jaunes ont été exécutées "partiellement" (par exemple une partie de conditionnelle non utilisée ou une instruction non exécutée).
-
-
Les diamants colorĂ©s indiquent les conditionnelles, et sont verts si toutes les branches de la conditionnelle sont couvertes, jaunes si seulement une partie des branches de la conditionnelle sont couvertes, rouges si aucune branche de la conditionnelle n’a Ă©tĂ© couverte.
Supposons la suite de tests suivante pour isPalindrome :
package app;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
public class SimplerBasicPalindromeTest {
@Test
@DisplayName("Can detect that a word is a palindrome")
public void testCanDetectPalindrome() throws IllegalArgumentException {
// Given
Palindrome palindrome = new BasicPalindrome();
// When
boolean result = palindrome.isPalindrome("anna");
// Then
assertTrue(result);
}
@Test
@DisplayName("Can detect that a word is not a palindrome")
public void testCanDetectNotPalindrome() throws IllegalArgumentException {
// Given
Palindrome palindrome = new BasicPalindrome();
// When
boolean result = palindrome.isPalindrome("john");
// Then
assertFalse(result);
}
}
Le premier scénario correspond au chemin ch8, et le second scénario correspond au chemin ch4.
|
On exécute cette suite de tests en activant JaCoCo, et ce dernier nous produit le rapport suivant pour la classe BasicPalindrome :
Si on s’intĂ©resse aux mesures pour la mĂ©thode isPalindrome (que nous souhaitions tester) :
-
Instructions : 90% des instructions bytecode couvertes.
-
Branches : 70% des branches bytecode couvertes.
-
Cyclomatic Complexity (Cxty) : 3/6 chemins linéairement indépendants non couverts, donc 50% de couverts.
-
Lines : 1/8 lignes non couvertes, donc 87,5% de couvertes.
Pour comprendre certains de ces résultats, on peut également accéder à une visualisation annotée du code source de isPalindrome :
On peut voir que seule la ligne 11 n’est pas couverte, et est donc affichĂ©e en rouge. Cela correspond au sommet non couvert numĂ©rotĂ© 6 dans le CFG de la mĂ©thode. En effet, notre suite de tests ne contient pas de scĂ©nario avec chaĂźne de caractĂšres invalide, cette ligne n’est donc jamais exĂ©cutĂ©e.
La ligne 10 est elle affichĂ©e en jaune, ce qui signifie qu’elle Ă©tĂ© exĂ©cutĂ©e partiellement. Le diamant jaune Ă gauche signifie que toutes les branches issues de la conditionnelle n’ont pas Ă©tĂ© explorĂ©es. Cela correspond aux arcs non couverts 5aâ6 et 5bâ6 dans le CFG de la mĂ©thode.