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