Cours INE21 Séances 1-5sebastien.bardin.free.fr/poly-herrmann.pdf · d’un logiciel vérifié en...

67
Introduction au test de logiciel Cours INE21 Séances 1-5 Philippe Herrmann [email protected] session 2010

Transcript of Cours INE21 Séances 1-5sebastien.bardin.free.fr/poly-herrmann.pdf · d’un logiciel vérifié en...

Introduction au test de logiciel

Cours INE21Séances 1-5

Philippe [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

1.2 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

1.3 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

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

2.2 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

i

TABLE DES MATIÈRES

2.2.5 Environnement de test unitaire . . . . . . . . . . . . . . . . . 15

2.2.6 Mesure de la qualité d’une suite de tests . . . . . . . . . . . . 16

2.3 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

2.4 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

2.5 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20

3 Sélection des Tests 213.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

3.2 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

3.3 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43

4 Génération Automatique de Tests 454.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

4.2 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

ii

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

4.3 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60

iii

iv

Chapitre 1

Introduction

1.1 Vérification : Objectifs et Intérêt

1.1.1 Vérification et Validation

Vérification et Validation ou V&V : ensemble d’activités exécutées en parallèle dudéveloppement d’un système logiciel afin de fournir l’assurance qu’il fonctionneraconformément à un ensemble d’exigences / spécifications / besoins utilisateur.

Le processus de V&V évalue le logiciel dans un contexte système par une approchestructuré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 parexemple) et l’utilisateur. Il s’inscrit naturellement dans le cycle en V classique de dé-veloppement de systèmes logiciels.

Exigences Système

Exigences du Logiciel

Spécifications

Conception Générale

Conception Détaillée

Codage

Recette du Produit final

Tests Système

Tests d'Intégration

Tests Unitaires

déverminage

analyses dynamiques(plate­forme d'exécution)

analyses statiquesrevues diverses

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 à desstandards, procédures, politiques, process, exigences, spécifications : « am I buildingthe product right ? »

– Validation : ici on compare le contenu informatif du produit à des propriétés extrin-sèques : fait-il ce pour quoi il a été conçu ? satisfait-il les besoins du client : « am Ibuilding 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 minimumde défauts. Les défauts interviennent typiquement à différents niveau dans le cycle enV :

– défaut dans les spécifications (amont) : une fonctionnalité attendue a été oubliée oumal spécifiée

– défaut dans la conception : la réalisation d’une fonctionnalité ne satisfait pas à ce quia été spécifié

– défaut de codage (aval) : l’implantation de la fonctionnalité n’a pas été faite en ac-cord avec sa conception

Plus le défaut est introduit en amont, plus son impact potentiel est grand et sa correctioncoûteuse. Il s’avère donc indispensable d’adopter une méthodologie de vérification quipermette de détecter les défauts au plus tôt après leur introduction, par exemple envé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 doncessentiel de pouvoir tracer un défaut jusqu’à sa source, notamment lors du développe-ment du logiciel, mais potentiellement aussi lors des phases d’intégration, voire lorsquele logiciel est en opération. Il est souvent indispensable de prévoir des fonctionnalitésde diagnostic afin de pouvoir effectuer un retour vers le développeur en cas de détectionde 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, notammentparce que vérifier a posteriori qu’un logiciel répond à certaines exigences est infinimentplus 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’agitde vérifier que les différents modèles décrivant le système final et ses sous-systèmessatisfont 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 niveausupé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èreincrémentale pour aboutir au système final. A chaque étape, les sous-systèmes sontsupposés remplir correctement des sous-fonctions, et l’on cherche à vérifier que leurcombinaison 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évelop-pement. Il existe des technologies outillées qui ont pour objectif d’assurer formelle-ment 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 utilisantla méthode B.

Il est cependant fréquent que les différentes étapes du développement soient chacuneassociée à un ensemble de processus ad hoc pour vérifier leurs exigences : le test, lapreuve de programme et le model checking en sont des exemples. Une difficulté estalors 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éthodo-logies 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éralementpour interdire des constructions jugées dangereuses pour des langages comme C ouC++

– revue par les pairs : les autres concepteurs/développeurs effectuent des relecturescritiques des modèles/codes, dans un processus qui peut être formalisé

– utilisation d’outils de génie logiciel : gestionnaire de versions 3, bug tracking sys-tems 4, . . .

– environnement d’automatisation des tests : faciliter leur écriture, automatiser leurexécution (non-régression)

Pourquoi vérifier ?

A un niveau plus macroscopique, la vérification a avant tout un intérêt économiquecrucial pour l’industrie du logiciel. Des études ont chiffré les gains potentiels d’uneactivité de test bien menée à plusieurs milliards de dollars pour l’industrie du logicielaméricaine. Il existe donc un intérêt économique évident à la vérification, qui permetde produire plus rapidement des logiciels de meilleure qualité. Ceci est à mettre enparallè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 hautniveau de qualité.

1http://www.atelierb.eu2http://www.misra.org.uk3http://subversion.apache.org4www.mantisbt.org

3

Introduction

Outre l’aspect économique, certains domaines d’activité industrielle ayant une compo-sante 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 autori-té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 lo-giciel 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 critiquesdans l’avionique. Les niveaux les plus exigeants de cette norme (A,B,C) exigent parexemple 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 lo-giciel produit dans un processus de développement classique a bien peu de chance desatisfaire à ce type de contraintes de couverture structurelle.

1.2 Bonnes pratiques dans le logiciel

1.2.1 Bonnes pratiques

Pour un développement classique de logiciel, certaines pratiques de développement etde vérification sont communément admises comme ayant fait leur preuve du point devue du rapport entre leur coût et leur gain en terme de qualité. L’état de l’art prôneprincipalement :

– 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 mo-

dè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 cou-

verture, et en général la réalisation et l’exécution d’un plan de tests pertinent (pourles 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éoriquessuivantes (en partant d’une situation sans systématisation du test, sans revue par lespairs, 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 nerelè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 à quelniveau ?

– intégrité, sécurité : résiste-t-il aux attaques intentionnelles ou aux maladresses del’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 (parexemple politique d’ordonnancement et analyse du pire temps d’exécution pour leslogiciels temps-réel).

La qualité du logiciel se mesure également en terme de facilité de développement etd’évolution, qui jouent un rôle important du point de vue de l’aspect pratique de lavé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 del’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 lapolitique de qualité (du logiciel ou plus généralement du système) ont émergé depuis15-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 Mel-lon 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 etde façon cohérente des processus qui sont documentés, gérés, mesurés, contrôlés etcontinuellement 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érienceacquise lors des projets

– niveau 4 : gestion quantitative des processus (on fait des statistiques pour repérer lesproblèmes)

– niveau 5 : on optimise en permanence les processus sur la base des analyses statis-tiques

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 ducycle en V) dans l’industrie du logiciel au cours du temps.

capture des exi-gences

conception préli-minaire

conceptiondétaillée

codage test unitaire test d’intégra-tion

test système

années 60-70 10% 80% 10%années 80 20% 60% 20%années 90 40% 30% 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éri-fication tardive : l’effort global s’est réparti plus amont

– cette prise de conscience a favorisé l’émergence de modèles de conception et delangages de spécifications, qui en retour a favorisé l’investissement dans l’effort deconception

– les systèmes étant de plus en plus gros et intégrés, les phases de test d’intégration etde 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 incom-pressible, notamment du fait de la complexité des systèmes produits

1.3 Vérification et méthodes formelles

1.3.1 Vérification Formelle : définition

Par vérification formelle d’un logiciel, on entend la vérification mathématique « ex-haustive » de sa correction. On cherche à prouver au sens mathématique que l’implan-tation (vue comme un modèle logique) satisfait à sa spécification (vue par exemplecomme un ensemble de formules logiques).

La vérification formelle est une activité complexe. Tout d’abord, il y a la difficulté tech-nique d’extraction d’un modèle depuis un programme ou de formules logiques depuisdes spécifications (souvent semi-formelles). Cette extraction doit se faire de manièresûre bien entendu. Mais la difficulté principale est d’arriver à faire la preuve que le mo-dèle extrait satisfait bien aux formules qui correspondent à la spécification requise. Pourun programme autre qu’un programme jouet, envisager une preuve manuelle complèteest souvent tout à fait hors de question, la taille du modèle étant rédhibitoire. La preuveautomatique à l’aide d’un ordinateur est quant à elle un problème tout à fait indéci-dable (il n’y a pas de logiciel magique qui serait capable de prouver automatiquementla correction de tout logiciel).

Le problème ne pouvant être résolu dans toute sa généralité, divers choix et techniquesse présentent pour le simplifier. La vérification formelle consiste généralement en lacombinaison 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 demettre le modèle en défaut par rapport à une propriété de la spécification. Le test peuts’effectuer sur le logiciel final, ou des sous-parties du logiciel (modules, fonctions), ousur des modèles du logiciel. La qualité première du test est de permettre de révéler desdéfauts avec une grande précision et à moindre coût, mais jamais de garantir qu’il n’yait aucun défaut.

La difficulté du test est de parvenir à un ensemble de tests (ou suite de tests) suffi-samment pertinent pour se convaindre de la correction du logiciel. Il est impossible deprouver qu’un programme est correct en n’utilisant que des techniques de test, puis-qu’il s’agit d’une technique qui explore seulement une sous-approximation des com-portements. Il est donc important de pouvoir mesurer la qualité d’une suite de tests, cequi se fait généralement en terme de couverture d’un certain critère. Les critères lesplus courants au niveau du test de code sont le taux de couverture des instructions oudes branches (décisions).

De nombreux auteurs considèrent que le test n’est pas une technique de vérificationformelle à proprement dit, puisqu’il ne permet pas à lui seul de conclure. Il reste qu’ils’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’abstrac-tion

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’utilisationde structures de données ayant des invariants complexes, le recours à des types de don-né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éfinitun système de transitions généralement infini décrivant l’ensemble des traces d’exécu-tion possibles (séquence des états mémoire). L’idée générale est d’abstraire ce systèmede 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 deprouver les propriétés requises sur cette vue abstraite, simplifiée par rapport à la vueconcrète de la sémantique opérationnelle.

Le point crucial est qu’une propriété prouvée sur la vue abstraite sera correcte sur lavue concrète. En revanche, une propriété qu’on n’arrive pas à prouver sur l’abstractionne sera pas forcément incorrecte sur la vue concrète, puisque la sur-approximation a puintroduire des comportements illicites du point de vue de la propriété à prouver maisqui 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éman-tique 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 peutpar exemple choisir un domaine abstrait de représentation des variables (ex : inter-valles), et choisir d’abstraire un chemin d’exécution par le point de contrôle qu’il at-

7

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’untableau, déréférencement d’un pointeur invalide. Il permet en effet de traquer avec unegrande précision les valeurs potentielles qu’une variable peut prendre en chaque pointdu graphe de contrôle. Dans les faits, une grande expertise est requise pour obtenir aufinal une abstraction fidèle du programme initial, sans trop de faux positifs (valeurs dudomaine 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’analysedevient 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érale-ment à base d’automates finis étendus avec des types de données simples) pour lequelil existe un algorithme permettant de vérifier la classe de propriétés visées (souventexprimées en terme de logique temporelles, décrivant des enchaînements complexesd’actions). Une première difficulté est de parvenir à abstraire le logiciel vers ce typede modèle, automatiquement dans l’idéal. Les limitations fortes sur le pouvoir d’ex-pression des modèles obligent souvent à des abstractions relativement grossières. Uneautre difficulté majeure est le problème du passage à l’échelle en fonction de la tailledu 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 issusdu 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 logi-ciel à partir de ses sources) travaillent par sur-approximation. Par exemple : algorithmesde calcul de dépendances de données et autres algorithmes dataflow utilisés dans lescompilateurs.

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 Coqdéveloppé à Orsay 10) qui intègrent des procédures de décision efficaces (preuve auto-matique, généralement sur des logiques du premier ordre sans quantification ; voir parexemple 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’as-sistant de preuve (problème de sa représentation dans la théorie choisie), mais sur desformules extraites du programme (obligations de preuves), de préférence de manièreautomatisé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

5http://frama-c.com6http://www.mathworks.com/products/polyspace7http://www.absint.com/astree8http://spinroot.com9http://www.uppaal.com

10http://coq.inria.fr11http://research.microsoft.com/en-us/um/redmond/projects/z3

8

en entrée de la fonction englobante. La principale difficulté est le passage des bouclesdu programme, pour lesquelles l’utilisateur doit fournir un invariant, qui permet d’abs-traire le comportement de la boucle. Les alias (plusieurs manières d’identifier la mêmelocation 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 engé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 depreuve 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 formellesau 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îtun 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ètreplus 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 à lavérification reste faible ou impose un processus de développement relativement spéci-fique (méthode B, outil Scade 13). Il est en particulier difficile de trouver un ensemblede modèles suffisamment riches pour représenter un système complet et ceci à tousles niveaux de conception, tout en gardant une grande liberté sur le choix des tech-nologies (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 re-lativement 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’algorithmesplutôt que d’implantations), même si les récents progrès notamment sur les solveurspermettent d’envisager un élargissement de l’offre.

Du point de vue du test (technique par sous-approximation), il s’agit d’une technologielargement répandue et réputée très efficace pour la recherche de défauts, notammentlorsque l’activité de test est structurée, systématisée et automatisée. Les environne-ments de test unitaire (comme JUnit 14 pour Java) ont connu un réel essort, et destechniques complexes de génération de tests sur des critères structurels sont en trainde 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 per-mettent par exemple de spécifier des pré/post-conditions et des invariants en annotant lecode 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.

12http://www.lri.fr/~marche13http://www.esterel-technologies.com/products/scade-suite14http://www.junit.org15http://research.microsoft.com/en-us/projects/Pex16http://www.eecs.ucf.edu/~leavens/JML17http://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 don-nées chiffrées suivantes :

– le poids de l’activité du test dans l’industrie du logiciel aux USA s’élève à plusieursdizaines 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 correspondprincipale à l’exécution de tests. Même si un code a été prouvé formellement, le testerreste 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 onlyproved it correct, not tried it ». Enfin, le test est la technique la moins coûteuse et laplus efficace pour capturer une grande partie des défauts d’implantation (bugs) et on nepeut 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 moyensautomatiques ou manuels, pour vérifier qu’il répond à ses spécifications ou identifierles 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 desdéfauts 2.

1IEEE (Standard Glossary of Software Engineering Terminology)2G. 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 testexhaustif d’un programme en injectant toutes les entrées possibles n’est en général paspossible. 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 lescoûts de développement et de maintenance, mais également de potentiellement sauverdes vies. Des tragédies comme le crash d’un Airbus A320 ou des surexpositions mor-telles à des radiations lors d’examens médicaux sont directement liées à des défautslogiciels.

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 im-pactant). Le test est une méthode dynamique, dans la mesure où l’on exécute réellementle programme, contrairement aux techniques par analyse statique, où seul le source estexaminé 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èsl’introduction de nouveau code .

– précision sur la détermination de l’origine d’un défaut. L’échec d’un test doit fournirun 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 lacorrection 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èresest 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à beau-coup été évoqué. Idéalement, cette recherche de défauts se fait au plus tôt, en parallèledu 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 (quelquefoisavant, cf. test driven development, souvent juste après), et s’aidant de ces tests pourcorriger les bugs rencontrés jusqu’à satisfaction. Le test est donc vu comme un moyende mettre le programme en défaut (test-to-fail) et l’on aboutit naturellement à l’obten-tion 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

3E. W. Dikjstra (Notes on Structured Programming, 1972

12

é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 maisque les interfaces et spécifications restent les mêmes.

Le test peut également être vu comme un processus d’assurance qualité (validationtesting) voire comme participant à la certification du logiciel. L’idée est de contrôlerla qualité d’un logiciel, pour soi ou un tiers (autorité de certification), dans la phaseascendante du cycle en V, généralement en boîte noire (connaissance des spécificationsmais pas de l’implantation du logiciel), et par des équipes dédiées (la DO178B peut parexemple exiger que ces tests soient effectués par des équipes distinctes des dévelop-peurs). Cette utilisation du test est typique dans les industries développement des sys-tèmes embarqués critiques. Le test est alors vu comme un moyen de confirmer le bonfonctionnement 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) quel’on cherche à exercer. Il s’agit de définir une suite d’actions (les entrées du test) ainsique 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 : va-riables globales, paramètres de fonctions, actions venues de l’extérieur . . . Chaque en-trée est associée à un domaine de valeurs possibles (domaine de définition), qui est unsous-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 dansson 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 correspondentbien à 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éclenchantet d’un oracle décidant de sa réussite. Il s’agit donc d’une étape dans la concrétisationd’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 derecueillir les résultats (on parle aussi de verdict de test, suivant que l’oracle soit satisfaitou 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 insertionou un tri-bulles. Le résultat du tri rapide doit correspondre exactement au résultat dutri 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’oracleest donc simple, la difficulté principale étant de fournir une implantation du tri simplequi 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, quel’on peut a priori supposer correcte. Pour le langage C on dispose par exemple du trirapide 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 êtreappelée (ici, la taille du tableau, la taille de chaque élément et une fonction de compa-raison entre éléments doivent être fournis).

Enfin, on peut également remarquer qu’implanter un oracle est souvent plus facilequ’implanter la fonction à réaliser (vérifier qu’une solution est correcte s’avère sou-vent plus facile que de construire une solution). Ici, on peut par exemple vérifier quele tableau en sortie est trié dans l’ordre qui convient (ce qui est facile), et qu’il com-porte exactement les mêmes éléments que le tableau en entrée, avec le même nombred’occurrences (plus difficile).

2.2.3 Process simplifié du test

Le processus de test suit les étapes suivantes :

1. identification des scénarios à tester2. détermination des oracles de chaque scénario3. 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énarios4. création et exécution d’un script de test évaluant le programme sur l’ensemble

des cas de tests5. comparaison des résultats obtenus aux oracles6. é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 oraclesutilisés au final nécessitent généralement d’être concrétisés de manière plus préciseque cela n’est possible en conception. Il faut en particulier être capable de traduireles entrées, sorties et observables tels que définis au niveau des spécifications en tantqu’éléments concrets de l’implantation finale.

La génération des données de test, qu’elle soit manuelle ou automatique, constitue uneactivité à 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 enV, au contraire des activités précédentes. Evidemment un constat d’échec à ce niveauimplique des corrections sur le code, la conception ou les spécifications du systèmesuivant la phase de test où l’on se trouve alors. La conception des tests eux-mêmespeut bien évidemment être en cause. La capacité à émettre des rapports de test infor-matifs est cruciale afin de pouvoir détecter le plus précisément possible l’origine de ladivergence 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 plu-sieurs cas de test, ceci en appelant un certain nombre de fonctions d’initialisationset de constructeurs. Il peut par exemple s’agir d’allouer un certain nombre d’objetsayant certaines dépendances, d’initialiser les tables d’une base de données avec cer-taines entrées, d’émettre ou de recevoir un ensemble de messages dans le cadre d’unprotocole . . .

– 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érationsd’observation qui vont permettre de faciler l’évaluation de l’oracle. Le scénario detest peut en effet nécessiter d’observer des actions effectuées en cours d’exécutiondu test, et non pas simplement le résultat final. Le script de test doit donc permettrede tracer les actions requises, ou de voir l’évolution des valeurs de certaines va-riables globales. Cela n’est possible que si le programme sous test rend ces donnéeseffectivement observables.

– Postambule : le script réinitialise le programme dans un état initial, par exemplel’état obtenu juste après exécution du préambule, ceci afin de permettre d’enchaîneravec 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 faciliterl’écriture, la maintenance ou l’exécution des tests unitaires, et éventuellement l’éva-luation de leur qualité (couverture de tests). Un tel environnement réalise tout ou partiedu travail que l’on peut attendre d’un script de test, sans le caractère ad hoc que peutavoir un script fait maison. On peut citer à titre d’exemples : JUnit pour Java (il existel’équivalent pour C#), et FORT pour Objective Caml (Framework for OCaml Regres-sion 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èmescritiques)

– les préambules, corps et postambules sont des fonctions virtuelles à définir dans lesclasses implantant effectivement les cas de test (généricité)

– l’oracle est implanté par l’utilisateur en utilisant tout la puissance du langage debase, ainsi que des facilités offertes par l’environnement de test

– l’environnement de test peut proposer un environnement d’exécution facilitant lamesure 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 desuites de tests pertinentes. L’objectif principal est d’obtenir une suite de tests dont letaux de couverture est élevé, ce qui indique qu’une bonne proportion des comporte-ments 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 branchesou de paires définition-utilisation effectivement exécutées.

On peut également baser la mesure de couverture sur le taux de détection de mutantsdu logiciel sous test. Un mutant est un quasi-clone du programme, dans lequel seulun petit nombre d’instructions (souvent une seule) a été modifié. On peut par exemplechoisir de remplacer un opérateur de comparaison < par ≤ ou un = par un 6=. Lestransformations effectuées pour obtenir les mutants se basent sur un modèle des fautesles plus probables commises par le programmeur, fautes qui devraient être à mêmed’être capturées par les cas de tests.

Outre la qualité de couverture obtenue, une deuxième caractéristique importante est dedisposer 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 équi-valent). Réduire le nombre de tests permet de réduire le coût d’écriture, d’exécution etde maintenance des tests.

Les critères de couverture principaux seront détaillés dans le chapitre suivant.

2.3 Caractérisations de l’activité de test

2.3.1 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éeset 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 contraired’approche « boîte blanche ».

Un test issu des spécifications est aussi appelé un test fonctionnel : le but du cas de testest de mettre en avant de manière explicite une fonctionnalité du système apparaissantdans 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ésau code (ou d’autres éléments structurels, comme le fait de chercher à atteindre uneinstruction particulière dans un état-mémoire donné). On associe généralement le testboî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 enV typique, en parallèle des phases de spécification, conception, et de codage. L’exécu-tion des tests se fera dans chacune des phases de la partie remontante du cycle, sur labase des cas de tests élaborés dans la phase jumelle de la partie descendante. On parlealors 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 lacorrection fonctionnelle et la sûreté d’un logiciel, en parallèle d’activités de vérificationformelle. Mais le test est également le principal moyen pour évaluer un système dupoint de vue de sa résistance aux attaques (sécurité), au stress ou à la charge, et dupoint 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 testde 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 lestests 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 desolliciter les scénarios de test correspondants. Différentes techniques manuelles ou au-tomatiques 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). Cestechnologies 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 deconception, généralement à base d’automates finis, qui se prêtent bien aux techniquesde génération automatique de tests structurels par des méthodes symboliques. Dansce cours on se concentre davantage sur le test de code source, étant entendu que lestechniques 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 faitl’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 égalementde génération, ou sélection, de cas de test. On peut classifier les différentes techniquespermettant de déterminer les cas de test en deux grandes familles : le test fonctionnelet le test structurel.

Test fonctionnel : On parle de test fonctionnel lorsque le cas de test est conçu à partirdes spécifications du logiciel (par exemple les cas d’utilisation définis par UML). Leconcepteur du test n’a pas accès au code source ou a choisi de ne pas regarder la façondont le logiciel a été écrit : c’est pour celà qu’on parle également de test en boîtenoire. Il s’agit principalement d’évaluer dans quelle mesure les fonctionnalités que lesspé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 égalementde test en boîte blanche). Le testeur essaie de mettre en évidence la façon dont l’im-plantation 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écu-tion qu’il veut voir sollicités. Il est évidemment nécessaire d’avoir accès au code sourceet 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 direc-tement compréhensibles que le code, l’écriture de cas de test s’en trouve facilitée, etils ont un sens fonctionnel généralement assez évident. De même, la déterminationdes oracles est en général simple car explicite dans la spécification. En revanche, lesspécifications n’étant pas toujours formelles ou très précises, il peut être difficile deconcrétiser les données de test ou de réaliser effectivement l’oracle sans passer parune analyse approfondie des documents de conception. En essence, on ne teste queles fonctionnalités attendues du programme, et il est peu probable de mettre à jour desfonctionnalités cachées ou des erreurs à l’exécution non triviales sans les rechercherexplicitement.

Le test structurel étant déterminé à partir du code source, la réalisation du script de tests’en trouve facilitée. En revanche la détermination de l’oracle peut poser des difficul-tés : un test sollicite un chemin dont la sémantique en termes fonctionnels demandepotentiellement beaucoup d’investissement de la part du testeur. De plus, des fonction-nalités de haut niveau relativement claires au niveau des spécifications peuvent êtredifficiles à 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 tech-niques 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 compositionde procédures et modules pour former des sous-systèmes, qui réalisent des fonctionna-lités de plus haut niveau.

Enfin les tests système / d’acceptation / de conformité permettent de valider le compor-tement fonctionnel du code par rapport aux spécifications générales du système, ainsique 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 toujourscroissante des fonctionnalités demandées. Le processus de test évolue alors en fonctionde l’étape du développement. Les premières étapes se focalisent sur l’écriture d’un plande test qui évoluera tout au long du développement en spirale, ainsi que sur l’écriturede tests unitaires et d’intégration qui seront raffinés dans les étapes ultérieures, toutcomme 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éveloppementorienté par les tests lient très fortement les activités de développement et de test. Lesexigences sont spécifiées sous forme de tests, et le code de test est souvent écrit avantle 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 unobjectif 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, destress, ou de performance.

2.4 Pièges du test

2.4.1 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 tra-vaillant par sous-approximation. Il ne faut néanmoins pas minimiser son intérêt : laplupart des systèmes industriels classiques sont validés principalement à travers dutest, souvent parce qu’il s’agit de la seule technologie efficace pour un coût restantraisonnable.

Le test a l’avantage considérable de ne pas exiger de modifier les processus de déve-loppement, qui l’intègre déjà, ni d’exiger de la part des équipes de développement uneculture forte en vérification formelle. Comparativement aux techniques formelles, ilne nécessite donc pas d’investissement lourd, que ce soit en terme de formation ou detemps de développement. Il peut également être utilisé jusqu’à satisfaction d’un objec-tif quantitatif (de couverture par exemple), alors qu’il fait rarement sens de prouver unsystè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 êtredétectés. C’est rarement le cas pour les autres types d’outils de vérification, qu’il estalors nécessaire de certifier pour une utilisation industrielle. Enfin le test a l’avantagede pouvoir détecter tous les types de défauts, alors que des techniques comme l’inter-pré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 desmé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 dutest fonctionnel (ou boîte noire). La seconde partie traite des différentes mesures de lacouverture 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’unprogramme (ou plus généralement, ses exigences), et impose a minima de pouvoiridentifier le domaine des entrées du programme sous test ainsi que les oracles. Cettetechnique de génération ne présuppose en revanche aucune connaissance de la structureinterne du programme, par exemple parce qu’on n’en dispose pas encore, ou qu’on necherche pas à l’exploiter. Il s’agit d’une technique applicable à tous les niveaux ducycle 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 domained’entrée d’un programme en un nombre fini de classes d’équivalences représentativesde classes de comportements, puis de sélectionner (au moins) un test dans chaque classed’é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. Sila partition est correctement réalisée, on peut a priori ne choisir qu’un cas de test parclasse d’équivalence.

Dans une approche par analyse partitionnelle, la stratégie de sélection des cas de testest la suivante :

1. Analyser les exigences pour identifier d’une part les entrées du programme etleurs 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 etinvalides sur les entrées, et pour définir également un oracle par classe. Uneclasse 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 domainede définition. A contrario, une classe d’équivalence invalide ne regroupe que desentré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 cer-tains cas, comme lorsque l’utilisateur doit saisir une partie des entrées via leclavier de manière libre : il n’est alors pas aisé de le contraindre à saisir unevaleur 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’unentier à partir d’une entrée au clavier (donc une chaîne de caractères) supposée fournieen notation décimale.

La spécification informelle de la fonction indique que le programme attend une entréeunique, dont le type est une chaîne de caractères str, et que str représente un entierrelatif en notation décimale.

Les entrées invalides du fait d’un nombre invalide d’entrées saisies (aucune entrée, ouplus d’une entrée) correspondent à des cas où la chaîne obtenue est soit vide, soit conte-nant plusieurs mots séparés par des espaces. On peut donc définir deux classes d’équi-valence d’entrées invalides : la classe « chaîne vide » (dont l’unique représentant est lachaî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 unechaî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 commeexemples de représentants "0x1234", "+12", "56a", "-0.0".

Il reste à définir les classes d’équivalence d’entrées valides. La fonctionnalité « valeurabsolue » du programme indique clairement qu’il est bon de définir au moins deuxclasses 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înescorrespondant à la notation décimale d’entiers < 0, par exemple "-1234"). Les oraclessont 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 apparte-nant à 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 validité représentant oraclechaîne vide invalide "" échec

plusieurs mots invalide "1234 1234" échecpas un décimal invalide "56a" échecdécimal positif valide "1234" 1234décimal négatif valide "-1234" 1234

Exemple 2

L’objectif est de tester une fonction maxsum(value,maxint) dont la spécificationsuit. Cette fonction calcule la somme des premiers value entiers tant que cette sommereste plus petite que maxint. Sinon, une erreur est affichée. Si value est négatif, lavaleur absolue de value est considérée.

Afin de définir les classes valides et invalides, il est utile d’introduire la relation binaireC1(x, y) définie par :

C1(x, y) ≡∑xi=0 i ≤ y

On obtient alors la partition suivante du domaine des entrées :

Domaine Classes valides Classes invalides

domaine de value

entier < 0satisfaisantC1(-value,maxint)entier ≥ 0satisfaisantC1(value,maxint)

entier < 0ne satisfaisant pasC1(-value,maxint)entier ≥ 0ne satisfaisant pasC1(value,maxint)

domaine de maxint entier ≥ 0 entier < 0

On voit ici l’intérêt de C1 qui permet de contraindre la valeur absolue de value enfonction de maxint tel que l’exige la spécification : la satisfaction de la contrainte C1conditionne la validité des entrées.

Quant à la partition du domaine de maxint, elle découle naturellement du fait qu’unevaleur strictement négative ne permette pas d’aboutir à un résultat autre que l’affichaged’une erreur.

Les classes valides ne comprennent que des entrées valides : il faut donc à la fois quemaxint ≥ 0 et que l’on ait C1(|value|,maxint). Ceci permet de définir deuxclasses 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’unesource 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 soitvalide mais C1(|value|,maxint) ne soit pas satisfait (avec des valeurs positives etnégatives de value). On aboutit donc à cinq classes d’équivalence, et donc cinq casde tests au total :

23

Sélection des Tests

maxint value validité oracle100 10 valide 55100 -10 valide 5510 5 invalide ¬ C1 erreur10 -5 invalide ¬ C1 erreur-10 1 invalide maxint < 0 erreur

3.1.2 Test aux limites

Le test aux limites permet de compléter une analyse partitionnelle en introduisant destests 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 ledéveloppeur a tendance à introduire des erreurs sur les cas limites, qu’il faut donc testerde 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’équivalenceet sélectionner des tests y correspondant. Pour l’exemple de la fonction maxsum,onchoisira par exemple des valeurs de maxint et value qui satisfont exactement∑|value|i=0 i = maxint (cas limite valide), et aussi

∑|value|i=0 i = maxint + 1 (cas

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 pro-gramme. 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, dessingletons, des paires, et des ensembles avec beaucoup d’éléments. Pour une en-tré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 deproduire ces valeurs en sortie. Pour une fonction inv(x) implantant 1/x pour uneentrée flottante, on sélectionnera des valeurs très proches de 0.0 par exemple, afind’obtenir de très grands nombres en sortie, ou au contraire des valeurs très grandespour 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 testsignificatives parmi un ensemble de configurations dont la combinatoire explose. Letest exhaustif est en effet impratiquable, même sur de petits programmes : pour un pro-gramme ayant 4 entrées qui sont des entiers codés sur 32 bits, il y a 2128 combinaisonsde 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-wiseest une généralisation, où l’on teste chaque combinaison de n valeurs. L’idée sous-jacente en terme de modèle de fautes (défauts) est qu’une majorité de défauts sontdé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) com-muniquant sur un certain nombre de bus de terrain. L’ECU est paramétrée par le choixd’un OS (système d’exploitation) et d’un CPU (processeur). Chaque bus de terrainest paramétré par le choix d’un protocole réseau. Les domaines d’entrée sont définiscomme suit :

OS Protocole CPU BusVxWorks CAN PowerPC 750 Confort

QNX Bluetooth ARM 9 MécaniqueLinux RT TTP Star12X Diagnostic

Le nombre total de combinaisons pour un test exhaustif est de 34 = 81, ce qui corres-pondrait à 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 untemps considérable. Une approche pairwise peut ici de manière crédible capturer ungrand nombre de défauts liés aux interactions entre les différentes dimensions du sys-tème (exemple fictif : Linux RT implante un temps réel trop mou pour qu’un protocoletime-trigger comme TTP fonctionne correctement). Il y a alors au plus 9 combinaisonsà tester, par exemple :

OS Protocole CPU BusVxWorks CAN PPC ConfortVxWorks Bluetooth ARM MécaVxWorks TTP Start12X Diag

QNX CAN Star12X MécaQNX Bluetooth PPC DiagQNX TTP ARM Confort

Linux RT CAN ARM DiagLinux RT Bluetooth Star12X ConfortLinux RT TTP PPC Méca

3.1.4 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 ellepermet d’obtenir une suite de tests dont la qualité en terme de niveau de couverturesoit 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éesest en effet une technique rudimentaire qui ne permet pas de prendre en compte lesdépendances entre les entrées. En outre, elle n’est adaptée que pour des cas où unoracle 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 detests aléatoires est facilement automatisable lorsque les entrées et leurs domaines devaleurs sont simples. Le tirage uniforme des valeurs se fait en utilisant un générateurde nombres aléatoires non-biaisé. Il est également possible d’implanter un tirage sui-vant des lois statistiques plus complexes que des tirages uniformes indépendants, et decombiner 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 testaléatoire dans une première phase n’a rien d’infamant et est à même de mettre à jourfacilement les défauts les plus grossiers. Il faut juste rester conscient du fait que du testaléatoire seul a bien peu de chances de permettre d’aboutir à une suite de tests ayant debonnes qualités de couverture, et qu’il faut presque toujours le compléter par d’autrestechniques.

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 surla notion d’automates finis (certains types de diagrammes UML, StateCharts etc.) Unetelle formalisation peut être exploitée pour sélectionner des cas de test, par exempleen cherchant à couvrir tous les chemins de contrôle jusqu’à une profondeur limite del’automate.

Générer des tests à partir d’un modèle (model-based testing) permet d’une part de levalider par rapport à une spécification, et d’autre part d’obtenir des cas de test pourtester l’implantation finale du point de vue « contrôle », les automates finis permettantdifficilement de représenter les données. Il peut paraître inutile de chercher à générerdes tests sur la base d’automates finis, puisqu’il existe des techniques de vérificationformelle de type model checking qui permettent de vérifier exhaustivement des pro-priétés temporelles sur de tels modèles. L’intérêt de générer des tests est de pouvoir lesexécuter sur l’implantation finale, alors que le model checking ne peut garantir que lacorrection du modèle et que son adéquation avec l’implantation doit être vérifiée pard’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 va-riables que l’on peut trouver dans les spécifications comme dans le code source. Ils sepré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èreque l’encodage des prédicats ou des expressions arithmétiques atomiques conduit à deserreurs 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. Parexemple, si le prédicat α = a < b ∧ c > d apparaît, on considère par exemple leprédicat β = a < b ∨ c > d, et on sélectionne un test dont la valeur de vérité estdiffé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 en-semble de causes (valeurs des entrées) provoquera quel ensemble d’effets (valeurs sor-ties). Il s’agit donc d’une modélisation graphique des relations de dépendance entre lesentrées (causes) et les sorties (effets) du système, qui se base sur les graphes élémen-taires suivants :

D’autres dépendances plus complexes peuvent également être représentées. L’intérêtprincipal de la démarche est qu’un diagramme causes-effets est un objet formel, à par-tir duquel on peut automatiquement générer une table de décision équivalente. Desheuristiques sont en général nécessaires pour limiter le nombre de colonnes de la tableet 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 codesource. L’idée principale est de sélectionner les cas de test sur la base des cheminsd’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émentspeuvent être issus soit du flot de contrôle, comme par exemple les blocs d’instructionsou 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 transformantpar exemple les prédicats correspondant aux conditions selon un modèle de fautes adhoc.

27

Sélection des Tests

Le choix des éléments à couvrir détermine donc un critère de couverture, et la suite detests s’évalue en fonction du taux de couverture du critère choisi, qui est le pourcentagedes éléments couverts parmi les éléments effectivement atteignables. La définition d’uncritère de couverture permet d’orienter naturellement la sélection des cas de tests, voirede contribuer à son automatisation pour des programmes de taille raisonnable : voir lechapitre 4.

3.2.1 Graphe de contrôle

Le graphe de contrôle (Control Flow Graph ou CFG) d’un programme est un graphefini et orienté représentant sa structure de contrôle. Intuitivement, il s’agit d’une abs-traction 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 programmetypique comporte un CFG par fonction, en distinguant le point d’entrée principal et enrepré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, modulole 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 passenécessairement à l’instruction suivante) ni des cibles de sauts (labels : le flot ne peutpas brancher sur cette instruction sans être passée par l’instruction précédente dans lebloc, sauf pour la première instruction du bloc), ni des instructions spéciales (voir ci-après). Il existe un nœud initial START (le point d’entrée du programme, typiquementla 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 placedans un contexte avec plusieurs procédures ou fonctions). Les sauts avant ou arrièresont représentés par un arc, les conditionnelles (if) par une paire d’arcs, et les instruc-tions de type switch/case généralement par plusieurs. Sur un branchement (choixentre deux arcs depuis un nœud) les arcs sortants sont étiquetés par des conditionsboolé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 autrenœ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 unefois. Il s’agit du critère de couverture le plus faible qui soit. La couverture des arcs visequant à 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 : BiUn bloc bl est dit inatteignable s’il n’existe aucune exécution du programme partant

28

STARTi n p u t ( i )sum := 0loop : i f ( i > 5 ) goto endi n p u t ( j )i f ( j < 0 ) goto endsum := sum + ji f ( sum > 100) goto endi := i +1goto l oopend : HALT

FIG. 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 faitl’hypothèse (cf. définition du CFG) que l’ensemble π des chemins de START à blest non vide. Si bl est inatteignable, alors tout chemin appartenant à π impose desconditions sur les données qui sont incompatibles, ce qui empêche toute exécutiond’atteindre bl. A titre d’exemple naïf, on peut considérer une instruction imbriquéedans 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 lamesure de couverture de blocs, puisqu’aucun test n’est susceptible de les couvrir. Leproblème de déterminer les blocs inatteignables est bien entendu en général indéci-dable. 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 : onatteint alors 100% de couverture des blocs atteignables. Un critère plus fort est d’exigeren plus qu’il n’y ait aucun bloc inatteignable (pas de code mort), ce qui correspond auniveau C de la norme DO178-B.

Le taux de couvertures des arcs (ou branches) se calcule de manière équivalente, la no-tion d’arc inatteignable (ou infaisable) se substituant à celle de bloc inatteignable. Il y acependant un point important qui a été omis dans la définition du CFG : quelles condi-tions (simples ou composées) accepte t’on pour étiqueter les arcs ? La figure suivantemontre qu’un programme peut être associé à des CFG très différents selon que l’onaccepte des conditions simples (atomiques), ou des conditions composées (expressionsbooléennes sur les conditions atomiques) :

29

Sélection des Tests

Les deux choix sont possibles, mais mesurer la couverture des arcs sur la base deconditions simples a un sens plus évident. Le traitement des conditions composéesest à 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}, quisollicite le chemin [abcd], permet de couvrir tous les blocs du CFG. Or le cas de testCT2{x = 0} permet de mettre à jour un défaut. Ceci illustre le fait que la satisfactiond’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 sommedes é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, parexemple 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 duprogramme, sans passer par une représentation de type CFG. Comme vu précédem-ment, une condition correspond à un prédicat présent dans le programme, et s’évaluedonc à vrai ou faux. Une condition est dite simple si elle est soit atomique, générale-ment du type expr1 ∼ expr2, où ∼ ∈ {<,≤,=, 6=, . . .} et expri est une expressionarithmétique, soit la négation d’une condition atomique. Une condition est dite com-posée (compound condition) s’il s’agit d’une expression booléenne sur au moins deuxconditions 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 duprogramme, choix qui s’effectue sur la base de l’évaluation d’une condition. Un if ouun while correspondent chacun à une décision, et une construction de type switchcomporte 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 decontrô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

sw i t ch ( exp r ) {

case 1 : /∗ d e c i s i o n 1 ∗ /

/∗ d e c i s i o n 1 :branche ’ v r a i ’ ∗ /f1 ( ) ;break ;

/∗ d e c i s i o n 1 :branche ’ f a u x ’ ∗ /

case 2 : /∗ d e c i s i o n 2 ∗ /

/∗ d e c i s i o n 2 :branche ’ v r a i ’ ∗ /f2 ( ) ;break ;

/∗ d e c i s i o n 2 :branche ’ f a u x ’ ∗ /

d e f a u l t :f d e f a u l t ( ) ;

}

FIG. 3.2 – Décisions : cas du switch

i f ( x == 0) /∗ d e c i s i o n ∗ //∗ branche ’ v r a i ’ ∗ /f t r u e ( ) ;

e l s e/∗ branche ’ f a u x ’ ∗ /f f a l s e ( ) ;

FIG. 3.3 – Décisions : cas du if

– pour un switch, qui comporte généralement plusieurs décisions, couvrir l’ensembledes cas possibles (cas par défaut compris).

On peut noter que même si un seul test peut potentiellement couvrir les deux destina-tions d’une décision à lui tout seul (cas des boucles par exemple), ce n’est nullementrequis, 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 basedes 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 alterna-tives. Une décision est infaisable ssi l’une des branches associée est infaisable (il estimpossible 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 celapeut ne pas être possible (cf. présence dans le programme d’instructions testant desdéfaillances matérielles).

Couverture des conditions

L’ensemble des conditions simples du programme est obtenu en considérant toutesles conditions (simples ou composées) du programme, qu’elles apparaissent dans lesstructures de contrôle, comme if, while, switch/case, ou dans des expres-sions, comme b = (x > 0)||(y > 0). Un prédicat x > 0 peut apparaître plusieurs foisdans le programme : chaque apparition correspond à une nouvelle instance de conditionsimple.

Une condition simple est couverte par une suite de tests si elle a été évaluée à vraie lorsde l’exécution d’un test t1, et à faux lors de l’exécution d’un test t2 : il est possible quet1 = t2 si le chemin d’exécution du test évalue la condition simple plus d’une fois. Lecritère de couverture « toutes les conditions » se réfère à la proportion des conditionssimples couvertes par une suite de de tests. Le taux de couverture T associé se calculede 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 lavaluation 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é liantles entrées du programme x, y (entiers relatifs) et la valeur de la sortie z :

x < 0 y < 0 Output(z)true true foo1(x,y)true false foo2(x,y)false true foo2(x,y)false false foo1(x,y)

On cherche à vérifier si l’implantation P1 suivante satisfait à la spécification ou non.

33

Sélection des Tests

i n t x , y , z ;i n p u t ( x , y ) ;i f ( x < 0 & y < 0)

z = foo1 ( x , y ) ;e l s e

z = foo2 ( x , y ) ;o u t p u t ( 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 = foo1(x, y), etpour t2 on obtient z = foo2(x, y) ce qui est correct par rapport à la spécification (res-pectivement première et deuxième ligne de la table de vérité). En revanche la suite detests n’est pas adéquate pour le critère de couverture des conditions, puisqu’on mesureT = 0.5 (la condition x < 0 n’est pas couverte, puisqu’elle s’évalue à vrai pour t1 ett2).

En revanche, le test t3 : (x, y) = (3, 4) permet de révéler un défaut dans l’implanta-tion : en effet P1 retourne z = foo2(x, y) alors que la quatrième ligne de la table devérité indique qu’il faut obtenir z = foo1(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 ». Latroisième ligne de la table de vérité de la spécification n’est couverte par aucun de cestests.

Exemple 2

Soit le programme P2 suivant :

i n t x , y , z ;i n p u t ( x , y ) ;i f ( x < 0 | y < 0) /∗ (&) d e v i e n t ( | ) ∗ /

z = foo1 ( x , y ) ;e l s e

z = foo2 ( x , y ) ;o u t p u t ( z ) ;

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 lecritère « toutes les conditions » : y < 0 restant toujours vrai.

S2 est adéquate pour le critère « toutes les conditions », mais pas pour « toutes lesdécisions » : l’un de x < 0 ou y < 0 étant toujours évalué à vrai, seule la premièrebranche de la décision est prise.

Ceci prouve que les critères « toutes les décisions » et « toutes les conditions » sontincomparables . Il est donc pertinent de mesurer en parallèle les taux de couverturecorrespondants, et possible de les combiner dans un taux de couverture global par laformule :

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 tousles 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 OUlogique, alors qu’en langage C il correspond en fait à un OU bit à bit. Les conversionsimplicites des conditions booléennes vers des entiers (0 pour une condition fausse, 1pour une condition vraie) impliquent leur équivalence sémantique, au moins dans lecadre 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émantiquede court-circuit : (a && b) n’évalue b que si a est vrai, alors que (a || b) n’évalue b quesi a est faux.

Les exemples présentés dans ce cours le sont sur la base d’opérateurs « sans court-circuit », mais peuvent se transposer aux opérateurs && et || à condition de noter qu’ilest parfois nécessaire, en particulier pour la couverture des condition, de fournir dessuites de tests différentes (généralement plus riches, puisque potentiellement moins deconditions sont évaluées).

Par exemple, sur la base d’une condition composée (x > 0) & (y > 0), la suite detests S = {(x, y) = (3, 4); (x, y) = (−3,−4)} est adéquate pour le critère « toutes lesconditions ». Mais S n’est pas adéquate pour ce critère lorsque la condition composéedevient (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 conditionl’ensemble des combinaisons de valeur de vérité des conditions simples la compo-sant. Pour une condition composée de k conditions simples, il faudra donc couvrirl’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 4combinaisons 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 étantcomposée de ki conditions simples, et si l’on note Ce l’ensemble des combinaisonspossibles, on a |Ce| =

∑ni=1 2ki . On note Cc l’ensemble des combinaisons couvertes

par la suite de tests, et Ci l’ensemble des combinaisons infaisables. Les combinaisonsinfaisables sont liées au fait que les conditions simples ne sont en général pas indépen-dantes : par exemple, savoir que (x = y) limite le nombre de combinaisons faisablespour 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|/(∑ni=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 statiquetrès précise du programme sous test. Sur-approximer Ci fait courir le risque de classercomme adéquate des suites de test ne couvrant en réalité pas toutes les combinaisonseffectivement faisables. Sous-approximer Ci (par exemple Ci = ∅, ce qui a l’avantagede n’imposer aucune analyse externe de la part de l’utilisateur) rend potentiellement ladétermination d’une suite de tests adéquate impossible. Il est donc clair que pour unprogramme complexe, et quelque soit le critère de couverture utilisé, obtenir une suitede 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 ex-ponentiel 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 combinatoiremanifeste 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 cri-tère de couverture appelé MC/DC (pour Modified Condition/Decision Coverage), quiaille au-delà de la couverture des décisions et des conditions simples, mais sans impo-ser 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 partieDC et la partie MC. La partie DC indique que le critère exige la couverture des déci-sions. La partie MC est la spécificité du critère et exige de démontrer par le test quechaque condition simple C de chaque condition composée CC influence de manièreindépendante l’évaluation de CC. Cette notion d’effet indépendant d’une conditionsimple s’exprime formellement de la manière suivante : il existe une valuation V desvaleurs de vérité des autres conditions simples constituant la condition composée CCpour 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 simpleC se fait en choisis-sant la valuation V (C0) = true, V (C1) = false. En effet, C = true implique alorsCC = true, alors que C = false implique CC = false. On peut en outre remar-quer 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étriqueW (C0) = true, W (C) = false. Quant à la preuve de MC pour la condition C0, onpeut 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 va-luations faisant la preuve du critère MC, certaines valuations pouvant en outre s’avérerinfaisables.

Exemples

Le choix des valuations pour le critère MC peut s’effectuer sur la base des tables devérité des conditions composées correspondantes, comme le montrent les exemplesqui suivent.

Pour la condition CC = C1 ∧ C2 ∧ C3 :

id test C1 C2 C3 C MC démontré pour part1 true true true truet2 true true false false C3 t1, t2t3 true false true false C2 t1, t3t4 false true truee false C1 t1, t4

Pour la condition CC = (C1 ∧ C2) ∨ C3 :

id test C1 C2 C3 C MC démontré pour part1 true false true truet2 true false false false C3 t1, t2t3 true true false true C2 t2, t3t4 false true false false C1 t3, t4

Infaisabilité

Le fait qu’il existe, pour un programme P , une suite de tests adéquate pour le critèreMC/DC implique que P ne contient pas de conditionnelles dont la construction estmaladroite. A titre d’illustration, prouver MC pour la condition composée suivante estimpossible :

(x > 0) ∧((x > −1) ∨ (y > 0)

)En effet, pour CC = C0 ∧ (C1 ∨ C2), la table de vérité est comme suit :

id test C0 C1 C2 C

* false any any falset1 true true true truet2 true true false truet3 true false true truet4 true false false 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 pourC0 = (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 syntaxiquementmaladroite car trop complexe pour ce qu’elle est censée réaliser. Ce type de construc-tions, qui sont des maladresses de programmation, peut tout à fait être un indicateurde 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 lesconditions ». Evidemment, la non-satisfaction du critère MC peut également avoir desorigines plus subtiles (sémantiques) que ce qui a été illustré. Penser par exemple à unecondition composée (x > 0) ∧ (y > 0) qui s’évalue toujours dans des contextesd’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 quirend la condition composée vraie, soit (y > 0) = false, et le condition composée serafausse indépendamment de la condition (x > 0).

Enfin, certaines conditions composées peuvent être écrites de telle façon qu’il n’est paspossible 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 sansmodifier la valeur de la seconde occurrence, ce qui empêche de prouver MC pour au-cune d’entre elles. Une possibilité est alors de réécrire la condition en la condition lo-giquement équivalent A ∧ (B ∨ C), qui ne présente pas le même défaut de structureet 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, ettaux 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), apparaissantou non dans les décisions. Une condition n’apparaît pas si elle fait partie d’une condi-tion 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 =∑ni=1 ei/

∑ni=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 lescodes critiques, et impose la fourniture d’une suite de tests adéquate pour le critère decouverture MC/DC. La suite de tests doit en outre avoir été sélectionnée à partir desspé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 testsfonctionnels couvrant ses exigences, et de vérifier via le taux de couverture MC/DCque le code le contient pas de fonctionnalités cachées ni de conditions complexes nonexercé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 contraintessur le test des boucles : on ne demande finalement guère plus que de couvrir le critèrepour le corps de boucle, et d’exercer la condition de sortie de la boucle.

A titre d’exemple, soit le programme suivant :

i n t f ( i n t e l t , i n t t a b [ 3 ] ) {i n t i ;i n t o l d = −1;f o r ( i = 0 ; i < 3 ; ++ i ) {

i f ( t a b [ i ] > e l t ) re turn o l d ;o l d = t a b [ i ] ;

}re turn o l d ;

}

FIG. 3.4 – Couverture des boucles : exemple

On suppose que la précondition de f exige que tab soit trié et composé d’élémentspositifs ou nuls. La spécification de la fonction est de retourner le plus grand élémentde tab qui soit inférieur ou égal à elt, s’il existe, et de retourner−1 si un tel élémentn’existe pas.

Pour la fonction f, une suite de tests couvrant toutes les décisions du programme devrajuste inclure :

– un cas de test pour lequel i atteint la valeur 3, ce qui exerce la condition de sortie dela boucle. Cela arrive exactement lorsque la condition tab[i] > elt dans le corps deboucle 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’unqui exécute le nombre maximal d’itérations, l’autre le nombre minimal (retour lors dupremier tour de boucle). Ceci correspond au cas où tab ne contient pas d’élémentsupérieur à elt, et au cas où tab ne contient que des éléments supérieurs à elt. Lacouverture est donc relativement faible au niveau fonctionnel, et il serait intéressantd’exercer un critère de couverture plus ambitieux que « toutes les décisions » sur cetexemple.

Ainsi, pour obtenir une couverture des boucles plus exigeante, il est intéressant dediffé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 comporte-ments 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 uncas où le corps de boucle est exercé plusieurs fois avant de retourner une valeur, sanspour 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érieurou égal à elt, et un élément strictement supérieur à elt. Ceci illustre le fait qu’exi-ger un critère de couverture structurelle plus fort au niveau du traitement des bouclespermet 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 bouclela plus extérieure sur les critères décrits dans le paragraphe précédent. Pour chaquenombre (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érentesboucles peuvent évidemment rendre certaines configurations infaisables.

Chemins

La couverture de l’ensemble des chemins structurels d’un programme est le critère leplus fort qui soit lorsqu’on se réfère au contrôle : chaque chemin (faisable) du graphede contrôle doit être couvert par un test.

Certains programmes comportant un nombre infini de chemins (boucles infinies), cecritère peut s’avérer irréalisable, les suites de test devant rester finies. Même en bornantle nombre de chemins, par exemple en limitant le nombre d’itérations des boucles, ilreste souvent intractable, le nombre de chemins potentiels à considérer pouvant êtreexponentiel 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érationautomatique de tests qui seront vues au chapitre 4 se basent sur une énumération d’unsous-ensemble des chemins structurels d’un programme, même lorsqu’elles visent uncritè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 programmedécrit les dépendances entre les variables d’un programme : la présence de pointeurset de références complique notoirement le calcul des dépendances de données, on n’enparlera pas dans ce cours.

Les nœuds du DFG sont les instructions du programme, sans regroupement en blocscomme pour le CFG. On suppose que le DFG est associé à un CFG ayant les mêmesnœuds (pas de blocs), ce qui permet de décrire les chemins de contrôle et en particulierles 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 var′ ← expr où expr contient var. On parle aussi de c-utilisation pour « utilisationdans un calcul ».

Un nœud du CFG p-utilise une variable var s’il correspond à une instruction de typedé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 telchemin de contrôle est appelé chemin de c-utilisation. Pour une p-utilisation, un telchemin de contrôle se prolonge en deux chemins de p-utilisation, un pour chacunedes deux branches associées à la décision.

La figure 3.5 contient le DFG du programme suivant :

i n p u t ( x , y ) ;

i f ( y > 0)x = 1 ;

/∗ e l s e { } ∗ /

o u t p u t ( 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 p-utilisation 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

FIG. 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, aumoins un des chemins de c-utilisation associé. La couverture des p-utilisations exigede couvrir, pour chaque arc de p-utilisation, au moins un chemin de p-utilisation pourchacune des deux branches associées à la décision. Chaque branche peut être sollicitéevia 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 couverturedes décisions, et la couverture des c-utilisations est plus forte que la couverture desblocs (sauf cas pathologiques, cf. instructions skip). Une c-utilisation est infaisablesi l’ensemble des chemins de c-utilisations sont infaisables, et une p-utilisation estinfaisable si, pour au moins l’une des décisions associée, l’ensemble des chemins dep-utilisations sont infaisables.

Les taux de couverture se calculent alors classiquement :

– CUe (resp. PUe) : ensemble des c-utilisations (resp. p-utilisations) du programme,– CUc (resp. PUc) : ensemble des c-utilisations (resp. p-utilisations) couvertes par la

suite de tests,

42

– CUi (resp. PUi) : ensemble des c-utilisations (resp. p-utilisations) démontrées infai-sables.

TCUse = |CUc|/(|CUe| − |CUi|)

TPUse = |PUc|/(|PUe| − |PUi|)

TUse = (|CUc|+ |PUc|)/((|CUe|+ |PUe|)− (|CUi|+ |PUi|)

)3.2.7 Couverture des mutants

Pour les gens intéressés, voir le chapitre 7 de [2] qui présente la mutation de pro-grammes 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’uti-liser une combinaison des méthodes de sélection des tests pour arriver à ses fins, suivantleur 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 couver-ture, 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 fonc-

tion 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 maximumde défauts ;

– sélectionner des tests assurant un maximum de couverture fonctionnelle, et effectuerdu test aux limites ;

– pour du débogage fin : sélectionner les tests sur la base d’un critère structurel abor-dable, sur les parties du programme non encore couvertes.

43

44

Chapitre 4

Génération Automatique deTests

Ce chapitre traite des techniques permettant de générer automatiquement des cas detest, c’est à dire d’écrire des programmes qui prennent en entrée un programme et unobjectif de test, et fournissent en sortie un ensemble de cas de tests couvrant tout oupartie de l’objectif de test. Il faut bien différencier d’une part la génération automatiquede tests et d’autre part l’automatisation du processus de test, qui consiste à automatiserl’exécution et l’évaluation des cas de test (vérification des oracles, mesure de couver-ture) et non à faciliter leur sélection.

4.1 Panorama des techniques de génération

4.1.1 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éa-toire des valeurs dans le domaine des entrées. L’avantage de cette technique est larapidité de la génération des valeurs et la simplicité de l’implantation (au moins pourun tirage uniforme, et pour des domaines d’entrée simples). Cette technique se focaliseprincipalement sur l’identification du domaine des entrées du programme sous tests.

Pour un programme sous test ayant un précondition complexe, induisant des contraintesrelationnelles fortes entre les entrées, un tirage aléatoire naïf aura toutes les chances dene 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 plusfacile de vérifier que des entrées satisfont à une précondition que de les générer. Dansd’autres cas, il est souvent nécessaire de diriger la génération aléatoire de manièreprogrammatique, en prenant en compte certaines relations entre les entrées. Le casextrême est d’utiliser un solveur de contraintes pour obtenir des solutions à la formulereprésentée par la précondition, mais on ne peut plus alors parler de technique purementalé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 fonction-nel. Pour un objectif de détection de défauts, il est naturel de compléter la générationaléatoire en insistant sur les valeurs aux limites, ce qui est à nouveau plus ou moinsfacile à 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éa-toire 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 entiersstrictement positifs : le choix des valeurs de s1, s2 peut se faire de manière uniforme etindé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 derelation 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 taillesrequises : 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éderest 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 sup-plé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 plusgrands que tous les éléments de T2 » demandent un plus gros effort de programmationde la génération aléatoire.

Exemple 2

Considérons le programme suivant :

t y p e d e f s t r u c t c e l l {i n t v ;s t r u c t c e l l ∗ n e x t ;

} c e l l ;

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 )re turn 1 ;

re turn 0 ;}

La fonction testme a une précondition implicite, qui est que soit p vaut NULL, soitp est valide, c’est à dire qu’il pointe vers une structure de type cell correctementallouée (ayant un champ next valide ou égal à NULL). Les données de test à générerdevront donc satisfaire à cette précondition, ce qui exige d’être capable d’allouer desdonné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éatoiresne convient pas : penser au cas où le champ next serait non NULL mais pointerait versune 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 dela spécification du programme, l’objectif étant de couvrir un maximum des compor-tements fonctionnels du programme sous test (on parle aussi de génération de testsfonctionnels). L’automatisation de cette technique nécessite de disposer de spécifica-tions formelles, c’est à dire compréhensibles par le programme en charge de générerles tests. Il peut s’agir par exemple de spécifications sous forme de prédicats, de mo-dèles à base d’automates finis (on parle de model-based testing) ou de diagrammescauses-effet. Les techniques manuelles (souvent « structurelles ») évoquées au para-graphe 3.1.5 pour chaque type de spécification peuvent être systématisées et automati-sées. On ne donnera pas plus de détails dans ce cours, les personnes intéressées peuventconsulter 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 tech-nique de génération se prête naturellement à l’automatisation, puisque le programmeest un objet formel destiné à être compilé (ou interprété) et exécuté : cela par contrasteavec les spécifications formelles, qui peuvent très bien ne pas être exécutables. Defait, les techniques de génération en boîte blanche procèdent par exécution symboliquedu programme sous test, généralement chemin par chemin, avec un objectif structurelglobal de couverture des instructions, branches, ou de tout autre critère vu à la sec-tion 3.2. La principale difficulté de la génération en boîte blanche est la problématiquedu passage à l’échelle : même un programme de quelques dizaines de lignes peut com-porter un nombre de chemins considérable (typiquement exponentiel en le nombre dedécisions successives traversées), qu’il faudra potentiellement tous exécuter symboli-quement et résoudre pour générer les tests. D’où l’intérêt de guider la génération enfonction 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 del’oracle se posant alors très rapidement, mais a un intérêt considérable pour un objectifde détection de défauts.

4.2 Techniques de génération automatique de tests struc-turels

Dans ce qui suit, on suppose que le langage de programmation servant à écrire le codesource est un langage jouet sans pointeurs ni références, sans appels de fonctions niexceptions, 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 demodélisation de la mémoire (validité des pointeurs, présence d’alias), et les exceptionsintroduisent 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érerles 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 dunœud initial jusqu’à un nœud final), soit préfixe d’un chemin complet. On suppose parailleurs avoir identifié l’ensemble des entrées du programme sous forme d’un vecteurV 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 siune valuation V0 de V satisfait ϕπ , alors l’exécution du programme sur la donnée detest associée à V0 suit le chemin π.

Noter qu’on parle du prédicat d’un chemin π comme d’un objet unique, bien qu’ils’agisse en fait d’une classe d’équivalence sur un ensemble de formules. La définitionrepose 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, etune 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 π estexécuté sur la base de la solution à ϕπ trouvée, alors il y a un problème soit au niveaude 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 i n p u t ( y , z ) ;2 y ++;3 x = y + 3 ;4 i f ( x < 2 ∗ z )5 i f ( x < z )6 re turn 0 ;7 e l s e re turn 1 ;8 e l s e re turn −1;

Supposons pour fixer les idées que l’appel à input(x,y) initialise y à la valeurY0 = 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’instructionsuivante incrémente x qui prend la valeur X0 = Y1 + 3 = 9. On remarque que chaquemodification 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à asso-cié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 pro-gramme é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 avoirsuivi le chemin π = 1− 2− 3− 4true − 5false − 7.

Pour calculer le prédicat associé à π, on se base sur une exécution symbolique de π quipermet 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’ilsoit besoin de les préciser. Le prédicat de chemin associé ϕ1 est « vide » et formelle-ment équivalent à true. L’instruction suivante introduit une nouvelle variable logiqueY1 représentant la nouvelle valeur courante de y, et le prédicat ϕ1−2 est construit enassociant cette nouvelle valeur à la précédente par la formule Y1 = Y0 + 1 (ce quicorrespond à 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− 3s’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 conditionassocié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 : lepré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 π (ins-truction 7) ne modifie aucune des variables et ne joue aucun rôle du point de vuede la génération du prédicat, au moins dans un contexte de test unitaire où la valeurretournée n’est pas utilisée. On vérifie que ϕπ est en outre équivalente à la formuleZ0 ≤ Y0 + 4 < 2 ∗ Z0 (projection de la formule sur les entrées) et que la valuationY0 = 5, Z0 = 6 satisfait bien la formule.

La table suivante résume l’exemple qu’on vient de traiter. L’évolution de la mémoirelors de l’exécution est modélisée par le fait d’associer à chaque variable du programmedéjà initialisée la variable logique qui lui correspond. La simplicité de ce modèle de lamémoire est liée au fait que l’on ne traite pas ici la notion de pointeur ou de référence.

Ligne Instruction Exécution concrète Exécution symbolique Association des variables

1 input(y,z) Y0=5, Z0=6 y → Y0, z → Z0

2 y++ Y1=6 Y1 = Y0 + 1 y → Y1

3 x = y + 3 X0=9 X0 = Y1 + 3 x→ X0

4true if (x < 2 * z) condition vraie (X0 < 2 ∗ Z0)5false if (x < z) condition fausse (X0 ≥ Z0)

7 return 1

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 permet-tant d’activer ce chemin. Il suffit en principe d’énumérer tous les chemins du graphe decontrôle, de générer les prédicats associés et de les résoudre s’ils sont faisables pour ob-tenir une suite de tests adéquate pour le critère « couverture de tous les chemins »(aveccomme bonus des démonstrations d’infaisabilité obtenues de manière automatique). Leprocessus général de génération de tests est le suivant :

1 Sélectionner un chemin π du CFG non encore traité ;2 Calculer le prédicat du chemin ϕπ par exécution symbolique ;3 Trouver une solution à ϕπ ou démontrer qu’il n’en existe pas ;4 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 ceprocessus : la technique d’énumération des chemins, le choix de la théorie dans laquelleles 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 symbo-lique est la technique d’énumération des chemins.

Comme vu précédemment, le critère de couverture « tous les chemins » est le plus puis-sant critère de couverture structurelle. Par conséquent il s’avère souvent intractable surdes programmes non triviaux, du fait du nombre potentiellement énorme de cheminsdu CFG.

Une première possibilité est de ne sélectionner que des chemins ayant certaines ca-racté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 etpeut 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 pro-fondeur d’abord (Depth-first search ou DFS) présente un grand nombre d’avantages enterme d’efficacité mémoire, mais d’autres parcours sont tout à fait envisageables. Leparcours DFS s’organise de la manière suivante : un premier chemin est parcouru, avecmé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 empi-lée correspond en fait à deux préfixes (un par branche), l’un correspondant au chemincourant, 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 alorsun retour en arrière (ou backtrack) en dépilant la décision et le contexte se trouvant ausommet 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é enprédicat et résolu. Si aucune solution n’existe, le backtrack se poursuit sur un préfixeplus petit. Si une solution existe, le chemin correspondant est exploré complètement enempilant 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 cheminsfaisables, 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 cri-tè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 desbranches 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 fai-sables sont couvertes (relaxation du critère d’arrêt), et il n’est pas judicieux de choisirun 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 premierordre 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édi-cats élémentaires (un prédicat par instruction, et une conjonction sur l’ensemble desprédicats associés aux instructions du chemin). Ce fragment restreint reste cependantsuffisamment expressif pour que les procédures de décision permettant de décider lecaractère satisfiable (la formule a t’elle une solution ou non ?) aient des complexitésélevées (NP -complet en l’occurrence).

Il peut être avantageux voire nécessaire d’interpréter ces formules dans une théoriemoins 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éso-lue plus facilement. A titre d’exemple, une formule arithmétique générale comportantd’é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 alorsré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 cettevé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) ousur des entiers représentables en machine (les opérations s’effectuant alors modulo uneconstante) ? La sémantique choisie peut s’éloigner de la sémantique effectivement im-planté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 solutionsdans 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 relativementancienne [3], mais son automatisation notamment pour des programmes C est beau-coup 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 pro-blè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 deparvenir à récupérer par ce biais les prédicats de chemin. Utiliser les formats internesd’un compilateur comme gcc s’avère ardu, et en pratique les outils actuels reposenttous sur le parseur CIL 1 qui date du début des années 2000. Outre le fait de disposerd’un parseur, la présence de pointeurs et de transtypage dans le langage C compliquentconsidérablement le modèle mémoire.

D’autre part, l’étape 3 du processus de génération nécessite pour être automatisée dedisposer d’un solveur automatique prenant en entrée une formule logique et répondantselon le cas :

– la formule est insatisfaisable (pas de solution), ce qui correspond à un chemin infai-sable si la formule correspond à un prédicat de chemin ;

– la formule est satisfaisable : dans ce cas, il est nécessaire que le solveur puisse enoutre fournir une solution explicite pour une utilisation dans un cadre de générationde tests ;

– autres cas : le solveur échoue sur une erreur interne (typiquement explosion desbesoins en terme de mémoire), ou le générateur de tests s’impatiente (réponse troplongue à venir). On ne peut alors ni décider si le chemin est faisable, ni s’il estinfaisable.

Des solveurs efficaces comme Simplify 2 pour des théories relativement générales nesont disponibles que depuis le début des années 2000. Il existe des solveurs très ef-ficaces bien antérieurs à cela mais pour des théories moins puissantes, basés sur laprogrammation linéaire 3 par exemple. L’exécution concolique (voir section 4.2.4) ap-porte également des pistes pour le cas où l’on souhaite se baser sur une théorie moinsgé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 etde retourner le plus grand d’entre eux. On se base sur l’implantation de max3 qui setrouve à 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 profon-deur (DFS) du CFG de max3, en privilégiant au niveau de chaque décision la branchepour 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émo-risant les prédicats des préfixes parcourus dans une pile. Le fait de pouvoir utiliserune pile plutôt qu’une structure plus compliquée (typiquement un arbre de préfixes dechemins) 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 no-tées I, J,K respectivement. La première décision rencontrée k >= i correspond auprédicat (I >= J) où I, J sont les variables logiques associées aux valeurs initialesdes paramètres i, j de la fonction. Le contexte à empiler correspond normalement au

1http://cil.sourceforge.net/2http://www.hpl.hp.com/techreports/2003/HPL-2003-148.html3http://sourceforge.net/projects/lpsolve

52

1 i n t max3 ( i n t i , i n t j , i n t k ) {2 i f ( i >= j )3 i f ( k >= i )4 re turn k ;5 e l s e6 re turn i ;7 e l s e8 i f ( k >= j )9 re turn k ;

10 e l s e11 i f ( i > j )12 re turn i ;13 e l s e14 re turn j ;15 }

FIG. 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 diversesexé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 descontextes. Le contexte peut donc être vu comme une paire associant une branche etun 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 depile :

3true K >= I2true 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. Lavaluation I = J = K = 0 est solution du prédicat et on vérifie bien que le che-min π1 est exécuté sur ce cas de test. On peut aussi vérifier que la fonction retournebien le maximum des trois valeurs en entrée, mais il s’agit d’un problème annexe à latechnique 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 based’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 pasencore été couverte (du fait de la priorité donnée à la branche vraie dans notre parcoursDFS) : le nouveau chemin est donc

π2 = 1− 2true − 3false − 5− 6

La pile des contextes devient :

3false K < I2true I >= J

Le chemin étant complet, il reste à résoudre le prédicat (K < I) ∧ (I >= J) parexemple 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 leparcours DFS. Le sommet de pile contient cette fois une branche correspondant à unecondition fausse : comme on sait que cette branche n’a pu être considérée qu’après labranche « vraie », il ne reste plus rien à explorer au niveau de cette décision pour lechemin préfixe courant. Le contexte se trouvant au sommet de la pile est donc retiré, etle contexte suivant est considéré : comme il correspond à la condition vraie 2true, onexplore en DFS le suffixe correspondant à sa condition niée 2false, ce qui aboutit auchemin complet

π3 = 1− 2false − 7− 8true − 9

La pile devient :

8true K >= J2false 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 I > J8false K < J2false I < J

54

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 estinfaisable, et par la même occasion que la branche 11true est infaisable (de même quel’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 I <= J8false K < J2false 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’enrend facilement compte en constatant que la pile ne contient plus que des branchementscorrespondant à des conditions fausses, le backtrack aboutirait donc à une pile vide cequi indique la fin du parcours DFS. On a bien considéré au total les 5 chemins du CFGde 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 pourpallier certains défauts de l’exécution symbolique « pure ». L’idée est que l’exécutionconcrète classique permet de sélectionner des chemins faisables de manière plus di-recte, d’obtenir des informations quant aux valeurs des variables permettant de mieuxpréciser le modèle mémoire (cf. relations d’alias), et d’abstraire des appels à des fonc-tions de bibliothèques ne pouvant être traités symboliquement (code source indispo-nible, écrit dans un langage différent, ou encore non instrumentable) tout en calculanttout de même une valeur cohérente. La technique générale est d’effectuer une exécu-tion concrète, par exemple en choisissant des valeurs au hasard pour les entrées, ce quipermet 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 à lafigure 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 i n t f ( i n t x1 , i n t x2 , i n t x3 ) {2 i f ( x1 > 0) {3 i f ( x1 == x2 ) {4 i f ( x2 < 0)5 i f ( x3 > 0)6 re turn 1 ;7 e l s e re turn 2 ;8 e l s e re turn 3 ;9 e l s e re turn 4 ;

10 } e l s e re turn 5 ;11 }

FIG. 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 cor-respond 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 toujoursprendre la branche vraie d’une décision avant la branche fausse. Ce n’est pas le casici : on stocke donc dans la pile un élément supplémentaire indiquant quelles branchesont déjà été parcourues (ici, la branche false). La pile correspondant à π1 a donc laforme :

2false X1 <= 0 false

Il est a priori inutile de résoudre le prédicat de chemin courant, puisqu’on dispose ducas de test initial. La phase de backtrack servant à obtenir un nouveau chemin (l’ob-jectif de test n’étant pas couvert) va considérer le sommet de pile et constater que labranche true n’a pas encore été couverte. L’exécution symbolique pousuivrait alorsl’exploration DFS en prolongeant le préfixe 1 − 2true obtenu. Mais pour une exécu-tion 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 dele 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 ducas de test obtenu : on obtient le chemin

π2 = 1− 2true − 3false − 9

et la pile devient

3false (X1 <> X2) false2true X1 > 0 true,false

A nouveau inutile de résoudre le prédicat de chemin total obtenu, puisqu’on est partid’une de ses solutions pour l’obtenir. La sélection du chemin suivant suit le mêmeprincipe : 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écu-tion concrète fournit le chemin complet associé à ces entrées

π3 = 1− 2true − 3true − 4false − 8

avec comme pile

4false (X2 >= 0) false3true (X1 = X2) true,false2true X1 > 0 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 pilevide, et donc à la fin de l’exploration, puisque les contextes qui restent en pile ontvu 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 structurelsdont 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, etque 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 titred’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’unedé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 premiersprédicats ne peut être satisfaite. Le parcours DFS effectuera donc un backtrack surla dernière branche non encore traitée, ce qui correspond à nier ψ100 et à poursuirel’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’échouerontla résolution de tous les prédicats qui suivront, jusqu’au point où l’exploration DFSaura 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 detest puisque tous les prédicats générés seront insatisfaisables (voir une illustration à lafigure 4.3).

FIG. 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 dene pas commencer l’exploration par un chemin infaisable : cette dernière solution estfacile à 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 (enconformité avec la précondition).

Les apports de l’exécution concolique ne se limitent cependant pas au choix du chemininitial. Comme on l’a vu précédemment, l’exécution concolique effectue un backtracksur un préfixe non encore exploré qui, s’il est satisfaisable, permet d’obtenir un casde test qui est immédiatement exécuté pour obtenir un chemin complet et par défini-tion faisable. L’exécution concolique permet donc à tout moment de travailler sur labase d’un chemin faisable, et évite de s’embarquer dans l’exploration de sous-arbresinfaisables.

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 pro-gramme 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’indicesde tableaux potentiellement égaux par exemple.

i n t t a b [ 1 0 ] ;i n t 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 pas en a l i a s ∗ /

i =5 ;j = i ; /∗ t a b [ i ] e t t a b [ j ] s o n t en a l i a s ∗ /j =6 ; /∗ t a b [ i } e t t a b [ j ] ne s o n t pas en a l i a s ∗ /

Supposons que l’on cherche à effectuer l’exécution symbolique des chemins de la fonc-tion 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 ) re turn 1 ;e l s e re turn 2 ;

}

Une manière naïve serait de considérer que *p1 et *p2 peuvent être considérés commedes 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’ins-truction return 1 est infaisable. Or il est tout à fait possible d’appeler falias avecp1 et p2 en alias, comme dans le programme suivant :

i n t main ( ) {i n t i = 3 ;i n t ∗ p = &i ;re turn 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 niveaude 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 aliasou 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 solveurspermettent de manipuler des théories efficaces pour ce type de problématique, maisl’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, puisqueles 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 valableque pour la relation d’alias effectivement explorée : il ne sera par exemple pas possibled’inférer l’infaisabilité d’une branche sur cette seule base. Il reste cependant toujoursla 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 utilisantdes 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é ex-terne utilisée, qui permet de faire le lien (sous forme de prédicat) entre les paramètresd’appel et la sortie attendue. L’appel à la fonction externe peut donc être abstrait à l’aidedu 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 tropcoûteuse à obtenir, ou s’avère trop complexe. Fournir une valeur par défaut intéressanten’est pas toujours possible (penser à une requête dans une base de données), de mêmeque 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 va-leur concrète qui soit compatible avec les autres valeurs concrètes utilisées commeparamètres, sans nécessiter de connaissance particulière sur le code appelé. L’exécu-tion et la génération de tests peut se poursuivre avec une valeur faisant sens, ce qui peutpermettre d’explorer davantage de comportements pertinents qu’une valeur par défautou purement symbolique.

4.3 Conclusion

La génération automatique de tests structurels connaît un regain d’intérêt considérabledepuis quelques années (2005 environ), et des outils commencent à être diffusés assezlargement hors de la communauté académique (outil Pex 4 de Microsoft Research). Lestechniques mises en œuvre sont à la confluence de plusieurs domaines de la vérificationde logiciel, comme l’interprétation abstraite, le bounded model checking et la preuveautomatique 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].

4http://research.microsoft.com/en-us/projects/Pex

60

Bibliographie

[1] K. R. Apt and M. Wallace. Constraint Logic Programming using Eclipse. Cam-bridge 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 theACM, 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 Genera-tion of Path Tests by Combining Static and Dynamic Analysis. Proc. DependableComputing - 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 ofHigh-Coverage Tests for Complex Systems Programs. Proc. of OSDI’2008.

61