Test Logiciel et Assurance Qualité

Aller au contenu | Aller au menu | Aller à la recherche

Tag - technique de test

Fil des billets - Fil des commentaires

jeudi 24 décembre 2009

Données de test : Attention aux pièges !

Lorsqu'on teste, on a souvent besoin de données factices. Dans ce post, ce que j'appelle "données factices" sont des octets quelconques, sans sens particulier. Dans un certain nombre de cas, le contenu n'a pas d'importance, comme dans l'exemple suivant :

// Testons les fonctions write(file, dataToWrite) et 
// read(file, outputBuffer) 
// On devrait pouvoir lire ce qu'on vient d'écrire
// dans FILE_1
testData = { ??? }
write(FILE_1, testData)
read(FILE_1, readData)
// Vérification que testData et readData
// contiennent la même chose
assertEquals(testData, readData)

read et write ne se préoccupent pas des données qu'elle manipulent. Ce cas de test fonctionne quel que soit la valeur de testData. Est-ce que cela signifie qu'on prendre n'importe quoi ? Certainement pas.

Remplissage avec des zéros

testData = {0, 0, 0, ... }

Le remplissage avec des zéros est le comportement par défaut dans un certain nombre de langages. Par exemple, en Java :

testData = new byte[10];
// testData vaut {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}

On peut utiliser n'importe quel contenu pour ce test, alors pourquoi s'embêter ? Pour répondre à cette question, reformulons le problème. read et write peuvent être bugguées. Nous avons un test pour ça. Le seul élément qui nous manque est testData. La question devient :

  • Y a-t-il un bug potentiel qui pourrait échapper à notre test en raison du choix de testData ?
  • Quelle est la probabilité de ce bug ?

On pourrait imaginer plusieurs implémentations. Celle-ci est suffisante :

write(file, dataToWrite) {
  // TODO
}

read(file, outputBuffer) {
  // TODO
}

En fait... read et write ne sont pas implémentées. Elles ne font rien et attendent qu'un développeur bienveillant les code. Si le produit est livré en l'état, on peut s'attendre à un bug à moment ou à un autre !

Évidemment, notre test devrait repérer cette erreur grossière. Que se passe-t-il si on l'exécute ? Rien. Il s'achève en un "PASSED" tout à fait rassurant. testData est rempli avec des zéros et il en est de même pour readData dans un langage comme Java si on ne prend pas la précaution de l'initialiser (et pourquoi le ferait-on ? read est supposé l'écrire après tout). Par conséquent, l'assertion finale compare deux tableaux identiques ({0, 0, ...}) et passe.

Le pattern "zero" est vraiment, vraiment mauvais. Au suivant !

Remplissage avec une unique valeur

testData = {0x95, 0x95, 0x95, ... }

Le zéro était une mauvaise idée. Qu'en est-il d'une autre valeur ? Même approche, voici le bug :

write(file, dataToWrite) {
  // TODO
}

read(file, outputBuffer) {
  fill(outputBuffer, 0x95)
}

A nouveau, le bug est facile à obtenir : write ne fait rien et read remplit le buffer avec 0x95, peu importe ce qu'on lui demande. Cependant, la situation est bien différente de l'exemple précédent. Oublier d'implémenter deux fonctions est une erreur tout à fait plausible. Mais que penser de fill(outputBuffer, 0x95) dans la seconde version ? Cette instruction est étrange. C'est plus qu'un oubli ou une incompréhension du développeur. L'hypothèse du programmeur compétent dit que cette erreur est peu probable.

Bien, une valeur non-nulle est meilleure que zéro, mais est-elle bonne pour autant ? Non, toujours pas :

write(file, dataToWrite) {
  for i = 0... dataToWrite.length - 1 {
    internalBuffer[file][i] = dataToWrite[0]
  }
}

read(file, outputBuffer) {
  for i = 0... dataToWrite.length - 1 {
    outputBuffer[i] = internalBuffer[file][i]
  }
}

Cette fois-ci, read et write font quelque chose de sensé. write copie les données dans un buffer interne et read fait l'opération inverse. Mais une fois encore, il y a un bug, tout à fait plausible cette fois-ci : au lieu de prendre l'octet à l'offset i, write copie toujours le premier octet de dataToWrite. Et comme tous les octets de dataToWrite sont identiques dans notre test, le bug ne sera pas découvert.

Le pattern basé sur une répétition est un mauvais choix pour une situation type "système de fichier" comme celle-ci. En plaçant tous les octets sur un plan d'égalité, il tend à annuler les nombreuses erreurs d'offset et longueur qui peuvent se produire.

Valeur = Index

testData = {0, 1, 2, ... }

Utiliser une seule valeur n'est pas efficace. L'étape suivante est d'utiliser plusieurs valeurs. Un tableau du type {0, 1, 2, ... } pourrait être un bon candidat. Il a l'avantage d'être facile à construire :

for i = 0... testData.length - 1 {
  testData[i] = i
}

Et... voici le bug :

write(file, dataToWrite) {
  for i = 0... dataToWrite.length - 1 {
    internalBuffer[file][i] = dataToWrite[i]
  }
}

read(file, outputBuffer) {
  for i = 0... dataToWrite.length - 1 {
    outputBuffer[i] = i
  }
}

Au lieu de copier le contenu de internalBuffer[file], read prend i, l'offset lui-même. Dans ce simple exemple, le développeur n'a que peu de chances de se tromper de la sorte. Dans un vrai système cela peut facilement se produire et on ne peut pas se permettre de passer à côté.

La racine du problème est la relation entre le contenu de testData et un autre facteur utilisé dans le code (l'offset i dans cet exemple). Une erreur du programme peut ainsi échapper au test parce que le choix de testData suit le même chemin.

Données aléatoires

testData = {random(), random(), random(), ... }

Remplir testData avec des données aléatoires est la première bonne proposition. Les octets ne sont pas constants ou liés à quelque chose comme l'offset, la longueur du fichier ou un identifiant. Cela prévient le risque de confusion. Je n'ai aucun exemple de bug réaliste cette fois-ci.

Les valeurs aléatoires soulèvent des questions et engendre parfois des débats sans fin. Le test ne produira jamais deux fois le même testData d'une exécution à l'autre. Si un bug est découvert, il pourrait ne plus être détecté lors de l'exécution suivante. Pour cette raison, les données aléatoires ont leurs détracteurs. Il existent des solutions de rechange, à discuter dans un autre post.

jeudi 3 décembre 2009

Le test qui ne pouvait échouer

Bien souvent, on s'attend à ce que les tests passent. Notre outil nous présente un rassurant "Tous les tests sont passés avec succès" ou quelque chose du genre. Idéal une veille de livraison.

Quid d'un test qui nous ferait toujours ce plaisir ?

Testons une méthode de vérification de PIN. verifyPIN retourne true lorsque le PIN de 4 chiffres passé en paramètre est correct, false sinon. Un test négatif est recommandé, qu'en dites-vous ? Dans cette version simplifiée, on prend la bonne valeur du PIN. On en modifie l'un des chiffres, puis on soumet cette valeur altérée à verifyPIN en s'attendant à ce qu'elle retourne false. On teste chaque position du PIN (le premier chiffre, le second, etc.) afin de s'assurer que chacune est impliquée dans la vérification :

// Testons toutes les positions d'un PIN à 4 chiffres
for (int i = 0; i < 4; i++) {
  // On récupère le vrai PIN
  pin = getPIN();
  // On le modifie
  pin[i] = (pin[i] + 1) % 10; // 0 -> 1, 1 -> 2, ..., 9 -> 0
  // A partir de maintenant, pin n'est plus correct

  // L'authentication devrait échouer
  assertFalse(verifyPIN(pin));
}

Le test passe. Bonne nouvelle pour la livraison de demain. En fait, ce n'est pas une bonne nouvelle, par la combinaison de deux hasards :

  • Pour une raison ou pour une autre, getPIN ne retourne pas la bonne valeur du PIN. Un problème dans la configuration du test peut-être ?
  • Il se trouve que verifyPIN est buggué. verifyPIN ne vérifie pas le dernier chiffre du PIN (une faute de frappe dans la longueur de la comparaison ?). Par conséquent, si le PIN est "1234", verifyPIN accepte naturellement "1234", mais aussi "1235" et "1239". Ouch.

Ce bug est critique et pourtant il n'est pas détecté par notre test qui est pourtant conçu pour cela. Que s'est-il passé ? Le but du test est de soumettre un PIN avec un seul chiffre incorrect. Comme getPIN ne retourne pas la bonne valeur, il y a toujours des erreurs sur les trois premiers chiffres que verifyPIN détecte, peu importe que quatrième. Ainsi que le test l'attend, l'authentification échoue, mais pas pour la bonne raison.

Le bug ne sera pas détecté et le produit sera livré demain comme prévu. Un jour, quelqu'un se rendra compte de l'erreur et là...

Donnons-lui une chance d'échouer

Il existe plusieurs moyens de rendre un test résistant. Pour un test négatif de ce genre, une méthode classique est d'ajouter un cas positif :

pin = getPIN();
// Avec le bon PIN, l'authentification doit réussir
assertTrue(verifyPIN(pin));

// Testons toutes les positions d'un PIN à 4 chiffres
for (int i = 0; i < 4; i++) {
  ... // Pas d'autre changement
}

Le test est plus sûr. Désormais, lorsque getPIN retourne une mauvaise valeur, le test échoue immédiatement puisque verifyPIN indique d'emblée que le PIN est incorrect. Certes, le test sortira failed pour une mauvaise raison (une bonne raison étant un bug de code), mais au moins l'erreur ne passera pas inaperçue et le testeur fera le nécessaire pour la corriger. Au pire, on livrera après-demain.

Comment ça "paranoïaque" ?

La vérification du PIN en début de test réduit considérablement le risque évoqué ci-dessus. Toute personne raisonnable s'arrêterait là.

Cependant, "raisonnable" est tout juste un minimum pour nous testeurs. D'un côté, nous traquons les bugs, sortons des sentiers battus pour trouver les scénarios litigieux, cherchons les failles... De l'autre, nous écrivons des tests et prenons le risque de commettre exactement les même erreurs que celles que nous recherchons. Et dans notre cas, personne n'est là pour tester notre travail. Nous devons nous efforcer de trouver nos propres erreurs. Pas simple.

Pour reprendre l'exemple du PIN, on peut rendre le test encore plus sûr :

// Testons toutes les positions d'un PIN à 4 chiffres
for (int i = 0; i < 4; i++) {
  // On récupère le vrai PIN
  pin = getPIN();

  // Avec le bon PIN, l'authentification doit réussir
  assertTrue(verifyPIN(pin));

  // On le modifie
  pin[i] = (pin[i] + 1) % 10; // 0 -> 1, 1 -> 2, ..., 9 -> 0
  // A partir de maintenant, pin n'est plus correct

  // L'authentication devrait échouer
  assertFalse(verifyPIN(pin));
}

La valeur de getPIN est maintenant vérifiée à chaque itération. En quoi est-ce plus sûr ? A moins d'une erreur de ma part dans le code qui précède, cela n'apporte rien. Mais nous ne cherchons pas à éviter les erreurs que nous connaissons : lorsqu'on a conscience d'une erreur, on la corrige, tout simplement. Il s'agit plutôt d'anticiper des problèmes typiques. Comme dans cet exemple :

// On récupère le vrai PIN
pin = getPIN();

// Testons toutes les positions d'un PIN à 4 chiffres
for (int i = 0; i < 4; i++) {
  // Avec le bon PIN, l'authentification doit réussir
  assertTrue(verifyPIN(pin));

  // On le modifie
  pin[i] = (pin[i] + 1) % 10; // 0 -> 1, 1 -> 2, ..., 9 -> 0
  // A partir de maintenant, pin n'est plus correct

  // L'authentication devrait échouer
  assertFalse(verifyPIN(pin));
}

L'appel à getPIN est maintenant sorti de la boucle for. Après tout, il n'est pas utile de l'appeler quatre fois n'est-ce pas ? En réalité, oui, c'est indispensable.

Le test veut soumettre à verifyPIN un PIN dont tous les chiffres sont corrects sauf un. Or, en l'état, ce n'est pas ce que fait le test. En effet, puisque pin n'est plus réinitialisé en début de boucle for, les irrégularités introduites dans pin s'accumulent d'une itération à l'autre. A la quatrième itération, ce n'est pas seulement le dernier mais tous les chiffres de pin qui sont incorrects. Une fois de plus, le bug ne serait pas découvert... si la vérification de pin n'était pas faite en début de boucle. Grâce à elle, lors de la seconde itération, le test échouera en constatant que verifyPIN(pin) retourne false.

Les self-tests ne sont pas toujours facile à introduire. On pourrait également s'interroger sur les éventuels effets de bord introduits par les appels supplémentaires à verifyPIN. C'est néanmoins une technique à garder à l'esprit, spécialement dans le cadre de tests négatifs.