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.
