Si chaque chemin à travers un programme est testé, cela garantit-il de trouver tous les bogues?
Sinon, pourquoi pas? Comment pourriez-vous parcourir toutes les combinaisons possibles de flux de programme et ne pas trouver le problème s'il en existe un?
J'hésite à suggérer que "tous les bogues" peuvent être trouvés, mais c'est peut-être parce que la couverture des chemins n'est pas pratique (car elle est combinatoire) et n'est donc jamais expérimentée?
Remarque: cet article donne un bref résumé des types de couverture que j'y pense.
Si chaque chemin à travers un programme est testé, cela garantit-il de trouver tous les bogues?
Non
Sinon, pourquoi pas? Comment pourriez-vous parcourir toutes les combinaisons possibles de flux de programme et ne pas trouver le problème s'il en existe un?
Parce que même si vous testez tous les chemins possibles , vous ne les avez toujours pas testés avec toutes les valeurs possibles ou toutes les combinaisons possibles de valeurs . Par exemple (pseudocode):
def Add(x as Int32, y as Int32) as Int32:
return x + y
Test.Assert(Add(2, 2) == 4) //100% test coverage
Add(MAXINT, 5) //Throws an exception, despite 100% test coverage
Cela fait maintenant deux décennies qu'il a été souligné que les tests de programme peuvent démontrer de manière convaincante la présence de bogues, mais ne peuvent jamais démontrer leur absence. Après avoir bien cité cela -déclaration pieuse, l'ingénieur logiciel revient à l'ordre du jour et continue d'affiner ses stratégies d'essais, tout comme l'alchimiste d'antan, qui a continué d'affiner ses purifications chrysocosmiques.
- E. W. Dijkstra (Je souligne. Écrit en 1988. Cela fait bien plus de 2 décennies maintenant.)
En plus de réponse de Mason , il y a aussi un autre problème: la couverture ne vous dit pas quel code a été testé, elle vous dit quel code a été exécuté .
Imaginez que vous ayez une suite de tests avec une couverture de chemin à 100%. Maintenant, supprimez toutes les assertions et réexécutez la suite de tests. Voilà, la suite de tests a toujours une couverture de chemin à 100%, mais elle ne teste absolument rien.
Voici un exemple plus simple pour arrondir les choses. Considérez l'algorithme de tri suivant (en Java):
int[] sort(int[] x) { return new int[] { x[0] }; }
Maintenant, testons:
sort(new int[] { 0xCAFEBABE });
Maintenant, considérez que (A) cet appel particulier à sort
renvoie le résultat correct, (B) tous les chemins de code ont été couverts par ce test.
Mais, évidemment, le programme ne trie pas réellement.
Il s'ensuit que la couverture de tous les chemins de code n'est pas suffisante pour garantir que le programme n'a pas de bogues.
Considérez la fonction abs
, qui renvoie la valeur absolue d'un nombre. Voici un test (Python, imaginez un framework de test):
def test_abs_of_neg_number_returns_positive():
assert abs(-3) == 3
Cette implémentation est correcte, mais elle n'obtient qu'une couverture de code de 60%:
def abs(x):
if x < 0:
return -x
else:
return x
Cette implémentation est incorrecte, mais elle obtient une couverture de code à 100%:
def abs(x):
return -x
Il ressort clairement des autres réponses que la couverture de code à 100% dans les tests ne signifie pas l'exactitude du code à 100%, ni même que tous les bogues qui pourraient être détectés par les tests seront trouvés (sans parler des bogues qu'aucun test ne pouvait détecter).
Une autre façon de répondre à cette question est une pratique:
Il existe, dans le monde réel, et même sur votre propre ordinateur, de nombreux logiciels qui sont développés à l'aide d'un ensemble de tests qui offrent une couverture à 100% et qui ont encore des bogues, y compris des bogues qu'un meilleur test identifierait.
Une question impliquée est donc:
Quel est l'intérêt des outils de couverture de code?
Les outils de couverture de code aident à identifier les domaines que l'on a négligé de tester. Cela peut être correct (le code est manifestement correct même sans test), il peut être impossible à résoudre (pour une raison quelconque, un chemin ne peut pas être atteint), ou il peut être l'emplacement d'un grand bogue puant, maintenant ou à la suite de modifications futures.
À certains égards, la vérification orthographique est comparable: quelque chose peut "passer" la vérification orthographique et être mal orthographié de manière à correspondre à un mot du dictionnaire. Ou il peut "échouer" car les mots corrects ne sont pas dans le dictionnaire. Ou cela peut passer et être complètement insensé. La vérification orthographique est un outil qui vous aide à identifier les endroits que vous avez peut-être manqués dans votre relecture, mais tout comme il ne peut pas garantir une relecture complète et correcte, la couverture du code ne peut pas garantir des tests complets et corrects.
Et bien sûr, la mauvaise façon d'utiliser la vérification orthographique est réputée pour accompagner chaque suggestion de brebis que cela suggère, de sorte que la chose esquive s'aggrave alors si la brebis lui a laissé un prêt.
Avec la couverture du code, il peut être tentant, surtout si vous avez un taux presque parfait de 98%, de remplir les cas afin que les chemins restants soient atteints.
Cela équivaut à redresser avec la vérification orthographique coudre que ce sont tous les mots météo ou nouer, ce sont tous les mots appropriés. Le résultat est un gâchis esquivant.
Cependant, si vous considérez les tests dont les chemins non couverts ont vraiment besoin, l'outil de couverture de code aura fait son travail; pas en vous promettant l'exactitude, mais en soulignant certains du travail qui devait être fait.
Encore un autre ajout à réponse de Mason , le comportement d'un programme peut dépendre de l'environnement d'exécution.
Le code suivant contient un Use-After-Free:
int main(void)
{
int* a = malloc(sizeof(a));
int* b = a;
*a = 0;
free(a);
*b = 12; /* UAF */
return 0;
}
Ce code est un comportement indéfini, selon la configuration (version | débogage), le système d'exploitation et le compilateur, il produira des comportements différents. Non seulement la couverture de chemin ne garantit pas que vous trouverez l'UAF, mais votre suite de tests ne couvrira généralement pas les différents comportements possibles de l'UAF qui dépendent de la configuration.
Sur une autre note, même si la couverture des chemins devait garantir la recherche de tous les bogues, il est peu probable que cela puisse être réalisé en pratique sur n'importe quel programme. Considérez le suivant:
int main(int a, int b)
{
if (a != b) {
if (cryptohash(a) == cryptohash(b)) {
return ERROR;
}
}
return 0;
}
Si votre suite de tests peut générer tous les chemins pour cela, alors félicitations, vous êtes un cryptographe.
Une partie du problème est que la couverture à 100% ne garantit que le code fonctionnera correctement après un exécution unique. Certains bogues comme les fuites de mémoire peuvent ne pas être apparents ou causer des problèmes après une seule exécution, mais au fil du temps, cela entraînera des problèmes pour l'application.
Par exemple, supposons que vous ayez une application qui se connecte à une base de données. Peut-être que dans une méthode, le programmeur oublie de fermer la connexion à la base de données lorsqu'ils ont terminé leur requête. Vous pouvez exécuter plusieurs tests sur cette méthode et ne trouver aucune erreur avec sa fonctionnalité, mais votre serveur de base de données peut s'exécuter dans un scénario où il est hors des connexions disponibles car cette méthode particulière n'a pas fermé la connexion lorsqu'elle a été effectuée et les connexions ouvertes doivent maintenant timeout.
La couverture du chemin ne peut pas vous dire si toutes les fonctionnalités requises ont été mises en œuvre. L'omission d'une fonctionnalité est un bug, mais la couverture du chemin ne la détectera pas.
Si chaque chemin à travers un programme est testé, cela garantit-il de trouver tous les bogues?
Comme déjà dit, la réponse est NON.
Sinon, pourquoi pas?
Outre ce qui est dit, il y a des bogues apparaissant à différents niveaux, qui ne peuvent pas être testés avec des tests unitaires. Pour n'en citer que quelques-uns:
Les autres réponses sont excellentes, mais je veux juste ajouter que la condition "chaque chemin à travers un programme est testé" est elle-même vague.
Considérez cette méthode:
def add(num1, num2)
foo = "bar" # useless statement
$global += 1 # side effect
num1 + num2 # actual work
end
Si vous écrivez un test qui affirme add(1, 2) == 3
, un outil de couverture de code vous dira que chaque ligne est exercée. Mais vous n'avez en fait rien affirmé sur l'effet secondaire global ou la cession inutile. Ces lignes ont été exécutées, mais n'ont pas vraiment été testées.
Les tests de mutation aideraient à trouver des problèmes comme celui-ci. Un outil de test de mutation aurait une liste de façons prédéterminées de "muter" le code et de voir si les tests réussissent toujours. Par exemple:
+=
En -=
. Cette mutation n'entraînerait pas d'échec du test, donc cela prouverait que votre test n'affirme rien de significatif sur l'effet secondaire global.En résumé, les tests de mutation sont un moyen de tester vos tests. Mais tout comme vous ne testerez jamais la fonction réelle avec tous les ensembles d'entrées possibles, vous n'exécuterez jamais toutes les mutations possibles, donc encore une fois, cela est limité.
Chaque test que nous pouvons faire est une heuristique pour passer à des programmes sans bogues. Rien n'est parfait.
Eh bien ... oui en fait, si chaque chemin "à travers" le programme est testé. Mais cela signifie que chaque chemin possible à travers l'espace entier de tous les états possibles que le programme peut avoir, y compris toutes les variables. Même pour un programme très simple compilé statiquement - disons, un ancien correcteur de nombres Fortran - ce n'est pas faisable, bien que cela puisse au moins être imaginable: si vous n'avez que deux variables entières, vous avez essentiellement affaire à toutes les façons possibles de connecter des points sur une grille bidimensionnelle; cela ressemble en fait beaucoup à Travelling Salesman. Pour n ces variables, vous avez affaire à une dimension n - dimensionnelle l'espace, donc pour tout programme réel, la tâche est complètement intraitable.
Pire: pour les choses sérieuses, vous avez pas juste un nombre fixe de variables primitives, mais créez des variables à la volée dans les appels de fonction, ou avez une taille variable variables ... ou quelque chose comme ça, autant que possible dans un langage Turing-complet. Cela rend l'espace d'état de dimension infinie, brisant tous les espoirs d'une couverture complète, même avec un équipement de test absurdement puissant.
Cela dit ... en fait, les choses ne sont pas si sombres. Il est possible de prouver que des programmes entiers sont corrects, mais vous ' Je vais devoir abandonner quelques idées.
Premièrement: il est fortement conseillé de passer à une langue déclarative. Les langages impératifs, pour une raison quelconque, ont toujours été de loin les plus populaires, mais la façon dont ils mélangent les algorithmes avec les interactions du monde réel rend extrêmement difficile même de dire ce que vous voulez dire par "correct".
Beaucoup plus facile dans les langages de programmation purement fonctionnels : ceux-ci ont une nette distinction entre les propriétés intéressantes réelles des fonctions mathématiques et les propriétés floues des interactions du monde réel dont vous ne pouvez vraiment rien dire. Pour les fonctions, il est très facile de spécifier "comportement correct": si pour toutes les entrées possibles (à partir des types d'arguments) le résultat souhaité correspondant sort, alors la fonction se comporte correctement.
Maintenant, vous dites que c'est encore insoluble ... après tout, l'espace de tous les arguments possibles est en général aussi de dimension infinie. C'est vrai - mais pour une seule fonction, même des tests de couverture naïfs vous mènent bien plus loin que vous ne pourriez jamais l'espérer dans un programme impératif! Cependant, il existe un outil incroyablement puissant qui change le jeu: quantification universelle/ polymorphisme paramétrique . Fondamentalement, cela vous permet d'écrire des fonctions sur des types de données très généraux, avec la garantie que si cela fonctionne pour un exemple simple des données, cela fonctionnera pour n'importe quelle entrée possible.
Du moins théoriquement. Il n'est pas facile de trouver les bons types qui sont vraiment si généraux que vous pouvez le prouver complètement - généralement, vous avez besoin d'un langage à saisie dépendante , et ceux-ci ont tendance à être assez difficiles à utiliser. Mais écrire dans un style fonctionnel avec le polymorphisme paramétrique seul amplifie déjà énormément votre "niveau de sécurité" - vous ne trouverez pas nécessairement tous les bugs, mais vous devrez les cacher assez bien pour que le compilateur ne les détecte pas!