Introduction au test de logiciel Cours INE21 Séances 1-5

Transcription

Introduction au test de logiciel Cours INE21 Séances 1-5
Introduction au test de logiciel
Cours INE21
Séances 1-5
Philippe Herrmann
[email protected]
session 2010
2
Table des matières
1
Introduction
1
1.1
Vérification : Objectifs et Intérêt . . . . . . . . . . . . . . . . . . . .
1
1.1.1
Vérification et Validation . . . . . . . . . . . . . . . . . . . .
1
1.1.2
Quand vérifier ? . . . . . . . . . . . . . . . . . . . . . . . . .
2
1.1.3
Comment vérifier ? . . . . . . . . . . . . . . . . . . . . . . .
3
Bonnes pratiques dans le logiciel . . . . . . . . . . . . . . . . . . . .
4
1.2.1
Bonnes pratiques . . . . . . . . . . . . . . . . . . . . . . . .
4
1.2.2
Mesures de la qualité d’un logiciel . . . . . . . . . . . . . . .
4
1.2.3
Qualité et CMMI . . . . . . . . . . . . . . . . . . . . . . . .
5
1.2.4
Répartition des efforts dans le domaine logiciel . . . . . . . .
6
Vérification et méthodes formelles . . . . . . . . . . . . . . . . . . .
6
1.3.1
Vérification Formelle : définition . . . . . . . . . . . . . . . .
6
1.3.2
Vérification par sous-approximation : le test . . . . . . . . . .
7
1.3.3
Vérification par sur-approximation : techniques d’abstraction .
7
1.3.4
Vérification par la preuve assistée . . . . . . . . . . . . . . .
8
1.3.5
Vérification formelle dans l’industrie . . . . . . . . . . . . .
9
1.2
1.3
2
Test de Logiciel
11
2.1
Généralités sur le test . . . . . . . . . . . . . . . . . . . . . . . . . .
11
2.1.1
Importance du test . . . . . . . . . . . . . . . . . . . . . . .
11
2.1.2
Test : définitions et propriétés . . . . . . . . . . . . . . . . .
11
2.1.3
Infrastructure de test . . . . . . . . . . . . . . . . . . . . . .
12
2.1.4
Perspectives du test . . . . . . . . . . . . . . . . . . . . . . .
12
Processus de test . . . . . . . . . . . . . . . . . . . . . . . . . . . .
13
2.2.1
Quelques définitions . . . . . . . . . . . . . . . . . . . . . .
13
2.2.2
Oracle de test : un exemple . . . . . . . . . . . . . . . . . . .
13
2.2.3
Process simplifié du test . . . . . . . . . . . . . . . . . . . .
14
2.2.4
Scripts de test . . . . . . . . . . . . . . . . . . . . . . . . . .
15
2.2
i
TABLE DES MATIÈRES
2.3
2.4
2.5
3
15
2.2.6
Mesure de la qualité d’une suite de tests . . . . . . . . . . . .
16
Caractérisations de l’activité de test . . . . . . . . . . . . . . . . . .
16
2.3.1
Typologie . . . . . . . . . . . . . . . . . . . . . . . . . . . .
16
2.3.2
Test Fonctionnel, Test Structurel . . . . . . . . . . . . . . . .
18
2.3.3
Phases du test . . . . . . . . . . . . . . . . . . . . . . . . . .
19
2.3.4
Autres types de tests . . . . . . . . . . . . . . . . . . . . . .
20
Pièges du test . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
20
2.4.1
Par rapport au rôle du test . . . . . . . . . . . . . . . . . . .
20
2.4.2
Par rapport au processus de test . . . . . . . . . . . . . . . .
20
Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
20
21
3.1
Génération boîte noire . . . . . . . . . . . . . . . . . . . . . . . . .
21
3.1.1
Analyse partitionnelle . . . . . . . . . . . . . . . . . . . . .
21
3.1.2
Test aux limites . . . . . . . . . . . . . . . . . . . . . . . . .
24
3.1.3
Test combinatoire : approche n-wise . . . . . . . . . . . . . .
24
3.1.4
Génération aléatoire . . . . . . . . . . . . . . . . . . . . . .
25
3.1.5
Autres techniques de génération . . . . . . . . . . . . . . . .
26
Génération boîte blanche et critères de couverture . . . . . . . . . . .
27
3.2.1
Graphe de contrôle . . . . . . . . . . . . . . . . . . . . . . .
28
3.2.2
Couverture des blocs, couverture des arcs . . . . . . . . . . .
28
3.2.3
Couverture des décisions, conditions . . . . . . . . . . . . . .
31
3.2.4
Couverture MC/DC . . . . . . . . . . . . . . . . . . . . . . .
36
3.2.5
Couverture de boucles, de chemins . . . . . . . . . . . . . .
39
3.2.6
Couverture du flot de données . . . . . . . . . . . . . . . . .
41
3.2.7
Couverture des mutants . . . . . . . . . . . . . . . . . . . . .
43
Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
43
3.3
Génération Automatique de Tests
45
4.1
Panorama des techniques de génération . . . . . . . . . . . . . . . .
45
4.1.1
Génération automatique aléatoire . . . . . . . . . . . . . . .
45
4.1.2
Génération automatique en boîte noire . . . . . . . . . . . . .
47
4.1.3
Génération automatique en boîte blanche . . . . . . . . . . .
47
Techniques de génération automatique de tests structurels . . . . . . .
47
4.2.1
Prédicat de chemin . . . . . . . . . . . . . . . . . . . . . . .
48
4.2.2
Génération par exécution symbolique des chemins . . . . . .
50
4.2.3
Exemple de génération par exécution symbolique . . . . . . .
52
4.2
ii
Environnement de test unitaire . . . . . . . . . . . . . . . . .
Sélection des Tests
3.2
4
2.2.5
4.3
4.2.4
Génération de tests par exécution concolique . . . . . . . . .
55
4.2.5
Apport pour le traitement des alias . . . . . . . . . . . . . . .
59
4.2.6
Apport pour l’utilisation de code externe . . . . . . . . . . .
60
Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
60
iii
iv
Chapitre 1
Introduction
1.1
1.1.1
Vérification : Objectifs et Intérêt
Vérification et Validation
Vérification et Validation ou V&V : ensemble d’activités exécutées en parallèle du
développement d’un système logiciel afin de fournir l’assurance qu’il fonctionnera
conformément à un ensemble d’exigences / spécifications / besoins utilisateur.
Le processus de V&V évalue le logiciel dans un contexte système par une approche
structurée : le logiciel est testé en interaction avec les fonctionnalités du système,
le contexte d’utilisation (hardware, OS), les interfaces logicielles (bibliothèques par
exemple) et l’utilisateur. Il s’inscrit naturellement dans le cycle en V classique de développement de systèmes logiciels.
Exigences Système
Recette du Produit final
Tests Système
Exigences du Logiciel
ve
Spécifications
i
s d
ue
s
rse
rev
Conception Générale
s
es
s
aly
an
es
qu
i
tat
analyses dynamiques
(plate­forme d'exécution)
Conception Détaillée
Codage
Tests d'Intégration
Tests Unitaires
déverminage
Généralement on effectue la distinction suivante entre vérification et validation :
1
Introduction
– Vérification : il s’agit de comparer certaines propriétés intrinsèques du logiciel à des
standards, procédures, politiques, process, exigences, spécifications : « am I building
the product right ? »
– Validation : ici on compare le contenu informatif du produit à des propriétés extrinsèques : fait-il ce pour quoi il a été conçu ? satisfait-il les besoins du client : « am I
building the right product ? »
Ce cours traite principalement de la vérification du logiciel sous les angles suivants :
– test : techniques et environnement de test pour le logiciel (ce document)
– preuve (3 cours) : prouver qu’un logiciel satisfait à ses spécifications par la preuve
de programme
– model checking (3 cours) : modéliser le logiciel par des techniques à base d’automate
et vérifier automatiquement des propriétés exprimées en logique temporelle
Objectifs de la vérification de logiciel
La vérification a pour objectif principal de construire des systèmes ayant un minimum
de défauts. Les défauts interviennent typiquement à différents niveau dans le cycle en
V:
– défaut dans les spécifications (amont) : une fonctionnalité attendue a été oubliée ou
mal spécifiée
– défaut dans la conception : la réalisation d’une fonctionnalité ne satisfait pas à ce qui
a été spécifié
– défaut de codage (aval) : l’implantation de la fonctionnalité n’a pas été faite en accord avec sa conception
Plus le défaut est introduit en amont, plus son impact potentiel est grand et sa correction
coûteuse. Il s’avère donc indispensable d’adopter une méthodologie de vérification qui
permette de détecter les défauts au plus tôt après leur introduction, par exemple en
vérifiant chaque étape du cycle en V avant de passer à l’étape suivante.
Un objectif annexe de la vérification est de permettre la détection précise des défauts.
Un logiciel conséquent peut avoir de l’ordre de 105 à 106 lignes de code. Il est donc
essentiel de pouvoir tracer un défaut jusqu’à sa source, notamment lors du développement du logiciel, mais potentiellement aussi lors des phases d’intégration, voire lorsque
le logiciel est en opération. Il est souvent indispensable de prévoir des fonctionnalités
de diagnostic afin de pouvoir effectuer un retour vers le développeur en cas de détection
de problèmes (par exemple lors de violations d’assertions à l’exécution).
1.1.2
Quand vérifier ?
La vérification doit fait partie intégrante du développement du logiciel, notamment
parce que vérifier a posteriori qu’un logiciel répond à certaines exigences est infiniment
plus coûteux que de procéder de manière structurée et incrémentale, et que la qualité
d’un logiciel vérifié en parallèle de son développement est bien supérieure.
Lors de la descente du cycle en V (spécification, conception, développement), il s’agit
de vérifier que les différents modèles décrivant le système final et ses sous-systèmes
satisfont bien aux exigences définies par le niveau de modélisation supérieur :
2
– les spécifications doivent répondre aux exigences systèmes
– les modèles de conception doivent réaliser les fonctionnalités spécifiées
– le code doit fournir une implantation correcte des modèles de conception
La vérification d’un niveau se fait conformément aux exigences héritées du niveau
supérieur, elles mêmes issues au final des exigences systèmes initiales.
Lors de la phase remontante du cycle en V, les sous-systèmes sont intégrés de manière
incrémentale pour aboutir au système final. A chaque étape, les sous-systèmes sont
supposés remplir correctement des sous-fonctions, et l’on cherche à vérifier que leur
combinaison permet de réaliser les fonctionnalités de plus haut niveau.
1.1.3
Comment vérifier ?
La vérification se base sur des processus spécifiques tout au long du cycle de développement. Il existe des technologies outillées qui ont pour objectif d’assurer formellement le développement et la vérification parallèle de la partie logicielle d’un système
(méthode B 1 ). Le projet Meteor (ligne 14 du métro de Paris) a été réalisé en utilisant
la méthode B.
Il est cependant fréquent que les différentes étapes du développement soient chacune
associée à un ensemble de processus ad hoc pour vérifier leurs exigences : le test, la
preuve de programme et le model checking en sont des exemples. Une difficulté est
alors d’assurer la cohérence entre les différents niveaux de description du système.
D’autre part, une grande part de la vérification est réalisée par l’utilisation de méthodologies que l’on peut qualifier de transversales. En vrac on peut citer :
– règles de codage (par exemple MISRA 2 pour le monde automobile) : généralement
pour interdire des constructions jugées dangereuses pour des langages comme C ou
C++
– revue par les pairs : les autres concepteurs/développeurs effectuent des relectures
critiques des modèles/codes, dans un processus qui peut être formalisé
– utilisation d’outils de génie logiciel : gestionnaire de versions 3 , bug tracking systems 4 , . . .
– environnement d’automatisation des tests : faciliter leur écriture, automatiser leur
exécution (non-régression)
Pourquoi vérifier ?
A un niveau plus macroscopique, la vérification a avant tout un intérêt économique
crucial pour l’industrie du logiciel. Des études ont chiffré les gains potentiels d’une
activité de test bien menée à plusieurs milliards de dollars pour l’industrie du logiciel
américaine. Il existe donc un intérêt économique évident à la vérification, qui permet
de produire plus rapidement des logiciels de meilleure qualité. Ceci est à mettre en
parallèle avec le fonctionnement d’une industrie comme l’automobile, où il y a intérêt
à avoir la capacité de sortir de nouvelles voitures rapidement tout en gardant un haut
niveau de qualité.
1 http://www.atelierb.eu
2 http://www.misra.org.uk
3 http://subversion.apache.org
4 www.mantisbt.org
3
Introduction
Outre l’aspect économique, certains domaines d’activité industrielle ayant une composante logicielle non négligeable (aéronautique, ferroviaire, nucléaire civil et médical)
voient leur processus de mise sur le marché de nouveaux produits soumis à des autorités de certification. Le logiciel de ces systèmes doit obligatoirement avoir été développé
selon certaines normes et/ou satisfaire à une certaine mesure de qualité. Certifier le logiciel de ces systèmes sans processus de vérification adapté est généralement illusoire.
A titre d’exemple, on peut citer la norme DO178B applicable aux logiciels critiques
dans l’avionique. Les niveaux les plus exigeants de cette norme (A,B,C) exigent par
exemple un certain niveau de couverture de code par les tests et la justification des
écarts constatés (par exemple : pas de code non-exécutable ou code « mort »). Un logiciel produit dans un processus de développement classique a bien peu de chance de
satisfaire à ce type de contraintes de couverture structurelle.
1.2
1.2.1
Bonnes pratiques dans le logiciel
Bonnes pratiques
Pour un développement classique de logiciel, certaines pratiques de développement et
de vérification sont communément admises comme ayant fait leur preuve du point de
vue du rapport entre leur coût et leur gain en terme de qualité. L’état de l’art prône
principalement :
– la vérification en profondeur de l’ensemble des exigences du logiciel
– la revue par des pairs (« peer-review ») des spécifications et des documents et modèles de conception
– la vérification en profondeur des implantations critiques, la revue pour le reste du
code
– le test unitaire (fonction par fonction) systématique avec une bonne qualité de couverture, et en général la réalisation et l’exécution d’un plan de tests pertinent (pour
les tests d’intégration, les tests du système . . . )
De bonnes pratiques de génie logiciel (Makefile, gestionnaire de version, bug-tracking,
qualité de la documentation, automatisation des tests de non-régression . . . ) contribuent
également à la qualité du logiciel et facilitent sa vérification.
La mise en œuvre de telles pratiques permettrait de générer les performances théoriques
suivantes (en partant d’une situation sans systématisation du test, sans revue par les
pairs, et à ne pas prendre trop au sérieux) :
–
–
–
–
70-90% des défauts détectés avant la phase de test
ratio 7-12x de retour sur investissement
réduction du time-to-market de 10-15% par an
productivité doublée en 5 ans
Ces bonnes pratiques contribuent globalement à la qualité d’un logiciel.
1.2.2
Mesures de la qualité d’un logiciel
La mesure de la qualité d’un logiciel peut se faire selon plusieurs dimensions, qui ne
relèvent pas toutes de la vérification.
4
En termes opérationnels, la qualité se mesure selon les axes suivants :
– correction, fiabilité, sûreté : le logiciel réalise-t-il les fonctions demandées et à quel
niveau ?
– intégrité, sécurité : résiste-t-il aux attaques intentionnelles ou aux maladresses de
l’utilisateur ?
– efficacité, performance : quelles ressources le logiciel requiert-il (temps, mémoire) ?
– facilité d’utilisation
La vérification se préoccupe principalement de la correction, fiabilité, sûreté voire sécurité du logiciel. Les aspects de performance peuvent également être vérifiés (par
exemple politique d’ordonnancement et analyse du pire temps d’exécution pour les
logiciels temps-réel).
La qualité du logiciel se mesure également en terme de facilité de développement et
d’évolution, qui jouent un rôle important du point de vue de l’aspect pratique de la
vérification, et de la vérification des évolutions d’un logiciel :
– maintenabilité : facilité à détecter et corriger des erreurs
– flexibilité : facilité à faire évoluer le logiciel ou l’adapter
– testabilité : facilité à dérouler une campagne de test
A titre informatif, la qualité d’un logiciel peut également être évaluée sous l’angle de
l’intégration :
– interopérabilité : capacité à interagir avec d’autres systèmes
– réutilisabilité de tout ou partie
– portabilité vers d’autres plateformes (OS, application, micro-contrôleurs . . . )
1.2.3
Qualité et CMMI
Sans vouloir entrer dans le détail, un certain nombre de modèles d’organisation de la
politique de qualité (du logiciel ou plus généralement du système) ont émergé depuis
15-20 ans. On peut citer le modèle CMMI (Capability Maturity Model + Integration)
ou « modèle intégré du niveau de maturité » développé à l’université de Carnegie Mellon pour le ministère de la défense des Etats-Unis. Il décrit cinq niveaux de maturité
d’une organisation, qui mesurent le degré auquel celle-ci a déployé explicitement et
de façon cohérente des processus qui sont documentés, gérés, mesurés, contrôlés et
continuellement améliorés. A titre d’exemple :
– niveau 1 : « l’ère des héros » (tout repose sur le développeur)
– niveau 2 : plus classique, le chef de projet joue un rôle important, le management
et les ingénieurs ont une idée de l’avancement global du projet qui peut être mesuré
quantitativement . . .
– niveau 3 : l’entreprise dispose d’un référentiel qui permet de capitaliser l’expérience
acquise lors des projets
– niveau 4 : gestion quantitative des processus (on fait des statistiques pour repérer les
problèmes)
– niveau 5 : on optimise en permanence les processus sur la base des analyses statistiques
5
Introduction
1.2.4
Répartition des efforts dans le domaine logiciel
Le tableau qui suit décrit l’évolution de la répartition des efforts (pour chaque étape du
cycle en V) dans l’industrie du logiciel au cours du temps.
capture des exigences
conception préliminaire
codage
10%
années 60-70
40%
test unitaire
test d’intégration
80%
20%
années 80
années 90
conception
détaillée
10%
20%
60%
30%
test système
30%
Quelques remarques concernant cette évolution :
– il y a eu une prise de conscience par l’industrie du logiciel du coût élevé de la vérification tardive : l’effort global s’est réparti plus amont
– cette prise de conscience a favorisé l’émergence de modèles de conception et de
langages de spécifications, qui en retour a favorisé l’investissement dans l’effort de
conception
– les systèmes étant de plus en plus gros et intégrés, les phases de test d’intégration et
de test systèmes sont devenues plus coûteuses
– le test est maintenant vu comme une activité à part entière, au coût toujours conséquent (un tiers du coût total)
– la phase de capture des exigences nécessite un effort important et souvent incompressible, notamment du fait de la complexité des systèmes produits
1.3
1.3.1
Vérification et méthodes formelles
Vérification Formelle : définition
Par vérification formelle d’un logiciel, on entend la vérification mathématique « exhaustive » de sa correction. On cherche à prouver au sens mathématique que l’implantation (vue comme un modèle logique) satisfait à sa spécification (vue par exemple
comme un ensemble de formules logiques).
La vérification formelle est une activité complexe. Tout d’abord, il y a la difficulté technique d’extraction d’un modèle depuis un programme ou de formules logiques depuis
des spécifications (souvent semi-formelles). Cette extraction doit se faire de manière
sûre bien entendu. Mais la difficulté principale est d’arriver à faire la preuve que le modèle extrait satisfait bien aux formules qui correspondent à la spécification requise. Pour
un programme autre qu’un programme jouet, envisager une preuve manuelle complète
est souvent tout à fait hors de question, la taille du modèle étant rédhibitoire. La preuve
automatique à l’aide d’un ordinateur est quant à elle un problème tout à fait indécidable (il n’y a pas de logiciel magique qui serait capable de prouver automatiquement
la correction de tout logiciel).
Le problème ne pouvant être résolu dans toute sa généralité, divers choix et techniques
se présentent pour le simplifier. La vérification formelle consiste généralement en la
combinaison de ces diverses techniques présentées ci-après.
6
1.3.2
Vérification par sous-approximation : le test
L’idée est de ne pas faire une vérification exhaustive du logiciel, mais d’essayer de
mettre le modèle en défaut par rapport à une propriété de la spécification. Le test peut
s’effectuer sur le logiciel final, ou des sous-parties du logiciel (modules, fonctions), ou
sur des modèles du logiciel. La qualité première du test est de permettre de révéler des
défauts avec une grande précision et à moindre coût, mais jamais de garantir qu’il n’y
ait aucun défaut.
La difficulté du test est de parvenir à un ensemble de tests (ou suite de tests) suffisamment pertinent pour se convaindre de la correction du logiciel. Il est impossible de
prouver qu’un programme est correct en n’utilisant que des techniques de test, puisqu’il s’agit d’une technique qui explore seulement une sous-approximation des comportements. Il est donc important de pouvoir mesurer la qualité d’une suite de tests, ce
qui se fait généralement en terme de couverture d’un certain critère. Les critères les
plus courants au niveau du test de code sont le taux de couverture des instructions ou
des branches (décisions).
De nombreux auteurs considèrent que le test n’est pas une technique de vérification
formelle à proprement dit, puisqu’il ne permet pas à lui seul de conclure. Il reste qu’il
s’agit de la technique de vérification la plus universellement adoptée, la moins coûteuse en terme de défauts trouvés par rapport à l’effort investi, et la mieux outillée
(environnement de tests comme JUnit, automatisation des tests).
1.3.3
Vérification par sur-approximation : techniques d’abstraction
La complexité d’un logiciel est liée à la complexité des comportements associés, généralement corrélés à sa taille, mais pas uniquement. La présence de boucles, l’utilisation
de structures de données ayant des invariants complexes, le recours à des types de données flottants contribuent parmi d’autres à la complexité du logiciel. Cette complexité
se reflète dans ce qui est appelé la sémantique opérationnelle du programme, qui définit
un système de transitions généralement infini décrivant l’ensemble des traces d’exécution possibles (séquence des états mémoire). L’idée générale est d’abstraire ce système
de transition par un système « essentiellement fini », qui en soit une sur-approximation
(toutes les traces d’exécutions sont représentées). Il est alors possible d’essayer de
prouver les propriétés requises sur cette vue abstraite, simplifiée par rapport à la vue
concrète de la sémantique opérationnelle.
Le point crucial est qu’une propriété prouvée sur la vue abstraite sera correcte sur la
vue concrète. En revanche, une propriété qu’on n’arrive pas à prouver sur l’abstraction
ne sera pas forcément incorrecte sur la vue concrète, puisque la sur-approximation a pu
introduire des comportements illicites du point de vue de la propriété à prouver mais
qui n’existaient pas initialement.
Il existe différentes techniques travaillant par sur-approximation.
Interprétation abstraite : il s’agit d’un cadre général qui permet d’associer une sémantique abstraite à un programme qui soit une généralisation de sa sémantique concrète,
ceci en fixant un domaine abstrait de représentation des traces d’exécutions. On peut
par exemple choisir un domaine abstrait de représentation des variables (ex : intervalles), et choisir d’abstraire un chemin d’exécution par le point de contrôle qu’il at7
Introduction
teint.
Il s’agit d’un cadre particulièrement adapté à la preuve automatique de l’absence de
« runtime-errors » : division par zéro, dépassement de capacité, accès hors bornes d’un
tableau, déréférencement d’un pointeur invalide. Il permet en effet de traquer avec une
grande précision les valeurs potentielles qu’une variable peut prendre en chaque point
du graphe de contrôle. Dans les faits, une grande expertise est requise pour obtenir au
final une abstraction fidèle du programme initial, sans trop de faux positifs (valeurs du
domaine abstrait posant problème et ne correspondant à aucune exécution concrète),
et sans utiliser dès les départ des domaines trop précis dont le coût lors de l’analyse
devient prohibitif (en temps et mémoire).
Exemples d’outils industriels : Frama-C 5 , Polyspace Verifier 6 , Astrée 7 .
Model checking : il s’agit cette fois de choisir un certain type de modèle (généralement à base d’automates finis étendus avec des types de données simples) pour lequel
il existe un algorithme permettant de vérifier la classe de propriétés visées (souvent
exprimées en terme de logique temporelles, décrivant des enchaînements complexes
d’actions). Une première difficulté est de parvenir à abstraire le logiciel vers ce type
de modèle, automatiquement dans l’idéal. Les limitations fortes sur le pouvoir d’expression des modèles obligent souvent à des abstractions relativement grossières. Une
autre difficulté majeure est le problème du passage à l’échelle en fonction de la taille
du modèle et de la complexité de la propriété à vérifier, les algorithmes de vérification
étant en général exponentiels. Les outils de model checking existant sont plutôt issus
du monde académique (Spin 8 , Uppaal 9 en est un exemple).
De manière générale, toutes les techniques dites d’analyse statique (analyse d’un logiciel à partir de ses sources) travaillent par sur-approximation. Par exemple : algorithmes
de calcul de dépendances de données et autres algorithmes dataflow utilisés dans les
compilateurs.
1.3.4
Vérification par la preuve assistée
La preuve assistée de programme a fait des progrès considérables ces dernières années,
notamment grâce au développement d’assistants de preuves ambitieux (comme Coq
développé à Orsay 10 ) qui intègrent des procédures de décision efficaces (preuve automatique, généralement sur des logiques du premier ordre sans quantification ; voir par
exemple le prouveur Z3 de Microsoft Research 11 ). L’expertise de l’utilisateur chargé
de prouver le programme joue bien entendu un rôle primordial.
Il ne s’agit pas de travailler directement sur le programme à prouver au niveau de l’assistant de preuve (problème de sa représentation dans la théorie choisie), mais sur des
formules extraites du programme (obligations de preuves), de préférence de manière
automatisée. Cette extraction peut se faire en utilisant des techniques de calcul de précondition « la plus faible » à la Hoare, qui permet de calculer (semi-)automatiquement
à partir d’un prédicat à prouver en un point du programme la précondition à vérifier
5 http://frama-c.com
6 http://www.mathworks.com/products/polyspace
7 http://www.absint.com/astree
8 http://spinroot.com
9 http://www.uppaal.com
10 http://coq.inria.fr
11 http://research.microsoft.com/en-us/um/redmond/projects/z3
8
en entrée de la fonction englobante. La principale difficulté est le passage des boucles
du programme, pour lesquelles l’utilisateur doit fournir un invariant, qui permet d’abstraire le comportement de la boucle. Les alias (plusieurs manières d’identifier la même
location mémoire, par exemple via des pointeurs) sont également source de difficulté
(complexité du modèle mémoire sous-jacent), et leur traitement correct nécessite en
général des analyses dédiées (de type interprétation abstraite ou autre).
La méthode B fournit un environnement qui comporte en particulier un assistant de
preuve dédié. Voir aussi Jessie et Why 12 .
1.3.5
Vérification formelle dans l’industrie
Si certains industriels utilisent des techniques de développement/vérification formelles
au cœur de leur métier (par exemple Siemens Transportation Systems et l’atelier B,
Airbus et l’outil Caveat développé au CEA LIST . . . ), et que le model checking connaît
un réel succès dans la vérification de matériel (au sens : description de circuits intégrés),
il y a encore beaucoup de chemin à parcourir pour que la vérification formelle pénètre
plus largement certains domaines de l’industrie (secteur automobile par exemple).
Une des difficultés vient du fait que l’offre en terme d’outils logiciels de support à la
vérification reste faible ou impose un processus de développement relativement spécifique (méthode B, outil Scade 13 ). Il est en particulier difficile de trouver un ensemble
de modèles suffisamment riches pour représenter un système complet et ceci à tous
les niveaux de conception, tout en gardant une grande liberté sur le choix des technologies (architecture matérielle, bus de communication, langage de programmation,
OS temps-réel) mais aussi organisation de l’entreprise en terme de processus métiers
(seule une petite partie des intervenants sont des ingénieurs logiciels). Si l’offre est relativement faible, c’est aussi que la preuve de logiciel automatisée était encore jusqu’à
récemment essentiellement confinée au domaine académique (preuve d’algorithmes
plutôt que d’implantations), même si les récents progrès notamment sur les solveurs
permettent d’envisager un élargissement de l’offre.
Du point de vue du test (technique par sous-approximation), il s’agit d’une technologie
largement répandue et réputée très efficace pour la recherche de défauts, notamment
lorsque l’activité de test est structurée, systématisée et automatisée. Les environnements de test unitaire (comme JUnit 14 pour Java) ont connu un réel essort, et des
techniques complexes de génération de tests sur des critères structurels sont en train
de se concrétiser dans des outils grand public (outil Pex 15 de Microsoft Research).
En parallèle se développent des langages de spécification (JML 16 , ACSL 17 ) qui permettent par exemple de spécifier des pré/post-conditions et des invariants en annotant le
code source, servant à la fois à des techniques de vérification dynamique (vérification
à l’exécution des assertions) ou de passerelle vers des assistants de preuve.
12 http://www.lri.fr/~marche
13 http://www.esterel-technologies.com/products/scade-suite
14 http://www.junit.org
15 http://research.microsoft.com/en-us/projects/Pex
16 http://www.eecs.ucf.edu/~leavens/JML
17 http://frama-c.com/acsl.html
9
10
Chapitre 2
Test de Logiciel
2.1
Généralités sur le test
2.1.1
Importance du test
Le test est une activité cruciale dans le monde du logiciel, comme l’attestent les données chiffrées suivantes :
– le poids de l’activité du test dans l’industrie du logiciel aux USA s’élève à plusieurs
dizaines de milliards de dollars par an
– en moyenne, le test représente 30 % du coût de développement d’un logiciel standard
– pour un logiciel critique (avionique, nucléaire, médical, transport), cette part moyenne
monte à 50 %
Le test est l’activité de V&V dominante, la phase remontante du cycle en V correspond
principale à l’exécution de tests. Même si un code a été prouvé formellement, le tester
reste indispensable : notamment car on teste l’implantation (ce qui va réellement s’exécuter, dans l’environnement réel d’exécution) alors qu’on ne prouve que des modèles.
Cf. cette citation de Donald Knuth : « Beware of bugs in the above code ; I have only
proved it correct, not tried it ». Enfin, le test est la technique la moins coûteuse et la
plus efficace pour capturer une grande partie des défauts d’implantation (bugs) et on ne
peut donc pas s’en priver.
2.1.2
Test : définitions et propriétés
Quelques définitions classiques :
Le test est l’exécution ou l’évaluation d’un système ou d’un composant par des moyens
automatiques ou manuels, pour vérifier qu’il répond à ses spécifications ou identifier
les différences entre les résultats attendus et les résultats obtenus 1 .
Tester, c’est exécuter le programme dans l’intention d’y trouver des anomalies ou des
défauts 2 .
1 IEEE
2 G.
(Standard Glossary of Software Engineering Terminology)
Myers (The Art of Software Testing, 1979)
11
Test de Logiciel
L’important est de retenir que le test est une méthode de vérification partielle : le test
exhaustif d’un programme en injectant toutes les entrées possibles n’est en général pas
possible. On ne prouve pas qu’un programme est correct par le test seul.
Testing can only reveal the presence of errors but never their absence 3 .
Tester sert avant tout à améliorer la qualité du logiciel, ce qui permet de réduire les
coûts de développement et de maintenance, mais également de potentiellement sauver
des vies. Des tragédies comme le crash d’un Airbus A320 ou des surexpositions mortelles à des radiations lors d’examens médicaux sont directement liées à des défauts
logiciels.
Tester consiste à stimuler un maximum des comportements d’un logiciel, en gardant à
l’esprit qu’on cherche à minimiser le nombre de tests (et surtout leur redondance) et à
maximiser leur pertinence (en exécutant des tests révélant des défauts réellement impactant). Le test est une méthode dynamique, dans la mesure où l’on exécute réellement
le programme, contrairement aux techniques par analyse statique, où seul le source est
examiné pour déterminer la correction du programme.
2.1.3
Infrastructure de test
L’infrastructure de test est l’ensemble des outils et processus permettant de mettre en
œuvre une politique de test.
La qualité d’une infrastructure de test se mesure selon les critères suivants :
– temps de détection des défauts après leur introduction (le plus court étant le mieux).
Des tests pertinents doivent être écrits et exécutés dans un délai très court après
l’introduction de nouveau code .
– précision sur la détermination de l’origine d’un défaut. L’échec d’un test doit fournir
un retour suffisant pour que l’origine du problème puisse être tracée précisément.
– capacité à caractériser l’impact système d’un défaut. Celà permet de classifier la
correction des défauts par ordre de priorité.
Le coût induit par des infrastructures de test inadéquates du point de vue de ces critères
est estimé à plusieurs dizaines de milliards de dollars par an.
2.1.4
Perspectives du test
Le test comme technique permettant de trouver des défauts (defect testing) a déjà beaucoup été évoqué. Idéalement, cette recherche de défauts se fait au plus tôt, en parallèle
du développement. C’est particulièrement aisé et conseillé à un niveau « test unitaire »,
le développeur écrivant les tests en parallèle de la fonction qu’il code (quelquefois
avant, cf. test driven development, souvent juste après), et s’aidant de ces tests pour
corriger les bugs rencontrés jusqu’à satisfaction. Le test est donc vu comme un moyen
de mettre le programme en défaut (test-to-fail) et l’on aboutit naturellement à l’obtention d’une suite de tests de non-régression.
Les suites de tests étant écrites par le développeur, on parle aussi de test « boîte blanche »
car le développeur à non seulement connaissance de la spécification de la fonction mais
3 E.
12
W. Dikjstra (Notes on Structured Programming, 1972
également de son implantation. On peut jauger la qualité de cette suite par sa capacité
à capturer des défauts lors des évolutions du code, lorsque l’implantation évolue mais
que les interfaces et spécifications restent les mêmes.
Le test peut également être vu comme un processus d’assurance qualité (validation
testing) voire comme participant à la certification du logiciel. L’idée est de contrôler
la qualité d’un logiciel, pour soi ou un tiers (autorité de certification), dans la phase
ascendante du cycle en V, généralement en boîte noire (connaissance des spécifications
mais pas de l’implantation du logiciel), et par des équipes dédiées (la DO178B peut par
exemple exiger que ces tests soient effectués par des équipes distinctes des développeurs). Cette utilisation du test est typique dans les industries développement des systèmes embarqués critiques. Le test est alors vu comme un moyen de confirmer le bon
fonctionnement du logiciel (test-to-pass).
2.2
Processus de test
Le processus de test est la façon dont le test est mis en œuvre.
2.2.1
Quelques définitions
Un scénario de test correspond à un chemin fonctionnel (issu des spécifications) que
l’on cherche à exercer. Il s’agit de définir une suite d’actions (les entrées du test) ainsi
que l’ensemble des réponses censées être déclenchées en retour.
Le domaine des entrées d’un programme est l’ensemble de ses entrées possibles : variables globales, paramètres de fonctions, actions venues de l’extérieur . . . Chaque entrée est associée à un domaine de valeurs possibles (domaine de définition), qui est un
sous-ensemble du domaine de valeurs que définit le type de l’entrée.
Les données de test associent à chaque entrée d’un programme une valeur choisie dans
son domaine de définition, ceci dans l’optique d’exercer un scénario de test.
Un oracle est un mécanisme permettant de décider la réussite d’un scénario de test,
c’est à dire de déterminer si les réponses obtenues à l’exécution du test correspondent
bien à ce que requiert le scénario.
Un cas de test est l’association d’un scénario de test, des données de test le déclenchant
et d’un oracle décidant de sa réussite. Il s’agit donc d’une étape dans la concrétisation
d’un scénario de test.
Un script de test est un mécanisme (en général un programme dédié ou un script shell)
en charge d’exécuter les cas de tests qui ont été définis pour le logiciel sous test, et de
recueillir les résultats (on parle aussi de verdict de test, suivant que l’oracle soit satisfait
ou non pour chaque cas de test).
2.2.2
Oracle de test : un exemple
Supposons qu’il faille implanter un oracle de test pour un tri rapide de type quicksort.
Une première possibilité est d’implanter un tri plus simple, comme un tri par insertion
ou un tri-bulles. Le résultat du tri rapide doit correspondre exactement au résultat du
tri simple sur tout tableau en entrée (la détermination de l’ensemble des tableaux à
13
Test de Logiciel
considérer comme données de test est un autre problème). La vérification de l’oracle
est donc simple, la difficulté principale étant de fournir une implantation du tri simple
qui soit correcte.
Pour pallier au problème d’avoir à fournir une implantation alternative d’un tri, il est
également possible de faire appel à une fonction de tri de la bibliothèque standard, que
l’on peut a priori supposer correcte. Pour le langage C on dispose par exemple du tri
rapide générique suivant :
void qsort (void *array, size_t count, size_t size,
comparison_fn_t compare)
La difficulté est alors simplement de comprendre la façon dont cette fonction doit être
appelée (ici, la taille du tableau, la taille de chaque élément et une fonction de comparaison entre éléments doivent être fournis).
Enfin, on peut également remarquer qu’implanter un oracle est souvent plus facile
qu’implanter la fonction à réaliser (vérifier qu’une solution est correcte s’avère souvent plus facile que de construire une solution). Ici, on peut par exemple vérifier que
le tableau en sortie est trié dans l’ordre qui convient (ce qui est facile), et qu’il comporte exactement les mêmes éléments que le tableau en entrée, avec le même nombre
d’occurrences (plus difficile).
2.2.3
Process simplifié du test
Le processus de test suit les étapes suivantes :
1. identification des scénarios à tester
2. détermination des oracles de chaque scénario
3. génération (manuelle ou automatique) des données de test de chaque scénario :
on dispose alors d’une suite de cas de test couvrant tous les scénarios
4. création et exécution d’un script de test évaluant le programme sur l’ensemble
des cas de tests
5. comparaison des résultats obtenus aux oracles
6. émission d’un rapport de test décrivant les cas de tests ayant réussis et ceux ayant
échoués
L’identification des scénarios à tester s’effectue lors de l’élaboration des plans de test,
en parallèle des phases de conception et de codage correspondantes.
14
La détermination des sorties attendues se fait de manière conjointe, mais les oracles
utilisés au final nécessitent généralement d’être concrétisés de manière plus précise
que cela n’est possible en conception. Il faut en particulier être capable de traduire
les entrées, sorties et observables tels que définis au niveau des spécifications en tant
qu’éléments concrets de l’implantation finale.
La génération des données de test, qu’elle soit manuelle ou automatique, constitue une
activité à part entière du test, et fera l’objet d’un chapitre spécifique.
L’activité d’exécution des tests prend place lors de la phase remontante du cycle en
V, au contraire des activités précédentes. Evidemment un constat d’échec à ce niveau
implique des corrections sur le code, la conception ou les spécifications du système
suivant la phase de test où l’on se trouve alors. La conception des tests eux-mêmes
peut bien évidemment être en cause. La capacité à émettre des rapports de test informatifs est cruciale afin de pouvoir détecter le plus précisément possible l’origine de la
divergence constatée.
2.2.4
Scripts de test
Un script de test peut schématiquement être décomposé de la manière suivante :
– Préambule : le programme est amené dans la configuration voulue pour un ou plusieurs cas de test, ceci en appelant un certain nombre de fonctions d’initialisations
et de constructeurs. Il peut par exemple s’agir d’allouer un certain nombre d’objets
ayant certaines dépendances, d’initialiser les tables d’une base de données avec certaines entrées, d’émettre ou de recevoir un ensemble de messages dans le cadre d’un
protocole . . .
– Corps : le script exécute les fonctions sous test avec les données de test qui ont été
générées.
– Identification (facultatif) : le script peut effectuer un certain nombre d’opérations
d’observation qui vont permettre de faciler l’évaluation de l’oracle. Le scénario de
test peut en effet nécessiter d’observer des actions effectuées en cours d’exécution
du test, et non pas simplement le résultat final. Le script de test doit donc permettre
de tracer les actions requises, ou de voir l’évolution des valeurs de certaines variables globales. Cela n’est possible que si le programme sous test rend ces données
effectivement observables.
– Postambule : le script réinitialise le programme dans un état initial, par exemple
l’état obtenu juste après exécution du préambule, ceci afin de permettre d’enchaîner
avec les tests restant. Il peut par exemple s’agir d’effectuer un rollback des requêtes
émises par le corps du test dans le cadre du test d’une base de données.
2.2.5
Environnement de test unitaire
Il s’agit d’un environnement (généralement une bibliothèque) qui permet de faciliter
l’écriture, la maintenance ou l’exécution des tests unitaires, et éventuellement l’évaluation de leur qualité (couverture de tests). Un tel environnement réalise tout ou partie
du travail que l’on peut attendre d’un script de test, sans le caractère ad hoc que peut
avoir un script fait maison. On peut citer à titre d’exemples : JUnit pour Java (il existe
l’équivalent pour C#), et FORT pour Objective Caml (Framework for OCaml Regression Testing).
15
Test de Logiciel
Les caractéristiques principales d’un environnement de test unitaire sont :
– le code de test est développé dans des fichiers distincts du code de développement,
qui n’est donc en aucun cas modifié par l’ajout de tests (requis pour les systèmes
critiques)
– les préambules, corps et postambules sont des fonctions virtuelles à définir dans les
classes implantant effectivement les cas de test (généricité)
– l’oracle est implanté par l’utilisateur en utilisant tout la puissance du langage de
base, ainsi que des facilités offertes par l’environnement de test
– l’environnement de test peut proposer un environnement d’exécution facilitant la
mesure de la qualité des tests (sous forme de couverture atteinte).
Voir le TP1 pour un exemple d’environnement jouet de test unitaire.
2.2.6
Mesure de la qualité d’une suite de tests
Une infrastructure de test adéquate doit permettre de pouvoir aboutir à l’obtention de
suites de tests pertinentes. L’objectif principal est d’obtenir une suite de tests dont le
taux de couverture est élevé, ce qui indique qu’une bonne proportion des comportements du logiciel est testée.
La couverture se mesure souvent sur la base de critères liés à la structure du programme
(flot de contrôle et de données), comme par exemple le taux d’instructions, de branches
ou de paires définition-utilisation effectivement exécutées.
On peut également baser la mesure de couverture sur le taux de détection de mutants
du logiciel sous test. Un mutant est un quasi-clone du programme, dans lequel seul
un petit nombre d’instructions (souvent une seule) a été modifié. On peut par exemple
choisir de remplacer un opérateur de comparaison < par ≤ ou un = par un 6=. Les
transformations effectuées pour obtenir les mutants se basent sur un modèle des fautes
les plus probables commises par le programmeur, fautes qui devraient être à même
d’être capturées par les cas de tests.
Outre la qualité de couverture obtenue, une deuxième caractéristique importante est de
disposer d’une suite de tests de taille raisonnable et comportant peu de tests redondants
(c’est à dire des paires de cas de test dont l’apport en terme de couverture est équivalent). Réduire le nombre de tests permet de réduire le coût d’écriture, d’exécution et
de maintenance des tests.
Les critères de couverture principaux seront détaillés dans le chapitre suivant.
2.3
2.3.1
Caractérisations de l’activité de test
Typologie
Afin de caractériser l’activité de test, diverses dimensions sont à prendre en compte :
A partir de quoi les tests sont-ils générés ?
Les cas de test peuvent être issus de la spécification seule : on parle alors d’approche
« boîte noire », car l’implantation est vue comme une boîte noire dont seules les entrées
et les sorties sont connues. Lorsque les cas de test sont déterminés en s’aidant du code
16
source et la connaissance de la mécanique interne du logiciel, on parle au contraire
d’approche « boîte blanche ».
Un test issu des spécifications est aussi appelé un test fonctionnel : le but du cas de test
est de mettre en avant de manière explicite une fonctionnalité du système apparaissant
dans la spécification. Un test issu du code source est le plus souvent un test structurel,
puisque la création du cas de test se fait sur la base des chemins d’exécution associés
au code (ou d’autres éléments structurels, comme le fait de chercher à atteindre une
instruction particulière dans un état-mémoire donné). On associe généralement le test
boîte noire avec le test fonctionnel, et le test boîte blanche avec le test structurel.
A quel niveau du cycle de développement se trouve-t-on ?
Les cas de tests sont élaborés (plans de test) lors de la partie descendante d’un cycle en
V typique, en parallèle des phases de spécification, conception, et de codage. L’exécution des tests se fera dans chacune des phases de la partie remontante du cycle, sur la
base des cas de tests élaborés dans la phase jumelle de la partie descendante. On parle
alors de test unitaire, de test d’intégration, de test système (ou de test de conformité).
Quel est l’objectif du test ?
Dans ce cours, l’activité de test est principalement vue comme une façon d’améliorer la
correction fonctionnelle et la sûreté d’un logiciel, en parallèle d’activités de vérification
formelle. Mais le test est également le principal moyen pour évaluer un système du
point de vue de sa résistance aux attaques (sécurité), au stress ou à la charge, et du
point de vue de sa performance (temps de réponse) et de son ergonomie.
Certains types de test ont pour objectif de fournir une aide au développement. Le test
de non-régression est un process facilitant l’écriture et l’exécution de tests unitaires à
des fins de détection rapide de défauts logiciels introduits en cours de développement.
Le test-driven development est une technique de développement agile qui utilise les
tests unitaires comme guide du développement.
Quelle est la technologie de génération des données de test ?
La réalisation concrète des cas de test nécessite de créer des données de test à même de
solliciter les scénarios de test correspondants. Différentes techniques manuelles ou automatiques coexistent pour sélectionner les données de test, suivant la nature du logiciel
à tester et le fait que la sélection des tests se fasse en boîte blanche (test mutationnel,
17
Test de Logiciel
test « symbolique ») ou noire (test combinatoire, test aux limites, test mutationnel). Ces
technologies seront décrites plus en détail dans le chapitre suivant.
Teste-t-on le code ou un modèle du code ?
La communauté du model-based testing focalise son attention sur le test de modèles de
conception, généralement à base d’automates finis, qui se prêtent bien aux techniques
de génération automatique de tests structurels par des méthodes symboliques. Dans
ce cours on se concentre davantage sur le test de code source, étant entendu que les
techniques de génération de tests qui seront vues peuvent être adaptées à des modèles
à base d’automates. En outre, la vérification de modèles à base d’automates finis a fait
l’objet d’un travail considérable : cf. le cours de model checking.
2.3.2
Test Fonctionnel, Test Structurel
La détermination des cas de test est une part essentielle du test. On parle également
de génération, ou sélection, de cas de test. On peut classifier les différentes techniques
permettant de déterminer les cas de test en deux grandes familles : le test fonctionnel
et le test structurel.
Test fonctionnel : On parle de test fonctionnel lorsque le cas de test est conçu à partir
des spécifications du logiciel (par exemple les cas d’utilisation définis par UML). Le
concepteur du test n’a pas accès au code source ou a choisi de ne pas regarder la façon
dont le logiciel a été écrit : c’est pour celà qu’on parle également de test en boîte
noire. Il s’agit principalement d’évaluer dans quelle mesure les fonctionnalités que les
spécifications requièrent du système sont réalisées.
Test structurel : le cas de test est conçu en partant du code source (on parle également
de test en boîte blanche). Le testeur essaie de mettre en évidence la façon dont l’implantation a réalisé la fonctionnalité requise en terme d’éléments de programmation
18
(structures de contrôle notamment), et écrit les tests en fonction des chemins d’exécution qu’il veut voir sollicités. Il est évidemment nécessaire d’avoir accès au code source
et d’être capable de le comprendre de manière détaillée.
Le test fonctionnel ne se basant que sur les spécifications, normalement plus directement compréhensibles que le code, l’écriture de cas de test s’en trouve facilitée, et
ils ont un sens fonctionnel généralement assez évident. De même, la détermination
des oracles est en général simple car explicite dans la spécification. En revanche, les
spécifications n’étant pas toujours formelles ou très précises, il peut être difficile de
concrétiser les données de test ou de réaliser effectivement l’oracle sans passer par
une analyse approfondie des documents de conception. En essence, on ne teste que
les fonctionnalités attendues du programme, et il est peu probable de mettre à jour des
fonctionnalités cachées ou des erreurs à l’exécution non triviales sans les rechercher
explicitement.
Le test structurel étant déterminé à partir du code source, la réalisation du script de test
s’en trouve facilitée. En revanche la détermination de l’oracle peut poser des difficultés : un test sollicite un chemin dont la sémantique en termes fonctionnels demande
potentiellement beaucoup d’investissement de la part du testeur. De plus, des fonctionnalités de haut niveau relativement claires au niveau des spécifications peuvent être
difficiles à retrouver au niveau de l’implantation.
Il va de soit que le test fonctionnel et le test structurel sont complémentaires.
2.3.3
Phases du test
Le test unitaire a pour objectif de tester les procédures, modules, ou autres composants
(classes dans un contexte orienté object) en isolation. La plus grande partie des techniques de détermination des cas de tests seront décrites dans le cadre des tests unitaires.
Les tests d’intégration servent à tester le comportement obtenu lors de la composition
de procédures et modules pour former des sous-systèmes, qui réalisent des fonctionnalités de plus haut niveau.
Enfin les tests système / d’acceptation / de conformité permettent de valider le comportement fonctionnel du code par rapport aux spécifications générales du système, ainsi
que sa conformité aux exigences.
Les modèles en spirale recommandent de développer l’application finale par des incréments conduisant tous à des prototypes intermédiaires réalisant une partie toujours
croissante des fonctionnalités demandées. Le processus de test évolue alors en fonction
de l’étape du développement. Les premières étapes se focalisent sur l’écriture d’un plan
de test qui évoluera tout au long du développement en spirale, ainsi que sur l’écriture
de tests unitaires et d’intégration qui seront raffinés dans les étapes ultérieures, tout
comme le seront les exigences et les spécifications. Lorsque les exigences se stabilisent,
les tests sytème et d’acceptation sont introduits.
Les processus de développement et de test agiles et plus précisément le développement
orienté par les tests lient très fortement les activités de développement et de test. Les
exigences sont spécifiées sous forme de tests, et le code de test est souvent écrit avant
le code applicatif.
19
Test de Logiciel
2.3.4
Autres types de tests
Il existe toute une gamme de tests que l’on ne détaillera pas plus avant, dirigés par un
objectif spécifique (on parle de goal-directed testing) et s’appliquant le plus souvent
à des applications complètes. Des exemples sont le test de robustesse, de charge, de
stress, ou de performance.
2.4
2.4.1
–
–
–
–
Pièges du test
Par rapport au rôle du test
ne pas chercher à trouver les défauts importants,
ne pas estimer la qualité des tests, ni la qualité de l’estimation,
ne se soucier de la qualité du logiciel que lors de la phase de test,
se fier uniquement au test pour vérifier un logiciel.
2.4.2
–
–
–
–
–
–
–
Par rapport au processus de test
ne pas budgétiser ou planifier l’activité de test,
se baser principalement sur du test fonctionnel,
ne pas faire de revue de conception des tests,
produire des rapports de test peu informatifs,
ne pas faire de test de configuration, charge, stress, procédure d’installation,
commencer les tests trop tard,
ne se fier qu’au taux de couverture comme mesure de qualité des tests ou des testeurs.
2.5
Conclusion
Le test reste une technique permettant avant tout de révéler les défauts d’un logiciel,
plutôt que de prouver sa correction, du simple fait qu’il s’agit d’une technique travaillant par sous-approximation. Il ne faut néanmoins pas minimiser son intérêt : la
plupart des systèmes industriels classiques sont validés principalement à travers du
test, souvent parce qu’il s’agit de la seule technologie efficace pour un coût restant
raisonnable.
Le test a l’avantage considérable de ne pas exiger de modifier les processus de développement, qui l’intègre déjà, ni d’exiger de la part des équipes de développement une
culture forte en vérification formelle. Comparativement aux techniques formelles, il
ne nécessite donc pas d’investissement lourd, que ce soit en terme de formation ou de
temps de développement. Il peut également être utilisé jusqu’à satisfaction d’un objectif quantitatif (de couverture par exemple), alors qu’il fait rarement sens de prouver un
système partiellement. Les outils de génération de tests sont robustes, dans le sens où
les tests générés peuvent être rejoués et que les défauts du générateur peuvent donc être
détectés. C’est rarement le cas pour les autres types d’outils de vérification, qu’il est
alors nécessaire de certifier pour une utilisation industrielle. Enfin le test a l’avantage
de pouvoir détecter tous les types de défauts, alors que des techniques comme l’interprétation abstraite ne visent généralement qu’à détecter des erreurs à l’exécution.
20
Chapitre 3
Sélection des Tests
Ce chapitre traite le problème de la sélection (ou génération) des cas de test par des
méthodes manuelles ou automatique. L’objectif idéal est, pour un programme donné,
de trouver un ensemble de tests qui permette de révéler tous ses défauts.
La première partie traite des méthodes permettant de générer des cas de test pour du
test fonctionnel (ou boîte noire). La seconde partie traite des différentes mesures de la
couverture d’une suite de tests, qui permettent de guider une génération de cas de test
« boîte blanche ».
3.1
Génération boîte noire
La génération des tests en boîte noire se base sur les spécifications fonctionnelles d’un
programme (ou plus généralement, ses exigences), et impose a minima de pouvoir
identifier le domaine des entrées du programme sous test ainsi que les oracles. Cette
technique de génération ne présuppose en revanche aucune connaissance de la structure
interne du programme, par exemple parce qu’on n’en dispose pas encore, ou qu’on ne
cherche pas à l’exploiter. Il s’agit d’une technique applicable à tous les niveaux du
cycle en V, et qui permet d’exploiter des exigences spécifiées informellement.
3.1.1
Analyse partitionnelle
L’analyse partitionnelle appliquée au test a pour objectif de partitionner le domaine
d’entrée d’un programme en un nombre fini de classes d’équivalences représentatives
de classes de comportements, puis de sélectionner (au moins) un test dans chaque classe
d’équivalence. L’idée est que le comportement du programme doit être « équivalent »,
d’un point de vue fonctionnel, pour toutes les valeurs d’une classe d’équivalence. Si
la partition est correctement réalisée, on peut a priori ne choisir qu’un cas de test par
classe d’équivalence.
Dans une approche par analyse partitionnelle, la stratégie de sélection des cas de test
est la suivante :
1. Analyser les exigences pour identifier d’une part les entrées du programme et
leurs domaines, et d’autre part les fonctionnalités réalisées. C’est la démarche
21
Sélection des Tests
classique pour toutes les formes de test.
2. Utiliser l’information obtenue pour définir des classes d’équivalence valides et
invalides sur les entrées, et pour définir également un oracle par classe. Une
classe d’équivalence valide ne regroupe que des entrées du programme valides,
c’est à dire telles que la valeur de chaque entrée soit choisie dans son domaine
de définition. A contrario, une classe d’équivalence invalide ne regroupe que des
entrées du programme invalides, c’est à dire telles que pour au moins une entrée,
le valeur choisie ne soit pas dans le domaine de définition.
La distinction entre entrée valide et invalide peut s’avérer nécessaire dans certains cas, comme lorsque l’utilisateur doit saisir une partie des entrées via le
clavier de manière libre : il n’est alors pas aisé de le contraindre à saisir une
valeur valide.
3. Choisir au moins une donnée de test par classe d’équivalence, qui sera associée
à l’oracle correspondant pour obtenir un cas de test.
Exemple 1
Supposons que l’on cherche à tester un programme calculant la valeur absolue d’un
entier à partir d’une entrée au clavier (donc une chaîne de caractères) supposée fournie
en notation décimale.
La spécification informelle de la fonction indique que le programme attend une entrée
unique, dont le type est une chaîne de caractères str, et que str représente un entier
relatif en notation décimale.
Les entrées invalides du fait d’un nombre invalide d’entrées saisies (aucune entrée, ou
plus d’une entrée) correspondent à des cas où la chaîne obtenue est soit vide, soit contenant plusieurs mots séparés par des espaces. On peut donc définir deux classes d’équivalence d’entrées invalides : la classe « chaîne vide » (dont l’unique représentant est la
chaîne vide), et la classe « plusieurs mots » (dont un représentant est "1234 1234").
Même pour une entrée unique et non vide, il est possible que l’utilisateur saisisse une
chaîne qui ne corresponde pas à un entier relatif en notation décimale. Ceci correspond
à une nouvelle classe d’équivalence d’entrées invalides « pas un décimal », avec comme
exemples de représentants "0x1234", "+12", "56a", "-0.0".
Il reste à définir les classes d’équivalence d’entrées valides. La fonctionnalité « valeur
absolue » du programme indique clairement qu’il est bon de définir au moins deux
classes d’équivalences, « décimal positif » (les chaînes correspondant à la notation décimale d’entiers ≥ 0 : par exemple "1234", "0") et « décimal négatif » (les chaînes
correspondant à la notation décimale d’entiers < 0, par exemple "-1234"). Les oracles
sont triviaux.
Il est également utile de voir si l’on accepte des entrées limites, notamment la chaîne
"-0" : avec les définitions choisies ci-dessus, il s’agirait d’une entrée invalide appartenant à la classe « pas un décimal ».
Pour résumer, on obtient au final un minimum de cinq cas de test, dont deux valides.
22
classe
chaîne vide
plusieurs mots
pas un décimal
décimal positif
décimal négatif
validité
invalide
invalide
invalide
valide
valide
représentant
""
"1234 1234"
"56a"
"1234"
"-1234"
oracle
échec
échec
échec
1234
1234
Exemple 2
L’objectif est de tester une fonction maxsum(value, maxint) dont la spécification
suit. Cette fonction calcule la somme des premiers value entiers tant que cette somme
reste plus petite que maxint. Sinon, une erreur est affichée. Si value est négatif, la
valeur absolue de value est considérée.
Afin de définir les classes valides et invalides, il est utile d’introduire la relation binaire
C1(x, y) définie par :
C1(x, y) ≡
Px
i=0
i≤y
On obtient alors la partition suivante du domaine des entrées :
Domaine
domaine de value
domaine de maxint
Classes valides
entier < 0
satisfaisant
C1(-value,maxint)
entier ≥ 0
satisfaisant
C1(value,maxint)
entier ≥ 0
Classes invalides
entier < 0
ne satisfaisant pas
C1(-value,maxint)
entier ≥ 0
ne satisfaisant pas
C1(value,maxint)
entier < 0
On voit ici l’intérêt de C1 qui permet de contraindre la valeur absolue de value en
fonction de maxint tel que l’exige la spécification : la satisfaction de la contrainte C1
conditionne la validité des entrées.
Quant à la partition du domaine de maxint, elle découle naturellement du fait qu’une
valeur strictement négative ne permette pas d’aboutir à un résultat autre que l’affichage
d’une erreur.
Les classes valides ne comprennent que des entrées valides : il faut donc à la fois que
maxint ≥ 0 et que l’on ait C1(|value|, maxint). Ceci permet de définir deux
classes d’équivalence valides selon que value ≥ 0 ou value < 0.
Pour les classes invalides, la meilleure façon de procéder est de ne considérer qu’une
source d’invalidité à la fois. Ici, on définit une classe pour laquelle maxint < 0
(invalidité sur le domaine de maxint), et deux classes pour lesquelles maxint soit
valide mais C1(|value|, maxint) ne soit pas satisfait (avec des valeurs positives et
négatives de value). On aboutit donc à cinq classes d’équivalence, et donc cinq cas
de tests au total :
23
Sélection des Tests
maxint
100
100
10
10
-10
3.1.2
value
10
-10
5
-5
1
validité
valide
valide
invalide ¬ C1
invalide ¬ C1
invalide maxint < 0
oracle
55
55
erreur
erreur
erreur
Test aux limites
Le test aux limites permet de compléter une analyse partitionnelle en introduisant des
tests dont l’objectif est de solliciter des entrées se trouvant aux limites (frontières)
des classes d’équivalence. L’idée sous-jacente en terme de modèle de fautes est que le
développeur a tendance à introduire des erreurs sur les cas limites, qu’il faut donc tester
de manière spécifique.
La stratégie pour le test aux limites est la suivante :
– suite à une analyse partitionnelle, identifier les frontières des classes d’équivalence
et sélectionner des tests y correspondant. Pour l’exemple de la fonction maxsum,on
choisira par exemple des valeurs de maxint et value qui satisfont exactement
P|value|
P|value|
i = maxint (cas limite valide), et aussi i=0 i = maxint + 1 (cas
i=0
limite invalide). De plus, un cas comme |value| = maxint = 1 est intéressant à
tester.
– de manière générale, identifier et tester les bornes des domaines des entrées du programme. Pour un domaine de type intervalle d’entiers [a, b] (avec a < b), il est intéressant de tester les valeurs invalides a−1, b+1 et les valeurs valides a, a+1, b−1, b.
Pour un domaine de type « ensemble fini », il faut sélectionner l’ensemble vide, des
singletons, des paires, et des ensembles avec beaucoup d’éléments. Pour une entrée de type fichier (en lecture), il faut considérer les cas du fichier vide, inexistant,
inacessible en lecture par l’utilisateur, et d’un fichier « normal ».
– de même, identifier les bornes des sorties et sélectionner des entrées permettant de
produire ces valeurs en sortie. Pour une fonction inv(x) implantant 1/x pour une
entrée flottante, on sélectionnera des valeurs très proches de 0.0 par exemple, afin
d’obtenir de très grands nombres en sortie, ou au contraire des valeurs très grandes
pour obtenir un résultat proche de 0.0.
3.1.3
Test combinatoire : approche n-wise
L’approche décrite ici permet de sélectionner un petit nombre de configurations de test
significatives parmi un ensemble de configurations dont la combinatoire explose. Le
test exhaustif est en effet impratiquable, même sur de petits programmes : pour un programme ayant 4 entrées qui sont des entiers codés sur 32 bits, il y a 2128 combinaisons
de valeurs différentes possibles.
L’approche pairwise consiste à tester un fragment des combinaisons de valeurs de façon à garantir que chaque combinaison de deux valeurs est testée. L’approche n-wise
est une généralisation, où l’on teste chaque combinaison de n valeurs. L’idée sousjacente en terme de modèle de fautes (défauts) est qu’une majorité de défauts sont
détectables par des combinaisons de deux valeurs de variables. Un défaut déclenché
par une certaine combinaison de valeurs pour n variables d’entrée est appelé un défaut
24
d’interaction, et est à même d’être capturé par une approche n-wise.
A titre d’exemple, supposons que le système sous test soit un calculateur (ECU) communiquant sur un certain nombre de bus de terrain. L’ECU est paramétrée par le choix
d’un OS (système d’exploitation) et d’un CPU (processeur). Chaque bus de terrain
est paramétré par le choix d’un protocole réseau. Les domaines d’entrée sont définis
comme suit :
OS
VxWorks
QNX
Linux RT
Protocole
CAN
Bluetooth
TTP
CPU
PowerPC 750
ARM 9
Star12X
Bus
Confort
Mécanique
Diagnostic
Le nombre total de combinaisons pour un test exhaustif est de 34 = 81, ce qui correspondrait à du 4-wise. Une combinaison correspond ici à une configuration du système,
pour laquelle tous les cas de tests doivent être exécutés. Ceci peut donc prendre un
temps considérable. Une approche pairwise peut ici de manière crédible capturer un
grand nombre de défauts liés aux interactions entre les différentes dimensions du système (exemple fictif : Linux RT implante un temps réel trop mou pour qu’un protocole
time-trigger comme TTP fonctionne correctement). Il y a alors au plus 9 combinaisons
à tester, par exemple :
OS
VxWorks
VxWorks
VxWorks
QNX
QNX
QNX
Linux RT
Linux RT
Linux RT
3.1.4
Protocole
CAN
Bluetooth
TTP
CAN
Bluetooth
TTP
CAN
Bluetooth
TTP
CPU
PPC
ARM
Start12X
Star12X
PPC
ARM
ARM
Star12X
PPC
Bus
Confort
Méca
Diag
Méca
Diag
Confort
Diag
Confort
Méca
Génération aléatoire
La génération aléatoire de tests constitue le mètre-étalon des techniques de génération :
une stratégie de sélection des cas de test n’est considérée comme pertinente que si elle
permet d’obtenir une suite de tests dont la qualité en terme de niveau de couverture
soit significativement meilleure que celle de tests générés selon une stratégie aléatoire.
Genérer des cas de test en tirant uniformément des valeurs dans le domaine des entrées
est en effet une technique rudimentaire qui ne permet pas de prendre en compte les
dépendances entre les entrées. En outre, elle n’est adaptée que pour des cas où un
oracle global, c’est à dire valable pour tous les cas de test, peut être défini.
Il reste que malgré ses inconvénients, et pour peu qu’on dispose d’un oracle, la génération aléatoire a pour avantage majeur sa facilité de mise en œuvre. La génération de
tests aléatoires est facilement automatisable lorsque les entrées et leurs domaines de
valeurs sont simples. Le tirage uniforme des valeurs se fait en utilisant un générateur
de nombres aléatoires non-biaisé. Il est également possible d’implanter un tirage suivant des lois statistiques plus complexes que des tirages uniformes indépendants, et de
combiner le test aléatoire avec du test aux limites de manière assez naturelle.
25
Sélection des Tests
En conclusion, pour le test unitaire d’un grand nombre de fonctions, utiliser le test
aléatoire dans une première phase n’a rien d’infamant et est à même de mettre à jour
facilement les défauts les plus grossiers. Il faut juste rester conscient du fait que du test
aléatoire seul a bien peu de chances de permettre d’aboutir à une suite de tests ayant de
bonnes qualités de couverture, et qu’il faut presque toujours le compléter par d’autres
techniques.
3.1.5
Autres techniques de génération
Génération à partir d’un modèle
Les exigences peuvent être formalisées sous forme de modèles généralement basés sur
la notion d’automates finis (certains types de diagrammes UML, StateCharts etc.) Une
telle formalisation peut être exploitée pour sélectionner des cas de test, par exemple
en cherchant à couvrir tous les chemins de contrôle jusqu’à une profondeur limite de
l’automate.
Générer des tests à partir d’un modèle (model-based testing) permet d’une part de le
valider par rapport à une spécification, et d’autre part d’obtenir des cas de test pour
tester l’implantation finale du point de vue « contrôle », les automates finis permettant
difficilement de représenter les données. Il peut paraître inutile de chercher à générer
des tests sur la base d’automates finis, puisqu’il existe des techniques de vérification
formelle de type model checking qui permettent de vérifier exhaustivement des propriétés temporelles sur de tels modèles. L’intérêt de générer des tests est de pouvoir les
exécuter sur l’implantation finale, alors que le model checking ne peut garantir que la
correction du modèle et que son adéquation avec l’implantation doit être vérifiée par
d’autres moyens.
Génération à partir de prédicats
Les prédicats sont la représentation formelle des conditions et propriétés sur les variables que l’on peut trouver dans les spécifications comme dans le code source. Ils se
présentent sous la forme de formules booléennes sur des conditions atomiques, c’est
à dire que l’on combine des conditions « simples » comme x = 0, var1 > var2 ,
f (g(2)) = 3 avec des connecteurs logiques comme ¬, ∧, ∨.
Le test de prédicats (predicate testing) se base sur le modèle de fautes qui considère
que l’encodage des prédicats ou des expressions arithmétiques atomiques conduit à des
erreurs des types suivants :
–
–
–
–
–
opérateur booléen incorrect : remplacer un ∧ par un ∨ ;
encodage de la négation du prédicat voulu ;
remplacement d’une variable booléenne par une autre ;
erreur dans un opérateur relationnel : < au lieu de ≤ ;
erreur dans les constantes, décalage de 1 ou −1 dans une expression arithmétique,
etc.
Il s’agit donc de générer des cas de test à même de capturer ce type d’erreurs. Par
exemple, si le prédicat α = a < b ∧ c > d apparaît, on considère par exemple le
prédicat β = a < b ∨ c > d, et on sélectionne un test dont la valeur de vérité est
différente sur α et β : par exemple en choisissant a, b de façon à ce que a < b soit vrai,
26
et c, d de façon à ce que c > d soit faux.
Diagrammes causes-effets
Les diagrammes causes-effets permettent de modéliser quelles combinaisons d’un ensemble de causes (valeurs des entrées) provoquera quel ensemble d’effets (valeurs sorties). Il s’agit donc d’une modélisation graphique des relations de dépendance entre les
entrées (causes) et les sorties (effets) du système, qui se base sur les graphes élémentaires suivants :
D’autres dépendances plus complexes peuvent également être représentées. L’intérêt
principal de la démarche est qu’un diagramme causes-effets est un objet formel, à partir duquel on peut automatiquement générer une table de décision équivalente. Des
heuristiques sont en général nécessaires pour limiter le nombre de colonnes de la table
et donc de tests, sans pouvoir assurer que les tests retirés n’aient pas d’intérêt.
3.2
Génération boîte blanche et critères de couverture
Cette section décrit la génération de tests en boîte blanche, c’est à dire à partir du code
source. L’idée principale est de sélectionner les cas de test sur la base des chemins
d’exécution qu’ils vont permettre de couvrir. Le nombre de chemins d’un programme
étant généralement très grand voire infini (penser aux boucles), en pratique on cherche
à générer des tests permettant de couvrir certains éléments plus limités. Ces éléments
peuvent être issus soit du flot de contrôle, comme par exemple les blocs d’instructions
ou les branches, soit du flot de données, comme les paires définition-utilisation. On peut
également chercher à couvrir des « mutants » du programme original, en transformant
par exemple les prédicats correspondant aux conditions selon un modèle de fautes ad
hoc.
27
Sélection des Tests
Le choix des éléments à couvrir détermine donc un critère de couverture, et la suite de
tests s’évalue en fonction du taux de couverture du critère choisi, qui est le pourcentage
des éléments couverts parmi les éléments effectivement atteignables. La définition d’un
critère de couverture permet d’orienter naturellement la sélection des cas de tests, voire
de contribuer à son automatisation pour des programmes de taille raisonnable : voir le
chapitre 4.
3.2.1
Graphe de contrôle
Le graphe de contrôle (Control Flow Graph ou CFG) d’un programme est un graphe
fini et orienté représentant sa structure de contrôle. Intuitivement, il s’agit d’une abstraction de l’espace des états accessibles et de la relation de transition sous-jacente,
mais limitée aux seuls points de contrôle : les états mémoires ne sont pas représentés.
On se place dans l’hypothèse d’un programme ayant une seule fonction : un programme
typique comporte un CFG par fonction, en distinguant le point d’entrée principal et en
représentant la structure des appels dans une structure à part appelée graphe d’appel
(ou Call Graph).
De manière plus précise, chaque nœud du CFG correspond à une instruction, modulo
le fait que certaines instructions sont regroupées en blocs (basic blocks). Pour cela,
il faut qu’il s’agisse d’instructions qui ne soient ni des branchements (le flot passe
nécessairement à l’instruction suivante) ni des cibles de sauts (labels : le flot ne peut
pas brancher sur cette instruction sans être passée par l’instruction précédente dans le
bloc, sauf pour la première instruction du bloc), ni des instructions spéciales (voir ciaprès). Il existe un nœud initial START (le point d’entrée du programme, typiquement
la fonction main en C) et un ou plusieurs nœuds finals (RETURN, ERROR, HALT).
Les arcs du CFG correspondent au flot de contrôle (intra-procédural, si l’on se place
dans un contexte avec plusieurs procédures ou fonctions). Les sauts avant ou arrière
sont représentés par un arc, les conditionnelles (if) par une paire d’arcs, et les instructions de type switch/case généralement par plusieurs. Sur un branchement (choix
entre deux arcs depuis un nœud) les arcs sortants sont étiquetés par des conditions
booléennes mutuellement exclusives et couvrant tous les cas possibles.
On suppose qu’il existe au moins un chemin entre le nœud inital START et tout autre
nœud du graphe. Voir la figure 3.1 pour un illustration de la notion de CFG.
3.2.2
Couverture des blocs, couverture des arcs
La couverture des blocs d’un CFG consiste à couvrir l’ensemble de ses nœuds, c’est
à dire de sélectionner des tests dont l’exécution traversera chaque nœud au moins une
fois. Il s’agit du critère de couverture le plus faible qui soit. La couverture des arcs vise
quant à elle à couvrir l’ensemble des arcs du CFG. Ce critère est strictement plus fort,
dans le sens où couvrir tous les arcs impose de couvrir tous les blocs.
La mesure de la couverture des blocs d’une suite de tests se fait en considérant :
– l’ensemble de tous les blocs : Be
– l’ensemble de tous les blocs couverts par les tests : Bc
– l’ensemble des blocs ayant été déterminés comme inatteignales : Bi
Un bloc bl est dit inatteignable s’il n’existe aucune exécution du programme partant
28
START
input ( i )
sum : = 0
l o o p : i f ( i > 5 ) g o t o end
input ( j )
i f ( j < 0 ) g o t o end
sum : = sum + j
i f ( sum > 1 0 0 ) g o t o end
i : = i +1
goto loop
end : HALT
F IG . 3.1 – Programme et son graphe de contrôle
du nœud START et traversant bl. Il ne s’agit pas d’une notion structurelle, car on fait
l’hypothèse (cf. définition du CFG) que l’ensemble π des chemins de START à bl
est non vide. Si bl est inatteignable, alors tout chemin appartenant à π impose des
conditions sur les données qui sont incompatibles, ce qui empêche toute exécution
d’atteindre bl. A titre d’exemple naïf, on peut considérer une instruction imbriquée
dans une double conditionnelle (x > 0) et (x < 0). On parle également de code mort.
Les blocs déterminés avec certitude comme inatteignables peuvent être retirés de la
mesure de couverture de blocs, puisqu’aucun test n’est susceptible de les couvrir. Le
problème de déterminer les blocs inatteignables est bien entendu en général indécidable. On aboutit donc à la formule suivante pour le taux de couverture des blocs T (où
|S| correspond au cardinal de l’ensemble S) :
T = |Bc |/(|Be | − |Bi |)
Une suite de tests est adéquate pour le critère de couverture des blocs si T = 1 : on
atteint alors 100% de couverture des blocs atteignables. Un critère plus fort est d’exiger
en plus qu’il n’y ait aucun bloc inatteignable (pas de code mort), ce qui correspond au
niveau C de la norme DO178-B.
Le taux de couvertures des arcs (ou branches) se calcule de manière équivalente, la notion d’arc inatteignable (ou infaisable) se substituant à celle de bloc inatteignable. Il y a
cependant un point important qui a été omis dans la définition du CFG : quelles conditions (simples ou composées) accepte t’on pour étiqueter les arcs ? La figure suivante
montre qu’un programme peut être associé à des CFG très différents selon que l’on
accepte des conditions simples (atomiques), ou des conditions composées (expressions
booléennes sur les conditions atomiques) :
29
Sélection des Tests
Les deux choix sont possibles, mais mesurer la couverture des arcs sur la base de
conditions simples a un sens plus évident. Le traitement des conditions composées
est à la base du critère MC/DC (voir section 3.2.4).
Limites du critère « tous les blocs »
On considère le programme/CFG suivants :
Une analyse rapide permet de déterminer que le seul cas de test CT1 = {x = 1}, qui
sollicite le chemin [abcd], permet de couvrir tous les blocs du CFG. Or le cas de test
CT2{x = 0} permet de mettre à jour un défaut. Ceci illustre le fait que la satisfaction
d’un critère de couverture arbitraire n’implique en rien l’absence de défauts.
Limites du critère « toutes les branches »
La fonction illustrée ci-arpès est censée implanter le calcul de l’inverse de la somme
des éléments d’un tableau, en émettant une erreur pour le cas inf > sup :
30
Le critère « toutes les branches » est satisfait par la sélection de l’unique cas de test
CT1 = {a[3] = {50, 60, 60}, inf = 0, sup = 2}.
Or le chemin [t1 , t4 ], qui révèle un défaut dans l’implantation fournie, est faisable, par
exemple en sélectionnant :
CT2 = {inf = 1, sup = 0}
3.2.3
Couverture des décisions, conditions
Conditions et décisions
Les conditions et décisions se réfèrent directement à des éléments du code source du
programme, sans passer par une représentation de type CFG. Comme vu précédemment, une condition correspond à un prédicat présent dans le programme, et s’évalue
donc à vrai ou faux. Une condition est dite simple si elle est soit atomique, généralement du type expr1 ∼ expr2 , où ∼ ∈ {<, ≤, =, 6=, . . .} et expri est une expression
arithmétique, soit la négation d’une condition atomique. Une condition est dite composée (compound condition) s’il s’agit d’une expression booléenne sur au moins deux
conditions simples, comme par exemple (x > y) ∨ ¬(x = 0 ∧ y = 0).
Les conditions se retrouvent naturellement dans les structures de contrôle comme if,
while, mais aussi dans des instructions d’affectation comme b = (x > 0) ∧ (y > 0),
b étant vu comme un booléen, et pouvant être testé par la suite.
Une décision est définie comme étant un point de choix entre deux destinations du
programme, choix qui s’effectue sur la base de l’évaluation d’une condition. Un if ou
un while correspondent chacun à une décision, et une construction de type switch
comporte en général plusieurs décisions.
Couverture des décisions
Une décision est considérée comme couverte si, après exécution des tests, le flot de
contrôle est passé par les deux destinations (branches) qui sont associées à la décision :
ceci est à mettre en parallèle avec la couverture des arcs du CFG. Ceci revient donc à
– couvrir les deux branches d’un if,
– couvrir à la fois la condition d’arrêt ou de continuation pour une boucle while,
31
Sélection des Tests
switch ( expr ) {
case 1
: /∗ decision 1 ∗/
/∗ decision 1 :
branche ’ v r a i ’ ∗/
f1 ( ) ;
break ;
/∗ decision 1 :
branche ’ faux ’ ∗/
case 2
:
/∗ decision 2 ∗/
/∗ decision 2 :
branche ’ v r a i ’ ∗/
f2 ( ) ;
break ;
/∗ decision 2 :
branche ’ faux ’ ∗/
default :
fdefault ();
}
F IG . 3.2 – Décisions : cas du switch
i f ( x == 0 ) / ∗ d e c i s i o n ∗ /
/∗ branche ’ v r a i ’ ∗/
ftrue ();
else
/∗ branche ’ faux ’ ∗/
ffalse ();
F IG . 3.3 – Décisions : cas du if
– pour un switch, qui comporte généralement plusieurs décisions, couvrir l’ensemble
des cas possibles (cas par défaut compris).
On peut noter que même si un seul test peut potentiellement couvrir les deux destinations d’une décision à lui tout seul (cas des boucles par exemple), ce n’est nullement
requis, et que la couverture d’une décision peut utiliser deux tests différents.
Le taux de couverture T pour le critère « toutes les décisions » se calcule sur la base
des ensembles suivants :
– De : ensemble des décisions du programme,
– Dc : ensemble des décisions couvertes par la suite de tests,
32
– Di : ensemble des décisions (démontrées) infaisables, par des techniques alternatives. Une décision est infaisable ssi l’une des branches associée est infaisable (il est
impossible que les deux branches soient infaisables).
T = |Dc |/(|De | − |Di |)
Une suite de tests est adéquate pour le critère « toutes les décision » lorsque T vaut 1.
On peut aussi vouloir exiger n’avoir aucune décision infaisable (Di = ∅), mais cela
peut ne pas être possible (cf. présence dans le programme d’instructions testant des
défaillances matérielles).
Couverture des conditions
L’ensemble des conditions simples du programme est obtenu en considérant toutes
les conditions (simples ou composées) du programme, qu’elles apparaissent dans les
structures de contrôle, comme if, while, switch/case, ou dans des expressions, comme b = (x > 0)||(y > 0). Un prédicat x > 0 peut apparaître plusieurs fois
dans le programme : chaque apparition correspond à une nouvelle instance de condition
simple.
Une condition simple est couverte par une suite de tests si elle a été évaluée à vraie lors
de l’exécution d’un test t1 , et à faux lors de l’exécution d’un test t2 : il est possible que
t1 = t2 si le chemin d’exécution du test évalue la condition simple plus d’une fois. Le
critère de couverture « toutes les conditions » se réfère à la proportion des conditions
simples couvertes par une suite de de tests. Le taux de couverture T associé se calcule
de la manière suivante :
– Ce : ensemble des conditions simples du programme,
– Cc : ensemble des conditions simples couvertes par la suite de tests,
– Ci : ensemble des conditions simples (démontrées) infaisables. Une condition simple
est infaisable si elle ne peut prendre qu’une seule valeur de vérité quelle que soit la
valuation des variables la concernant (comme par exemple true, (x > 0) ∧ (x < 0),
. . . ).
T = |Cc |/(|Ce | − |Ci |)
Exemple 1
On considère la spécification suivante, donnée sous la forme d’une table de vérité liant
les entrées du programme x, y (entiers relatifs) et la valeur de la sortie z :
x<0
true
true
false
false
y<0
true
false
true
false
Output(z)
foo1(x,y)
foo2(x,y)
foo2(x,y)
foo1(x,y)
On cherche à vérifier si l’implantation P1 suivante satisfait à la spécification ou non.
33
Sélection des Tests
int x , y , z ;
input (x , y );
i f ( x < 0 & y < 0)
z = foo1 ( x , y ) ;
else
z = foo2 ( x , y ) ;
output ( z ) ;
La suite de tests {t1 : (x, y) = (−3, −2); t2 : (x, y) = (−4, 2)} est adéquate sur P1
pour le critère de couverture des décisions. La sortie pour t1 est z = f oo1(x, y), et
pour t2 on obtient z = f oo2(x, y) ce qui est correct par rapport à la spécification (respectivement première et deuxième ligne de la table de vérité). En revanche la suite de
tests n’est pas adéquate pour le critère de couverture des conditions, puisqu’on mesure
T = 0.5 (la condition x < 0 n’est pas couverte, puisqu’elle s’évalue à vrai pour t1 et
t2 ).
En revanche, le test t3 : (x, y) = (3, 4) permet de révéler un défaut dans l’implantation : en effet P1 retourne z = f oo2(x, y) alors que la quatrième ligne de la table de
vérité indique qu’il faut obtenir z = f oo1(x, y). En outre, la suite de tests {t1 , t2 , t3 }
est adéquate pour les critères « toutes les décisions » et « toutes les conditions ». La
troisième ligne de la table de vérité de la spécification n’est couverte par aucun de ces
tests.
Exemple 2
Soit le programme P2 suivant :
int x , y , z ;
input (x , y );
i f ( x < 0 | y < 0)
z = foo1 ( x , y ) ;
else
z = foo2 ( x , y ) ;
output ( z ) ;
/ ∗ (&) d e v i e n t ( | ) ∗ /
Soit les suites de tests
S1 = {(x, y) = (−3, 2); (x, y) = (4, 2)}
S2 = {(x, y) = (−3, 2); (x, y) = (4, −2)}
On vérifie que S1 est adéquate pour le critère « toutes les décisions », mais pas pour le
critère « toutes les conditions » : y < 0 restant toujours vrai.
S2 est adéquate pour le critère « toutes les conditions », mais pas pour « toutes les
décisions » : l’un de x < 0 ou y < 0 étant toujours évalué à vrai, seule la première
branche de la décision est prise.
Ceci prouve que les critères « toutes les décisions » et « toutes les conditions » sont
incomparables . Il est donc pertinent de mesurer en parallèle les taux de couverture
correspondants, et possible de les combiner dans un taux de couverture global par la
formule :
34
T = (|Dc | + |Cc |)/ (|De | − |Di |) + (|Ce | − |Ci |)
Le niveau B de la norme DO178-B exige que l’on fournisse une suite de tests adéquate
à la fois pour le critère « toutes les décision » (par conséquent couvrant également tous
les blocs, comme l’exige le niveau A de la norme) et le critère « toutes les conditions »,
avec en outre Di = Ci = ∅ (pas de décision ni de condition infaisable).
Opérateurs « court-circuit »
L’opérateur | utilisé dans l’exemple précédent est supposé avoir la sémantique d’un OU
logique, alors qu’en langage C il correspond en fait à un OU bit à bit. Les conversions
implicites des conditions booléennes vers des entiers (0 pour une condition fausse, 1
pour une condition vraie) impliquent leur équivalence sémantique, au moins dans le
cadre des exemples présentés.
En toute rigueur, les opérateurs booléens du langage C sont && pour la conjonction,
et || pour la disjonction, et non pas &, |. Ces opérateurs agissent avec une sémantique
de court-circuit : (a && b) n’évalue b que si a est vrai, alors que (a || b) n’évalue b que
si a est faux.
Les exemples présentés dans ce cours le sont sur la base d’opérateurs « sans courtcircuit », mais peuvent se transposer aux opérateurs && et || à condition de noter qu’il
est parfois nécessaire, en particulier pour la couverture des condition, de fournir des
suites de tests différentes (généralement plus riches, puisque potentiellement moins de
conditions sont évaluées).
Par exemple, sur la base d’une condition composée (x > 0) & (y > 0), la suite de
tests S = {(x, y) = (3, 4); (x, y) = (−3, −4)} est adéquate pour le critère « toutes les
conditions ». Mais S n’est pas adéquate pour ce critère lorsque la condition composée
devient (x > 0) && (y > 0), la condition y > 0 n’étant alors jamais évalué à faux.
Couverture des conditions multiples
Le critère « toutes les conditions multiples » impose de couvrir pour chaque condition
l’ensemble des combinaisons de valeur de vérité des conditions simples la composant. Pour une condition composée de k conditions simples, il faudra donc couvrir
l’ensemble des 2k combinaisons de valuations booléennes (des conditions simples)
possibles. Par exemple, pour la condition (x > 0) & (y > 0), il faut couvrir les 4
combinaisons possibles : (x > 0) & (y > 0), (x ≤ 0) & (y > 0), (x > 0) & (y ≤ 0),
et (x ≤ 0) & (y ≤ 0).
Si l’on considère un programme contenant n conditions composées, la condition i étant
composée de ki conditions
Pn simples, et si l’on note Ce l’ensemble des combinaisons
possibles, on a |Ce | = i=1 2ki . On note Cc l’ensemble des combinaisons couvertes
par la suite de tests, et Ci l’ensemble des combinaisons infaisables. Les combinaisons
infaisables sont liées au fait que les conditions simples ne sont en général pas indépendantes : par exemple, savoir que (x = y) limite le nombre de combinaisons faisables
pour la condition composée (x > 0) & (y < 0).
Le taux de couverture T pour le critère « toutes les conditions multiples » est donné
par :
35
Sélection des Tests
T = |Cc |/(
Pn
i=1
2ki − |Ci |)
Plus encore que pour le caractère infaisable d’une décision ou d’une condition simple,
déterminer Ci est un problème difficile qui nécessite en principe une analyse statique
très précise du programme sous test. Sur-approximer Ci fait courir le risque de classer
comme adéquate des suites de test ne couvrant en réalité pas toutes les combinaisons
effectivement faisables. Sous-approximer Ci (par exemple Ci = ∅, ce qui a l’avantage
de n’imposer aucune analyse externe de la part de l’utilisateur) rend potentiellement la
détermination d’une suite de tests adéquate impossible. Il est donc clair que pour un
programme complexe, et quelque soit le critère de couverture utilisé, obtenir une suite
de tests adéquate (T = 1) nécessite d’être capable de prouver l’infaisabilité de certains
éléments du critère par des techniques annexes au test.
3.2.4
Couverture MC/DC
Motivation
Le critère « toutes les conditions multiples » présente l’inconvénient majeur d’obliger,
pour chaque condition composée, à couvrir (ou à prouver infaisable) un nombre exponentiel d’éléments par rapport au nombre de conditions simples la composant (2n
combinaisons pour n conditions simples). Il y a donc là une explosion combinatoire
manifeste par rapport aux critères « toutes les décisions » ou « toutes les conditions »,
ce qui rend ce critère souvent inutilisable en pratique. L’idée est donc de définir un critère de couverture appelé MC/DC (pour Modified Condition/Decision Coverage), qui
aille au-delà de la couverture des décisions et des conditions simples, mais sans imposer le test d’un nombre exponentiel de combinaisons pour des conditions composées.
Définition
Pour définir le critère MC/DC, il est commode de le scinder en deux parties : la partie
DC et la partie MC. La partie DC indique que le critère exige la couverture des décisions. La partie MC est la spécificité du critère et exige de démontrer par le test que
chaque condition simple C de chaque condition composée CC influence de manière
indépendante l’évaluation de CC. Cette notion d’effet indépendant d’une condition
simple s’exprime formellement de la manière suivante : il existe une valuation V des
valeurs de vérité des autres conditions simples constituant la condition composée CC
pour laquelle le fait de rendre C vrai puis faux modifie la valeur de vérité de CC.
Illustration
Pour illustrer la partie MC du critère, considérons la condition composée
CC = C0 ∧ (C1 ∨ C)
Démontrer la partie MC du critère MC/DC pour la condition simple C se fait en choisissant la valuation V (C0 ) = true, V (C1 ) = f alse. En effet, C = true implique alors
CC = true, alors que C = f alse implique CC = f alse. On peut en outre remarquer que V est l’unique valuation permettant de prouver MC pour C relativement
36
à CC. Prouver MC pour la condition C1 se fait en utilisant la valuation symétrique
W (C0 ) = true, W (C) = f alse. Quant à la preuve de MC pour la condition C0 , on
peut choisir n’importe quelle valuation Z satisfaisant Z(C1 ) = true ou Z(C) = true.
La difficulté est bien entendu de trouver des cas de test permettant d’aboutir aux valuations faisant la preuve du critère MC, certaines valuations pouvant en outre s’avérer
infaisables.
Exemples
Le choix des valuations pour le critère MC peut s’effectuer sur la base des tables de
vérité des conditions composées correspondantes, comme le montrent les exemples
qui suivent.
Pour la condition CC = C1 ∧ C2 ∧ C3 :
id test
t1
t2
t3
t4
C1
true
true
true
false
C2
true
true
false
true
C3
true
false
true
truee
C
true
false
false
false
MC démontré pour
par
C3
C2
C1
t1 , t2
t1 , t3
t1 , t4
MC démontré pour
par
C3
C2
C1
t1 , t2
t2 , t3
t3 , t4
Pour la condition CC = (C1 ∧ C2 ) ∨ C3 :
id test
t1
t2
t3
t4
C1
true
true
true
false
C2
false
false
true
true
C3
true
false
false
false
C
true
false
true
false
Infaisabilité
Le fait qu’il existe, pour un programme P , une suite de tests adéquate pour le critère
MC/DC implique que P ne contient pas de conditionnelles dont la construction est
maladroite. A titre d’illustration, prouver MC pour la condition composée suivante est
impossible :
(x > 0) ∧ (x > −1) ∨ (y > 0)
En effet, pour CC = C0 ∧ (C1 ∨ C2 ), la table de vérité est comme suit :
id test
*
t1
t2
t3
t4
C0
false
true
true
true
true
C1
any
true
true
false
false
C2
any
true
false
true
false
C
false
true
true
true
false
Il est facile de vérifier que l’unique couple de tests à même de prouver MC pour C1
est (t2 , t4 ). Or le test t4 n’est faisable que si C1 peut être rendu faux même lorsque
37
Sélection des Tests
C0 est vrai. Ce qui est impossible lorsque C0 =⇒ C1 , comme c’est le cas pour
C0 = (x > 0) et C1 = (x > −1). Dans ce cas, le programme P gagnerait à être réécrit, en remplaçant la condition composée incriminée par la condition équivalente
(x > 0) : équivalence logique, et non du point de vue de MC/DC.
Ce cas se base sur un programme P comportant une conditionnelle syntaxiquement
maladroite car trop complexe pour ce qu’elle est censée réaliser. Ce type de constructions, qui sont des maladresses de programmation, peut tout à fait être un indicateur
de défauts plus graves. Le critère MC/DC permet de capturer ce type de « défauts »,
ce que ne permettent pas les critères plus faibles « toutes les décisions » et « toutes les
conditions ». Evidemment, la non-satisfaction du critère MC peut également avoir des
origines plus subtiles (sémantiques) que ce qui a été illustré. Penser par exemple à une
condition composée (x > 0) ∧ (y > 0) qui s’évalue toujours dans des contextes
d’exécution où (x > y) : le critère MC ne pourra pas être démontré pour la condition
(x > 0), puisqu’on on aura soit (y > 0) = true, et dans ce cas (x > 0) = true, ce qui
rend la condition composée vraie, soit (y > 0) = f alse, et le condition composée sera
fausse indépendamment de la condition (x > 0).
Enfin, certaines conditions composées peuvent être écrites de telle façon qu’il n’est pas
possible de prouver MC pour chaque condition simple, sans pour autant qu’elle puisse
être qualifiées de « maladroites ». Par exemple, dans la condition
(A ∧ B) ∨ (A ∧ C)
on ne peut évidemment pas faire varier la valeur de la première occurrence de A sans
modifier la valeur de la seconde occurrence, ce qui empêche de prouver MC pour aucune d’entre elles. Une possibilité est alors de réécrire la condition en la condition logiquement équivalent A ∧ (B ∨ C), qui ne présente pas le même défaut de structure
et pour laquelle on pourra éventuellement prouver MC.
Mesure du taux de couverture MC/DC
Pour le critère MC/DC, le taux de couverture se décline en général en quatre mesures :
taux de couverture des blocs, des conditions simples, des décisions, qui ont été vus, et
taux de couverture pour la partie MC.
Le taux de couverture pour la partie MC est calculé à partir des éléments suivants :
– C1 , C2 , . . . , Cn les conditions du programme (simples ou composées), apparaissant
ou non dans les décisions. Une condition n’apparaît pas si elle fait partie d’une condition composée.
– ci ≥ 1 : le nombre de conditions simples dans Ci ,
– ei : le nombre de conditions simples démontrées comme satisfaisant à MC par la
suite de tests,
– fi : le nombre de conditions simples dont on a montré par ailleurs qu’elles étaient
infaisables au sens de MC.
On a alors :
T =
Pn
i=1 ei /
Pn
i=1 (ci
− fi )
Une suite de tests est adéquate pour le critère MC/DC si tous les taux de couverture
(blocs, conditions simples, décisions, MC) valent 1.
38
DO-178B niveau A
Le niveau A est le niveau le plus exigeant de la norme DO-178B : il concerne les
codes critiques, et impose la fourniture d’une suite de tests adéquate pour le critère de
couverture MC/DC. La suite de tests doit en outre avoir été sélectionnée à partir des
spécifications (sélection en boîte noire), et non par des techniques « boîte blanche »
comme la génération automatique de tests. L’idée est d’exercer le code par des tests
fonctionnels couvrant ses exigences, et de vérifier via le taux de couverture MC/DC
que le code le contient pas de fonctionnalités cachées ni de conditions complexes non
exercées lors des tests.
3.2.5
Couverture de boucles, de chemins
Boucles
Les critères vus jusqu’à présent (MC/DC compris) imposent finalement peu de contraintes
sur le test des boucles : on ne demande finalement guère plus que de couvrir le critère
pour le corps de boucle, et d’exercer la condition de sortie de la boucle.
A titre d’exemple, soit le programme suivant :
int f ( int elt , int tab [3]) {
int i ;
i n t o l d = −1;
f o r ( i = 0 ; i < 3 ; ++ i ) {
i f ( tab [ i ] > e l t ) return old ;
old = tab [ i ] ;
}
return old ;
}
F IG . 3.4 – Couverture des boucles : exemple
On suppose que la précondition de f exige que tab soit trié et composé d’éléments
positifs ou nuls. La spécification de la fonction est de retourner le plus grand élément
de tab qui soit inférieur ou égal à elt, s’il existe, et de retourner −1 si un tel élément
n’existe pas.
Pour la fonction f, une suite de tests couvrant toutes les décisions du programme devra
juste inclure :
– un cas de test pour lequel i atteint la valeur 3, ce qui exerce la condition de sortie de
la boucle. Cela arrive exactement lorsque la condition tab[i] > elt dans le corps de
boucle s’évalue à faux pour 0 ≤ i < 3, par exemple en choisissant
(elt, tab) = (1, {0, 0, 0})
– un cas de test pour lequel la condition tab[i] > elt dans le corps de boucle s’évalue
à vrai, pour une valeur de 0 ≤ i < 3 quelconque. On peut choisir
(elt, tab) = (−1, {0, 0, 0})
qui retourne de la fonction dès la première itération.
39
Sélection des Tests
Il suffit donc de deux cas de test pour couvrir toutes les décisions du programme : l’un
qui exécute le nombre maximal d’itérations, l’autre le nombre minimal (retour lors du
premier tour de boucle). Ceci correspond au cas où tab ne contient pas d’élément
supérieur à elt, et au cas où tab ne contient que des éléments supérieurs à elt. La
couverture est donc relativement faible au niveau fonctionnel, et il serait intéressant
d’exercer un critère de couverture plus ambitieux que « toutes les décisions » sur cet
exemple.
Ainsi, pour obtenir une couverture des boucles plus exigeante, il est intéressant de
différencier les comportements suivants :
– le corps de boucle n’est jamais exercé (impossible sur le programme exemple),
– le corps de boucle est exercé une seule fois,
– le corps de boucle est exercé un nombre « typique » de fois (non représenté sur le
programme exemple),
– le corps de boucle est exercé un grand nombre de fois,
– le corps de boucle est exercé un nombre maximal de fois.
Bien entendu, il est possible de construire des boucles qui rendent certains comportements infaisables (cf. boucles s’exerçant un nombre fixé de fois). On ne cherche pas à
fournir ici une définition générale pour le taux de couverture associé à un tel critère.
Sur l’exemple traité, exercer la boucle un nombre « typique » de fois revient à tester un
cas où le corps de boucle est exercé plusieurs fois avant de retourner une valeur, sans
pour autant atteindre le nombre maximal d’itérations. Ceci revient fonctionnellement à
trouver un cas de test pour lequel tab contient à la fois au moins un élément inférieur
ou égal à elt, et un élément strictement supérieur à elt. Ceci illustre le fait qu’exiger un critère de couverture structurelle plus fort au niveau du traitement des boucles
permet d’enrichir valablement la suite de tests d’un point de vue fonctionnel.
Pour traiter des boucles imbriquées, une possibilité est de chercher à couvrir la boucle
la plus extérieure sur les critères décrits dans le paragraphe précédent. Pour chaque
nombre (on pourrait dire « classe ») d’itérations de la boucle externe, on cherche alors
à faire varier le nombre d’itérations des boucles internes selon les mêmes critères.
Les dépendances potentielles entre les nombres d’itérations possibles des différentes
boucles peuvent évidemment rendre certaines configurations infaisables.
Chemins
La couverture de l’ensemble des chemins structurels d’un programme est le critère le
plus fort qui soit lorsqu’on se réfère au contrôle : chaque chemin (faisable) du graphe
de contrôle doit être couvert par un test.
Certains programmes comportant un nombre infini de chemins (boucles infinies), ce
critère peut s’avérer irréalisable, les suites de test devant rester finies. Même en bornant
le nombre de chemins, par exemple en limitant le nombre d’itérations des boucles, il
reste souvent intractable, le nombre de chemins potentiels à considérer pouvant être
exponentiel avec le nombre de décisions du programme.
Il s’agit donc d’un critère que l’on retient surtout parce que les techniques de génération
automatique de tests qui seront vues au chapitre 4 se basent sur une énumération d’un
sous-ensemble des chemins structurels d’un programme, même lorsqu’elles visent un
critère de couverture plus faible, comme toutes les branches.
40
3.2.6
Couverture du flot de données
Graphe des dépendances de données
Le graphe des dépendances de données (Data Flow Graph ou DFG) d’un programme
décrit les dépendances entre les variables d’un programme : la présence de pointeurs
et de références complique notoirement le calcul des dépendances de données, on n’en
parlera pas dans ce cours.
Les nœuds du DFG sont les instructions du programme, sans regroupement en blocs
comme pour le CFG. On suppose que le DFG est associé à un CFG ayant les mêmes
nœuds (pas de blocs), ce qui permet de décrire les chemins de contrôle et en particulier
les chemins de c-utilisation et p-utilisation introduits ci-dessous.
Un nœud du DFG définit une variable var s’il est de la forme var ← expr ou input(var)
(on suppose qu’il s’agit là des seules instructions à même de modifier var).
Un nœud du CFG c-utilise une variable var s’il correspond à une instruction output(var),
ou var0 ← expr où expr contient var. On parle aussi de c-utilisation pour « utilisation
dans un calcul ».
Un nœud du CFG p-utilise une variable var s’il correspond à une instruction de type
décision dont la condition se réfère à var (comme if(var > 0) pour fixer les idées).
On parle aussi de p-utilisation pour « utilisation dans un prédicat ».
Il existe un arc de c-utilisation (respectivement p-utilisation) entre un nœud origine n1
et un nœud destination n2 du DFG si il existe une variable var telle que :
– n1 définit var,
– n2 c-utilise var (resp. p-utilise var),
– il existe un chemin de contrôle allant de n1 à n2 tel qu’aucun des nœuds du chemin
hors n1 (et potentiellement n2 ) ne redéfinisse var. Pour une c-utilisation, un tel
chemin de contrôle est appelé chemin de c-utilisation. Pour une p-utilisation, un tel
chemin de contrôle se prolonge en deux chemins de p-utilisation, un pour chacune
des deux branches associées à la décision.
La figure 3.5 contient le DFG du programme suivant :
input (x , y );
i f ( y > 0)
x = 1;
/∗ else { } ∗/
output (x ) ;
Le DFG ne comporte dans ce cas que deux chemins, qui sont
π1 = (N1 , N2 , N3 , N4 )
π2 = (N1 , N2 , N4 )
Le seul chemin de p-utilisation pour la branche true (resp. false) de l’arc de putilisation puse(N1 , N2 ) est π1 (resp. π2 ).
Le seul chemin de c-utilisation pour l’arc de c-utilisation cuse(N1 , N4 ) (resp. pour
41
Sélection des Tests
F IG . 3.5 – Arc plein = contrôle, Arc pointillé = def-use
l’arc cuse(N3 , N4 )) est π2 (resp. π1 ).
Couverture du DFG
La couverture des c-utilisations exige de couvrir, pour chaque arc de c-utilisation, au
moins un des chemins de c-utilisation associé. La couverture des p-utilisations exige
de couvrir, pour chaque arc de p-utilisation, au moins un chemin de p-utilisation pour
chacune des deux branches associées à la décision. Chaque branche peut être sollicitée
via un chemin de p-utilisation différent si nécessaire, mais les deux branches doivent
être couvertes afin que la p-utilisation soit considérée couverte.
La couverture de « toutes les utilisations » combine les deux couvertures précédentes,
qui sont incomparables. La couverture des p-utilisations est plus forte que la couverture
des décisions, et la couverture des c-utilisations est plus forte que la couverture des
blocs (sauf cas pathologiques, cf. instructions skip). Une c-utilisation est infaisable
si l’ensemble des chemins de c-utilisations sont infaisables, et une p-utilisation est
infaisable si, pour au moins l’une des décisions associée, l’ensemble des chemins de
p-utilisations sont infaisables.
Les taux de couverture se calculent alors classiquement :
– CUe (resp. P Ue ) : ensemble des c-utilisations (resp. p-utilisations) du programme,
– CUc (resp. P Uc ) : ensemble des c-utilisations (resp. p-utilisations) couvertes par la
suite de tests,
42
– CUi (resp. P Ui ) : ensemble des c-utilisations (resp. p-utilisations) démontrées infaisables.
TCU se = |CUc |/(|CUe | − |CUi |)
TP U se = |P Uc |/(|P Ue | − |P Ui |)
TU se = (|CUc | + |P Uc |)/ (|CUe | + |P Ue |) − (|CUi | + |P Ui |)
3.2.7
Couverture des mutants
Pour les gens intéressés, voir le chapitre 7 de [2] qui présente la mutation de programmes comme une technique permettant d’estimer la qualité d’une suite de tests.
3.3
Conclusion
L’objectif étant d’obtenir la meilleure suite de tests possibles, il est recommandé d’utiliser une combinaison des méthodes de sélection des tests pour arriver à ses fins, suivant
leur pertinence en fonction de la nature du programme sous test.
Si l’on cherche à générer des tests dans l’objectif d’assurer un bon niveau de couverture, on recommande les pratiques suivantes :
– ne pas commencer sa campagne de test par du test structurel ;
– effectuer du test aléatoire, jusqu’à obtenir un taux de couverture raisonnable en fonction du problème ;
– compléter avec des tests fonctionnels, évaluer la couverture structurelle atteinte ;
– compléter par du test structurel sur les parties non couvertes.
Dans une optique de recherche de défauts, on préconise les étapes suivantes :
– effectuer du test aléatoire pour du débogage grossier, pour mettre à jour un maximum
de défauts ;
– sélectionner des tests assurant un maximum de couverture fonctionnelle, et effectuer
du test aux limites ;
– pour du débogage fin : sélectionner les tests sur la base d’un critère structurel abordable, sur les parties du programme non encore couvertes.
43
44
Chapitre 4
Génération Automatique de
Tests
Ce chapitre traite des techniques permettant de générer automatiquement des cas de
test, c’est à dire d’écrire des programmes qui prennent en entrée un programme et un
objectif de test, et fournissent en sortie un ensemble de cas de tests couvrant tout ou
partie de l’objectif de test. Il faut bien différencier d’une part la génération automatique
de tests et d’autre part l’automatisation du processus de test, qui consiste à automatiser
l’exécution et l’évaluation des cas de test (vérification des oracles, mesure de couverture) et non à faciliter leur sélection.
4.1
4.1.1
Panorama des techniques de génération
Génération automatique aléatoire
La technique la plus simple de génération de tests est de sélectionner de manière aléatoire des valeurs dans le domaine des entrées. L’avantage de cette technique est la
rapidité de la génération des valeurs et la simplicité de l’implantation (au moins pour
un tirage uniforme, et pour des domaines d’entrée simples). Cette technique se focalise
principalement sur l’identification du domaine des entrées du programme sous tests.
Pour un programme sous test ayant un précondition complexe, induisant des contraintes
relationnelles fortes entre les entrées, un tirage aléatoire naïf aura toutes les chances de
ne pas aboutir à des entrées valides. Dans les cas les moins contraints, une possibilité
est de simplement rejeter les tirages invalides, la condition étant que la probabilité
d’obtenir des entrées valides reste tout de même relativement élevée. Il est en effet plus
facile de vérifier que des entrées satisfont à une précondition que de les générer. Dans
d’autres cas, il est souvent nécessaire de diriger la génération aléatoire de manière
programmatique, en prenant en compte certaines relations entre les entrées. Le cas
extrême est d’utiliser un solveur de contraintes pour obtenir des solutions à la formule
représentée par la précondition, mais on ne peut plus alors parler de technique purement
aléatoire mais plutôt de technique « boîte noire »(voir section 4.1.2).
La génération aléatoire sur la base des domaines d’entrée ne permet évidemment pas
45
Génération Automatique de Tests
d’exploiter un objectif de test structurel. De manière implicite, la génération aléatoire,
pour peu qu’elle prenne en compte la précondition, vise davantage un objectif fonctionnel. Pour un objectif de détection de défauts, il est naturel de compléter la génération
aléatoire en insistant sur les valeurs aux limites, ce qui est à nouveau plus ou moins
facile à automatiser en fonction de la complexité du domaine des entrées.
Exemple 1
A titre d’illustration, supposons que l’on cherche à générer des tests de manière aléatoire pour une fonction de fusion de deux tableaux d’entiers positifs triés T1 et T2
(comme utilisée par le tri-fusion). Les tailles des tableaux s1 et s2 sont des entiers
strictement positifs : le choix des valeurs de s1 , s2 peut se faire de manière uniforme et
indépendante dans l’intervalle [1, N] où N est une constante entière choisie de façon à
pouvoir générer des tableaux comportant un grand nombre d’éléments. Il n’y a pas de
relation entre s1 et s2 , il est même déconseillé d’imposer s1 ≥ s2 .
Une fois s1 et s2 choisis pour un cas de test donné, les tableaux sont alloués aux tailles
requises : il s’agit ici de rejeter les cas pour lesquels une des allocations échouerait
(mémoire insuffisante). Si cette étape n’échoue pas, les éléments des tableaux doivent
être tous initialisés de façon à ce que les tableaux soient triés. Une manière de procéder
est de générer le premier élément égal à v1 par un tirage uniforme dans [0, MAXentier ]
(MAXentier étant la borne supérieure du domaine pour le type entier), le second
élément dans [v1 , MAXentier ] et ainsi de suite pour chaque tableau de manière indépendante.
Pour couvrir des cas davantage aux limites, une possibilité est ici de générer en supplément des cas de test pour lesquels s1 = 1 et / ou s2 = 1, s1 = N et / ou s2 = N.
D’autres situations « limites », comme par exemple « tous les éléments de T1 sont plus
grands que tous les éléments de T2 » demandent un plus gros effort de programmation
de la génération aléatoire.
Exemple 2
Considérons le programme suivant :
typedef struct c e l l {
int v ;
struct c e l l ∗ next ;
} cell ;
i n t t e s t m e ( c e l l ∗p , i n t x ) {
i f ( x > 0)
i f ( p ! = NULL)
i f ( ( 2 ∗ x + 1 ) == p−>v )
i f ( p−> n e x t == p )
return 1;
return 0;
}
La fonction testme a une précondition implicite, qui est que soit p vaut NULL, soit
p est valide, c’est à dire qu’il pointe vers une structure de type cell correctement
allouée (ayant un champ next valide ou égal à NULL). Les données de test à générer
devront donc satisfaire à cette précondition, ce qui exige d’être capable d’allouer des
données valides de type cell. La procédure de génération de tests consistant à allouer
46
une zone mémoire ayant la même taille que cell et comportant des valeurs aléatoires
ne convient pas : penser au cas où le champ next serait non NULL mais pointerait vers
une zone mémoire hors espace d’adressage autorisé.
4.1.2
Génération automatique en boîte noire
On parle de génération de tests en boîte noire lorsqu’on génère les tests à partir de
la spécification du programme, l’objectif étant de couvrir un maximum des comportements fonctionnels du programme sous test (on parle aussi de génération de tests
fonctionnels). L’automatisation de cette technique nécessite de disposer de spécifications formelles, c’est à dire compréhensibles par le programme en charge de générer
les tests. Il peut s’agir par exemple de spécifications sous forme de prédicats, de modèles à base d’automates finis (on parle de model-based testing) ou de diagrammes
causes-effet. Les techniques manuelles (souvent « structurelles ») évoquées au paragraphe 3.1.5 pour chaque type de spécification peuvent être systématisées et automatisées. On ne donnera pas plus de détails dans ce cours, les personnes intéressées peuvent
consulter les chapitres 2 et 3 du livre Foundations of Software Testing [2].
4.1.3
Génération automatique en boîte blanche
Lorsque la sélection des tests se fait à partir de l’implantation (sources du programme),
on parle de génération en boîte blanche ou de génération de tests structurels. Cette technique de génération se prête naturellement à l’automatisation, puisque le programme
est un objet formel destiné à être compilé (ou interprété) et exécuté : cela par contraste
avec les spécifications formelles, qui peuvent très bien ne pas être exécutables. De
fait, les techniques de génération en boîte blanche procèdent par exécution symbolique
du programme sous test, généralement chemin par chemin, avec un objectif structurel
global de couverture des instructions, branches, ou de tout autre critère vu à la section 3.2. La principale difficulté de la génération en boîte blanche est la problématique
du passage à l’échelle : même un programme de quelques dizaines de lignes peut comporter un nombre de chemins considérable (typiquement exponentiel en le nombre de
décisions successives traversées), qu’il faudra potentiellement tous exécuter symboliquement et résoudre pour générer les tests. D’où l’intérêt de guider la génération en
fonction de l’objectif de couverture, afin de réduire la complexité. Ce type de génération est rarement utilisé pour un objectif de test fonctionnel, la problématique de
l’oracle se posant alors très rapidement, mais a un intérêt considérable pour un objectif
de détection de défauts.
4.2
Techniques de génération automatique de tests structurels
Dans ce qui suit, on suppose que le langage de programmation servant à écrire le code
source est un langage jouet sans pointeurs ni références, sans appels de fonctions ni
exceptions, et déterministe (un cas de test correspond à un seul chemin du programme).
Les pointeurs et références augmentent considérablement la complexité en terme de
modélisation de la mémoire (validité des pointeurs, présence d’alias), et les exceptions
introduisent des ruptures du flot de contrôle nécessitant une machinerie annexe pour
47
Génération Automatique de Tests
être gérées correctement. On peut utiliser des techniques d’inlining simples pour gérer
les appels de fonctions, ce qui ne pose en général qu’un problème éventuel de passage à
l’échelle. Les techniques présentées se basent sur le graphe de contrôle du programme.
On se place dans le cadre de la logique du premier ordre (ou « calcul des prédicats »)
qui permet de donner une sémantique à un programme ou à un chemin du programme.
4.2.1
Prédicat de chemin
Soit π un chemin du CFG associé au programme, chemin qui est soit complet (allant du
nœud initial jusqu’à un nœud final), soit préfixe d’un chemin complet. On suppose par
ailleurs avoir identifié l’ensemble des entrées du programme sous forme d’un vecteur
V de variables. Une donnée de test correspond donc à une valuation de V .
Le prédicat du chemin π est une formule logique ϕπ sur les variables de V telle que si
une valuation V0 de V satisfait ϕπ , alors l’exécution du programme sur la donnée de
test associée à V0 suit le chemin π.
Noter qu’on parle du prédicat d’un chemin π comme d’un objet unique, bien qu’il
s’agisse en fait d’une classe d’équivalence sur un ensemble de formules. La définition
repose sur l’hypothèse que le programme est déterministe.
La valuation V0 des variables de V permet de définir complètement le cas de test, et
une grande partie de l’enjeu est de parvenir à déterminer si une telle valuation existe
(faisabilité ou non du chemin) et quelles sont ses valeurs pour un prédicat de chemin
(et donc une formule logique) donné.
Le chemin π permet de disposer d’un oracle minimal : si un autre chemin que π est
exécuté sur la base de la solution à ϕπ trouvée, alors il y a un problème soit au niveau
de la génération du prédicat, soit de sa résolution. Si ϕπ n’a pas de solution, le chemin
π est démontré infaisable.
Exemple Soit le programme suivant, dont les entrées sont les variables y et z :
1
2
3
4
5
6
7
8
input (y , z );
y ++;
x = y + 3;
if (x < 2 ∗ z)
if (x < z)
return 0;
e l s e return 1;
e l s e r e t u r n −1;
Supposons pour fixer les idées que l’appel à input(x,y) initialise y à la valeur
Y0 = 5 et z à Z0 = 6. L’exécution se poursuit par l’incrémentation de y : cette opération modifie la valeur de y qui devient égale à Y1 = Y0 + 1 = 6. L’instruction
suivante incrémente x qui prend la valeur X0 = Y1 + 3 = 9. On remarque que chaque
modification d’une variable v introduit une nouvelle variable logique, qui est soit V0
lorsque v n’étant encore associé à aucune valeur, soit Vi+1 lorsque v était déjà associée à la variable Vi . L’instruction qui suit teste la condition x < 2 ∗ z, ce qui revient
à évaluer X0 < 2 ∗ Z0 ou encore 9 < 2 ∗ 6, qui vaut true. L’exécution exacte ou
« concrète » que l’on suit ici définit toujours un unique chemin d’exécution, le programme étant déterministe. Le test suivant revient à évaluer X0 < Z0 , soit 9 < 6
48
ce qui vaut false. Le programme s’achève sur l’instruction return 1, après avoir
suivi le chemin π = 1 − 2 − 3 − 4true − 5false − 7.
Pour calculer le prédicat associé à π, on se base sur une exécution symbolique de π qui
permet de construire le prédicat de manière itérative. L’instruction input(x,y) créé
deux variables logiques Y0 , Z0 représentant les valeurs initiales de y et z, sans qu’il
soit besoin de les préciser. Le prédicat de chemin associé ϕ1 est « vide » et formellement équivalent à true. L’instruction suivante introduit une nouvelle variable logique
Y1 représentant la nouvelle valeur courante de y, et le prédicat ϕ1−2 est construit en
associant cette nouvelle valeur à la précédente par la formule Y1 = Y0 + 1 (ce qui
correspond à la sémantique de l’instruction exécutée) :
ϕ1−2 ≡ (Y1 = Y0 + 1)
L’instruction suivante créé la variable logique X0 , le prédicat du chemin 1 − 2 − 3
s’obtient en ajoutant la formule X0 = Y0 + 3 au prédicat du préfixe déjà parcouru :
ϕ1−2−3 ≡ (Y1 = Y0 + 1) ∧ (X0 = Y1 + 3)
L’instruction suivante étant un test s’évaluant à true sur le chemin π suivi, la condition
associée doit être vraie et le prédicat devient
ϕ1−2−3−4true ≡ ϕ1−2−3 ∧ (X0 < 2 ∗ Z0 )
L’instruction qui suit est à nouveau un test, mais qui s’évalue cette fois à false : le
prédicat obtenu est
ϕ1−2−3−4true −5false
≡ ϕ1−2−3−4true ∧ ¬(X0 < Z0 )
≡ ϕ1−2−3−4true ∧ (X0 ≥ Z0 )
≡ ϕπ
La dernière équivalence provient du fait que la dernière instruction du chemin π (instruction 7) ne modifie aucune des variables et ne joue aucun rôle du point de vue
de la génération du prédicat, au moins dans un contexte de test unitaire où la valeur
retournée n’est pas utilisée. On vérifie que ϕπ est en outre équivalente à la formule
Z0 ≤ Y0 + 4 < 2 ∗ Z0 (projection de la formule sur les entrées) et que la valuation
Y0 = 5, Z0 = 6 satisfait bien la formule.
La table suivante résume l’exemple qu’on vient de traiter. L’évolution de la mémoire
lors de l’exécution est modélisée par le fait d’associer à chaque variable du programme
déjà initialisée la variable logique qui lui correspond. La simplicité de ce modèle de la
mémoire est liée au fait que l’on ne traite pas ici la notion de pointeur ou de référence.
Ligne
1
2
3
4true
5false
7
Instruction
Exécution concrète
Exécution symbolique
input(y,z)
y++
x=y+3
if (x < 2 * z)
if (x < z)
return 1
Y0 =5, Z0 =6
Y1 =6
X0 =9
condition vraie
condition fausse
Y1 = Y0 + 1
X0 = Y1 + 3
(X0 < 2 ∗ Z0 )
(X0 ≥ Z0 )
Association des variables
y → Y0 , z → Z 0
y → Y1
x → X0
49
Génération Automatique de Tests
4.2.2
Génération par exécution symbolique des chemins
Processus de génération
La résolution d’un prédicat de chemin permet de générer les données de test permettant d’activer ce chemin. Il suffit en principe d’énumérer tous les chemins du graphe de
contrôle, de générer les prédicats associés et de les résoudre s’ils sont faisables pour obtenir une suite de tests adéquate pour le critère « couverture de tous les chemins »(avec
comme bonus des démonstrations d’infaisabilité obtenues de manière automatique). Le
processus général de génération de tests est le suivant :
1
2
3
4
Sélectionner un chemin π du CFG non encore traité ;
Calculer le prédicat du chemin ϕπ par exécution symbolique ;
Trouver une solution à ϕπ ou démontrer qu’il n’en existe pas ;
Si l’objectif de test (e.g. couverture des chemins, ou instructions, ou branches) n’est
pas atteint, retourner en 1.
Les paragraphes qui suivent traitent d’aspects important de la mise en œuvre de ce
processus : la technique d’énumération des chemins, le choix de la théorie dans laquelle
les prédicats de chemin sont résolus, et l’automatisation du processus.
Enumération des chemins
Un élément clé de la mise en œuvre du processus de génération par exécution symbolique est la technique d’énumération des chemins.
Comme vu précédemment, le critère de couverture « tous les chemins » est le plus puissant critère de couverture structurelle. Par conséquent il s’avère souvent intractable sur
des programmes non triviaux, du fait du nombre potentiellement énorme de chemins
du CFG.
Une première possibilité est de ne sélectionner que des chemins ayant certaines caractéristiques : par exemple les chemins dont la taille est bornée par une constante (à
déterminer de manière ad hoc), ou les chemins ne comportant pas plus de k itérations
(on parle de k-chemins). Ceci limite de manière artificielle le nombre de chemins à
considérer, mais s’avère néanmoins souvent indispensable en présence de boucles et
peut se justifier dans une optique de recherche de défauts.
L’ordre de sélection des chemins joue également un rôle important : un parcours en profondeur d’abord (Depth-first search ou DFS) présente un grand nombre d’avantages en
terme d’efficacité mémoire, mais d’autres parcours sont tout à fait envisageables. Le
parcours DFS s’organise de la manière suivante : un premier chemin est parcouru, avec
mémorisation au niveau de chaque décision de la branche prise et du contexte courant
(état mémoire et prédicat de chemin) dans une structure de pile. Chaque décision empilée correspond en fait à deux préfixes (un par branche), l’un correspondant au chemin
courant, l’autre à un chemin non encore couvert. Le prédicat du chemin complet est résolu, ce qui fournit soit un cas de test, soit une preuve d’infaisabilité. On effectue alors
un retour en arrière (ou backtrack) en dépilant la décision et le contexte se trouvant au
sommet de la pile et en considérant le préfixe non encore couvert par le chemin initial.
Ce préfixe, qui correspond à l’exécution de la branche alternative, est transformé en
prédicat et résolu. Si aucune solution n’existe, le backtrack se poursuit sur un préfixe
plus petit. Si une solution existe, le chemin correspondant est exploré complètement en
empilant les décisions et contextes qui suivent le préfixe déjà empilé : on procède alors
50
récursivement, en notant que la décision dépilée ne sera plus considérée puisqu’elle a
été complètement explorée. La procédure termine en explorant l’ensemble des chemins
faisables, un chemin et ses suffixes étant « coupé » dès lors qu’un de ses préfixes a été
démontré infaisable.
Même si le principe général repose sur une énumération de chemins du CFG, le critère d’arrêt de la génération d’une part (étape 4) et la sélection du chemin d’autre part
(étape 1) bénéficient de l’utilisation de critères plus légers, comme la couverture des
branches ou des instructions. Par exemple pour la couverture de branches, il ne sert à
rien de poursuivre la génération par énumération de chemins si toutes les branches faisables sont couvertes (relaxation du critère d’arrêt), et il n’est pas judicieux de choisir
un chemin pour lequel il n’y a aucun espoir d’améliorer la couverture des branches,
cette vérification n’étant pas triviale en général.
Choix de la théorie
Les prédicats de chemins sont exprimés dans un fragment de la logique du premier
ordre qui permet d’exprimer des formules sous la forme de quantification existentielle
(« il existe des valuations des variables d’entrées . . . ») sur des conjonctions de prédicats élémentaires (un prédicat par instruction, et une conjonction sur l’ensemble des
prédicats associés aux instructions du chemin). Ce fragment restreint reste cependant
suffisamment expressif pour que les procédures de décision permettant de décider le
caractère satisfiable (la formule a t’elle une solution ou non ?) aient des complexités
élevées (N P -complet en l’occurrence).
Il peut être avantageux voire nécessaire d’interpréter ces formules dans une théorie
moins générale, en relâchant par exemple des contraintes (à l’extrême : en les retirant)
pour aboutir à une formule simplifiée (ayant davantage de solutions) pouvant être résolue plus facilement. A titre d’exemple, une formule arithmétique générale comportant
d’éventuelles multiplications entre variables peut être simplifiée en formule d’arithmétique linéaire (multiplication d’une variable par une constante uniquement) et être alors
résolue par un solveur de type « programmation linéaire ». La solution obtenue pourra
éventuellement ne pas être une solution de la formule originale, mais au moins cette
vérification s’effectue simplement.
Un autre point concerne l’interprétation des opérations : doit-on par exemple considérer que les opérations arithmétiques se font sur la base d’entiers idéaux (non bornés) ou
sur des entiers représentables en machine (les opérations s’effectuant alors modulo une
constante) ? La sémantique choisie peut s’éloigner de la sémantique effectivement implantée par la machine, mais permet d’utiliser un solveur potentiellement plus efficace.
Le tout est de s’assurer que les valuations obtenues sont effectivement des solutions
dans la sémantique d’origine, et correspondent donc à de « vrais » tests.
Automatisation du processus
L’idée du processus de génération de tests par exécution symbolique est relativement
ancienne [3], mais son automatisation notamment pour des programmes C est beaucoup plus récente [5, 6], pour des raisons évoquées ci-après.
D’une part, l’automatisation de l’exécution symbolique pose un certain nombre de problèmes. Il faut tout d’abord disposer d’un parseur efficace pour le langage en entrée,
51
Génération Automatique de Tests
permettant d’instrumenter le code et de générer des lanceurs, avec pour objectif de
parvenir à récupérer par ce biais les prédicats de chemin. Utiliser les formats internes
d’un compilateur comme gcc s’avère ardu, et en pratique les outils actuels reposent
tous sur le parseur CIL 1 qui date du début des années 2000. Outre le fait de disposer
d’un parseur, la présence de pointeurs et de transtypage dans le langage C compliquent
considérablement le modèle mémoire.
D’autre part, l’étape 3 du processus de génération nécessite pour être automatisée de
disposer d’un solveur automatique prenant en entrée une formule logique et répondant
selon le cas :
– la formule est insatisfaisable (pas de solution), ce qui correspond à un chemin infaisable si la formule correspond à un prédicat de chemin ;
– la formule est satisfaisable : dans ce cas, il est nécessaire que le solveur puisse en
outre fournir une solution explicite pour une utilisation dans un cadre de génération
de tests ;
– autres cas : le solveur échoue sur une erreur interne (typiquement explosion des
besoins en terme de mémoire), ou le générateur de tests s’impatiente (réponse trop
longue à venir). On ne peut alors ni décider si le chemin est faisable, ni s’il est
infaisable.
Des solveurs efficaces comme Simplify 2 pour des théories relativement générales ne
sont disponibles que depuis le début des années 2000. Il existe des solveurs très efficaces bien antérieurs à cela mais pour des théories moins puissantes, basés sur la
programmation linéaire 3 par exemple. L’exécution concolique (voir section 4.2.4) apporte également des pistes pour le cas où l’on souhaite se baser sur une théorie moins
générale que requis par le programme sous test.
4.2.3
Exemple de génération par exécution symbolique
Soit la fonction max3 dont la spécification est de prendre en argument trois entiers et
de retourner le plus grand d’entre eux. On se base sur l’implantation de max3 qui se
trouve à la figure 4.1. L’objectif de test est de couvrir tous les chemins de la fonction,
ce qui est raisonnable vu leur petit nombre.
On suppose que l’on effectue la génération de tests sur la base d’un parcours en profondeur (DFS) du CFG de max3, en privilégiant au niveau de chaque décision la branche
pour laquelle la condition est vraie. Ceci donne le chemin initial
π1 = 1 − 2true − 3true − 4
pour lequel on va calculer le prédicat associé de manière incrémentale tout en mémorisant les prédicats des préfixes parcourus dans une pile. Le fait de pouvoir utiliser
une pile plutôt qu’une structure plus compliquée (typiquement un arbre de préfixes de
chemins) est directement lié à la nature du parcours DFS.
Les variables logiques associées aux paramètres d’entrée i, j, k de max3 seront notées I, J, K respectivement. La première décision rencontrée k >= i correspond au
prédicat (I >= J) où I, J sont les variables logiques associées aux valeurs initiales
des paramètres i, j de la fonction. Le contexte à empiler correspond normalement au
1 http://cil.sourceforge.net/
2 http://www.hpl.hp.com/techreports/2003/HPL-2003-148.html
3 http://sourceforge.net/projects/lpsolve
52
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
i n t max3 ( i n t i , i n t j , i n t k ) {
i f ( i >= j )
i f ( k >= i )
return k ;
else
return i ;
else
i f ( k >= j )
return k ;
else
if ( i > j )
return i ;
else
return j ;
}
F IG . 4.1 – Fonction de calcul du maximum de 3 entiers
prédicat, à l’état mémoire courant, et au branchement pris. Pour la fonction max3,
qui n’effectue aucune écriture, l’état mémoire est constant tout au long des diverses
exécutions possibles et égal à
i → I, j → J, k → K
Il n’est donc pas nécessaire de mémoriser l’état mémoire au niveau de la pile des
contextes. Le contexte peut donc être vu comme une paire associant une branche et
un prédicat. La pile, qui était initialement vide, devient donc :
2true
I >= J
La deuxième décision k >= i ajoute le prédicat K >= I, qui est placé en sommet de
pile :
3true
2true
K >= I
I >= J
La dernière instruction du chemin return k ne modifie pas le prédicat de chemin,
qui est obtenu en effectuant une conjonction de l’ensemble des éléments de la pile
ϕπ1 ≡ (K >= I) ∧ (I >= J)
L’étape de calcul du prédicat de chemin étant terminée, il s’agit maintenant de le résoudre, soit en trouvant une solution, soit en démontrant qu’il n’a pas de solution. La
valuation I = J = K = 0 est solution du prédicat et on vérifie bien que le chemin π1 est exécuté sur ce cas de test. On peut aussi vérifier que la fonction retourne
bien le maximum des trois valeurs en entrée, mais il s’agit d’un problème annexe à la
technique de génération de tests structurels.
I = J = K = 0 |= ϕπ1
53
Génération Automatique de Tests
L’objectif de test n’étant pas couvert, il s’agit de recommencer le processus sur la base
d’un nouveau chemin. Comme pour une DFS classique, on se sert pour cela de la pile,
qui code en particulier le chemin courant parcouru. Le sommet de la pile correspondant
à une condition vraie, on sait que la branche correspondant à la condition fausse n’a pas
encore été couverte (du fait de la priorité donnée à la branche vraie dans notre parcours
DFS) : le nouveau chemin est donc
π2 = 1 − 2true − 3false − 5 − 6
La pile des contextes devient :
3false
2true
K<I
I >= J
Le chemin étant complet, il reste à résoudre le prédicat (K < I) ∧ (I >= J) par
exemple en choisissant I = J = 1, K = 0.
I = J = 1, K = 0 |= ϕπ2
L’objectif de test n’étant toujours pas couvert, on considère le chemin suivant dans le
parcours DFS. Le sommet de pile contient cette fois une branche correspondant à une
condition fausse : comme on sait que cette branche n’a pu être considérée qu’après la
branche « vraie », il ne reste plus rien à explorer au niveau de cette décision pour le
chemin préfixe courant. Le contexte se trouvant au sommet de la pile est donc retiré, et
le contexte suivant est considéré : comme il correspond à la condition vraie 2true , on
explore en DFS le suffixe correspondant à sa condition niée 2false , ce qui aboutit au
chemin complet
π3 = 1 − 2false − 7 − 8true − 9
La pile devient :
8true
2false
K >= J
I<J
Une solution au prédicat obtenu est I = 0, J = K = 1.
I = 0, J = K = 1 |= ϕπ3
Le parcours récursif en DFS continue alors via le chemin
π4 = 1 − 2false − 7 − 8false − 10 − 11true − 12
qui correspond à la pile de contextes suivante :
11true
8false
2false
54
I>J
K<J
I<J
Le prédicat du chemin π4 est égal à
ϕπ4 ≡ (I > J) ∧ (K < J) ∧ (I < J)
qui n’a évidemment pas de solution. On vient donc de prouver que le chemin π4 est
infaisable, et par la même occasion que la branche 11true est infaisable (de même que
l’instruction 12), dans la mesure où π4 est le seul chemin susceptible de la couvrir.
L’objectif de test n’est pas couvert pour autant : le prochain chemin est
π5 = 1 − 2false − 7 − 8false − 10 − 11false − 13 − 14
qui correspond à la pile :
11false
8false
2false
I <= J
K<J
I<J
Une solution possible est I = K = 0, J = 1.
I = K = 0, J = 1 |= ϕπ5
L’objectif de test de couverture de tous les chemins est maintenant atteint : on s’en
rend facilement compte en constatant que la pile ne contient plus que des branchements
correspondant à des conditions fausses, le backtrack aboutirait donc à une pile vide ce
qui indique la fin du parcours DFS. On a bien considéré au total les 5 chemins du CFG
de max3, trouvé des solutions pour 4 d’entre eux et prouvé l’infaisabilité du chemin
π4 .
4.2.4
Génération de tests par exécution concolique
L’exécution concolique mêle une exécution concrète et une exécution symbolique pour
pallier certains défauts de l’exécution symbolique « pure ». L’idée est que l’exécution
concrète classique permet de sélectionner des chemins faisables de manière plus directe, d’obtenir des informations quant aux valeurs des variables permettant de mieux
préciser le modèle mémoire (cf. relations d’alias), et d’abstraire des appels à des fonctions de bibliothèques ne pouvant être traités symboliquement (code source indisponible, écrit dans un langage différent, ou encore non instrumentable) tout en calculant
tout de même une valeur cohérente. La technique générale est d’effectuer une exécution concrète, par exemple en choisissant des valeurs au hasard pour les entrées, ce qui
permet de définir un chemin qui sera suivi en parallèle par l’exécution symbolique.
Principe (exemple)
On illustre le principe de l’exécution concolique sur le code source se trouvant à la
figure 4.2.
Au lieu de choisir comme chemin initial celui qui aboutit à l’instruction return 1
(toutes les décisions rencontrées s’évaluent à vrai), l’exécution concolique va choisir
55
Génération Automatique de Tests
1
2
3
4
5
6
7
8
9
10
11
i n t f ( i n t x1 , i n t x2 , i n t x3 ) {
i f ( x1 > 0 ) {
i f ( x1 == x2 ) {
i f ( x2 < 0 )
i f ( x3 > 0 )
return 1;
e l s e return 2;
e l s e return 3;
e l s e return 4;
} e l s e return 5;
}
F IG . 4.2 – Exemple pour la génération « concolique »
des valeurs au hasard pour les entrées (x1, x2, x3), par exemple (0, 0, 0), ce qui correspond après exécution classique (ou concrète) au chemin
π1 = 1 − 2false − 10
Le parcours DFS utilisé pour illustrer l’exécution symbolique imposait de toujours
prendre la branche vraie d’une décision avant la branche fausse. Ce n’est pas le cas
ici : on stocke donc dans la pile un élément supplémentaire indiquant quelles branches
ont déjà été parcourues (ici, la branche false). La pile correspondant à π1 a donc la
forme :
2false
X1 <= 0
false
Il est a priori inutile de résoudre le prédicat de chemin courant, puisqu’on dispose du
cas de test initial. La phase de backtrack servant à obtenir un nouveau chemin (l’objectif de test n’étant pas couvert) va considérer le sommet de pile et constater que la
branche true n’a pas encore été couverte. L’exécution symbolique pousuivrait alors
l’exploration DFS en prolongeant le préfixe 1 − 2true obtenu. Mais pour une exécution concolique, le prédicat correspondant au préfixe obtenu, qui est ici ¬(X1 <=
0) ≡ (X1 > 0), est immédiatement résolu pour obtenir un cas de test permettant de
le solliciter. Une solution existe ici, par exemple X1 = 1, X2 = X3 = 0.
Pour obtenir un chemin complet à présent, il suffit de faire une exécution concrète du
cas de test obtenu : on obtient le chemin
π2 = 1 − 2true − 3false − 9
et la pile devient
3false
2true
(X1 <> X2)
X1 > 0
false
true,false
A nouveau inutile de résoudre le prédicat de chemin total obtenu, puisqu’on est parti
d’une de ses solutions pour l’obtenir. La sélection du chemin suivant suit le même
principe : il s’agit de solliciter le préfixe 1 − 2true − 3true qui correspond au prédicat
(X1 > 0) ∧ (X1 = X2)
56
Ce prédicat est faisable, en choisissant par exemple X1 = X2 = 1, X3 = 0. L’exécution concrète fournit le chemin complet associé à ces entrées
π3 = 1 − 2true − 3true − 4false − 8
avec comme pile
4false
3true
2true
(X2 >= 0)
(X1 = X2)
X1 > 0
false
true,false
true,false
La sélection du chemin suivant passe par la résolution du prédicat associé au préfixe
1 − 2true − 3true − 4true
donc au prédicat
(X1 > 0) ∧ (X1 = X2) ∧ (X2 < 0)
Or ce prédicat s’avère infaisable. La poursuite du backtrack aboutit alors à une pile
vide, et donc à la fin de l’exploration, puisque les contextes qui restent en pile ont
vu toutes leurs branches explorées. La génération de tests structurels s’arrête donc là,
l’exécution concolique ayant permis de n’effectuer au total que 3 appels au solveur
(dont 2 ont fourni un cas de test), alors que le code comporte 5 chemins structurels
dont 3 faisables, qui auraient tous été énumérés par une exécution symbolique naïve.
Apport pour la sélection de chemins faisables
Supposons que le chemin initial sélectionné par un parcours DFS soit infaisable, et
que les raisons de l’infaisabilité proviennent d’un préfixe πprefixe petit par rapport
à la longueur totale du chemin (calculée en nombre de décisions parcourues). A titre
d’exemple :
ϕ1 ≡ (X > 1) ∧ (Y = X − 1) ∧ (Y < 0) ∧ ψ1 ∧ ψ2 ∧ . . . ∧ ψ100
où les ψ1≤i≤100 sont des prédicats simples correspondant chacun à la branche d’une
décision, et où le prédicat (X > 1) ∧ (Y = X − 1) ∧ (Y < 0) correspond au chemin
πprefixe .
La résolution du prédicat ϕ1 échouera, du fait que la conjonction des trois premiers
prédicats ne peut être satisfaite. Le parcours DFS effectuera donc un backtrack sur
la dernière branche non encore traitée, ce qui correspond à nier ψ100 et à poursuire
l’exécution sur un suffixe. Le prochain prédicat considéré sera donc de la forme :
ϕ2 ≡ (X > 1) ∧ (Y = X − 1) ∧ (Y < 0) ∧ ψ1 ∧ ψ2 ∧ . . . ∧ ¬(ψ100 ) ∧ . . .
A nouveau la résolution du prédicat ϕ2 ne peut qu’échouer. De même qu’échoueront
la résolution de tous les prédicats qui suivront, jusqu’au point où l’exploration DFS
aura effectué un backtrack sur le prédicat simple (Y < 0) : on aura alors exploré tout
57
Génération Automatique de Tests
le sous-arbre se trouvant sous le préfixe πprefixe , sans avoir trouvé un seul cas de
test puisque tous les prédicats générés seront insatisfaisables (voir une illustration à la
figure 4.3).
F IG . 4.3 – Sous-arbre suffixe infaisable
Pour éviter d’explorer le sous-arbre infaisable, il aurait suffit soit de résoudre le préfixe de manière incrémentale (ce que certains solveurs encouragent d’ailleurs), soit de
ne pas commencer l’exploration par un chemin infaisable : cette dernière solution est
facile à implanter pour peu que l’on choisisse le chemin initial sur la base d’une exécution concrète, par exemple avec des valeurs des entrées choisies aléatoirement (en
conformité avec la précondition).
Les apports de l’exécution concolique ne se limitent cependant pas au choix du chemin
initial. Comme on l’a vu précédemment, l’exécution concolique effectue un backtrack
sur un préfixe non encore exploré qui, s’il est satisfaisable, permet d’obtenir un cas
de test qui est immédiatement exécuté pour obtenir un chemin complet et par définition faisable. L’exécution concolique permet donc à tout moment de travailler sur la
base d’un chemin faisable, et évite de s’embarquer dans l’exploration de sous-arbres
infaisables.
58
4.2.5
Apport pour le traitement des alias
Ce paragraphe illustre brièvement la question des alias. Un alias apparaît dans un programme lorsque une même location mémoire peut être identifiée par des biais différents : c’est le cas en présence de pointeurs pointant sur la même variable, ou d’indices
de tableaux potentiellement égaux par exemple.
int tab [10];
int v , i , j ;
i n t ∗ p1 , ∗ p2 ;
p1 = &v ;
p2 = &v ; / ∗ p1 e t p2 s o n t en a l i a s ∗ /
p2 = &i ; / ∗ p1 e t p2 ne s o n t p a s en a l i a s ∗ /
i =5;
j=i ;
j =6;
/ ∗ t a b [ i ] e t t a b [ j ] s o n t en a l i a s ∗ /
/ ∗ t a b [ i } e t t a b [ j ] ne s o n t p a s en a l i a s ∗ /
Supposons que l’on cherche à effectuer l’exécution symbolique des chemins de la fonction suivante :
i n t f a l i a s ( i n t ∗ p1 , i n t ∗ p2 ) {
∗ p1 = 2 ;
∗ p2 = 3 ;
i f ( ∗ p1 == ∗ p2 ) r e t u r n 1 ;
e l s e return 2;
}
Une manière naïve serait de considérer que *p1 et *p2 peuvent être considérés comme
des variables distinctes. L’état mémoire au niveau de la décision serait alors de la forme
∗p1 → 2, ∗p2 → 3
Par conséquent, on concluerait que la décision s’évalue toujours à faux, et que l’instruction return 1 est infaisable. Or il est tout à fait possible d’appeler falias avec
p1 et p2 en alias, comme dans le programme suivant :
i n t main ( ) {
int i = 3;
i n t ∗ p = &i ;
return f a l i a s ( p , p ) ;
}
Dans ce cas, *p1 et *p2 correspondent à la même variable, et l’état mémoire au niveau
de la décision est cette fois de la forme
∗p1 = ∗p2 → 3
Ceci illustre le fait que la question des alias complique la représentation des états mémoires : chaque fois que se pose la question de savoir si deux variables sont en alias
ou pas, il est nécessaire de générer un état mémoire pour le cas où il n’y a pas d’alias,
59
Génération Automatique de Tests
et un autre état mémoire pour le cas où il y a une relation d’alias. Certains solveurs
permettent de manipuler des théories efficaces pour ce type de problématique, mais
l’encodage requis peut s’avérer très coûteux.
Le fait d’utiliser une exécution concolique permet de figer une relation d’alias, puisque
les pointeurs pointent alors vers des locations connues, et donc de se ramener à des
états mémoires « simples ». En revanche, la génération de tests structurels n’est valable
que pour la relation d’alias effectivement explorée : il ne sera par exemple pas possible
d’inférer l’infaisabilité d’une branche sur cette seule base. Il reste cependant toujours
la possibilité de poursuivre la génération de tests en explorant d’autres relations d’alias.
4.2.6
Apport pour l’utilisation de code externe
On évoque ici brièvement l’apport d’une exécution concolique lorsqu’on cherche à
générer des tests pour des programmes effectuant des appels au système, ou utilisant
des fonctions de bibliothèques externes, ou encore accédant à des bases de données.
Dans un cadre idéal, on dispose d’une spécification formelle de la fonctionnalité externe utilisée, qui permet de faire le lien (sous forme de prédicat) entre les paramètres
d’appel et la sortie attendue. L’appel à la fonction externe peut donc être abstrait à l’aide
du prédicat la formalisant, ce qui s’intègre bien à une exécution symbolique classique.
Mais dans de nombreux cas pratiques, une telle spécification n’existe pas, est trop
coûteuse à obtenir, ou s’avère trop complexe. Fournir une valeur par défaut intéressante
n’est pas toujours possible (penser à une requête dans une base de données), de même
que fournir une valeur symbolique libre de toute contrainte n’est pas toujours judicieux.
La possibilité offerte par l’exécution concolique est d’obtenir « gratuitement » une valeur concrète qui soit compatible avec les autres valeurs concrètes utilisées comme
paramètres, sans nécessiter de connaissance particulière sur le code appelé. L’exécution et la génération de tests peut se poursuivre avec une valeur faisant sens, ce qui peut
permettre d’explorer davantage de comportements pertinents qu’une valeur par défaut
ou purement symbolique.
4.3
Conclusion
La génération automatique de tests structurels connaît un regain d’intérêt considérable
depuis quelques années (2005 environ), et des outils commencent à être diffusés assez
largement hors de la communauté académique (outil Pex 4 de Microsoft Research). Les
techniques mises en œuvre sont à la confluence de plusieurs domaines de la vérification
de logiciel, comme l’interprétation abstraite, le bounded model checking et la preuve
automatique de théorèmes. Le principal domaine d’application est la recherche de défauts, typiquement des erreurs à l’exécution, ceci sur des codes pouvant aller jusqu’à
quelques dizaines de milliers de lignes [7].
4 http://research.microsoft.com/en-us/projects/Pex
60
Bibliographie
[1] K. R. Apt and M. Wallace. Constraint Logic Programming using Eclipse. Cambridge University Press, 2007.
[2] A. P. Mathur. Foundations of Software Testing. Pearson Education, 2008.
[3] J. C. King. Symbolic execution and program testing. Communications of the
ACM, Volume 19, Issue 7 (July 1976), 385 - 394.
[4] G. J. Myers et al. The Art of Software Testing (Second Edition). John Wiley &
Sons Inc., 2004.
[5] N. Williams, B. Marre, P. Mouy and M. Roger. PathCrawler : Automatic Generation of Path Tests by Combining Static and Dynamic Analysis. Proc. Dependable
Computing - EDCC 2005, LNCS Vol. 3463/2005, 281-292.
[6] P. Godefroid, N. Klarlund, K. Sen. DART : directed automated random testing.
Proc. of PLDI’2005, 213-223.
[7] C. Cadar, D. Dunbar, D. Engler. KLEE : Unassisted and Automatic Generation of
High-Coverage Tests for Complex Systems Programs. Proc. of OSDI’2008.
61

Documents pareils