Property-based Testing ou l'insuffisance des tests unitaires

L'importance des tests unitaires n'est plus à démontrer. Ils nous rassurent sur notre implémentation, sécurisent chaque remaniement de notre code et nous préviennent de potentielles régressions.

Cependant, sont-ils suffisants ? Peut-on se satisfaire de la rédaction de tests basés sur des exemples choisis délibérément par le développeur ? Qu'en est-il des autres cas ?

Example-based Testing : est-ce suffisant ?

Quels que soient les tests que nous rédigeons, nous avons coutume d'utiliser des exemples pour illustrer une généralité de notre système. Le but de ces tests basés sur les exemples est de vérifier le résultat exact d'une implémentation à partir de valeurs d'entrée représentatives et choisies par le développeur.

Par exemple, dans le cas de la définition d'un prix de vente pour un article, nous validerons que ce prix est strictement positif. Cette vérification peut être validée par le test suivant :

// Exemple Java / AssertJ
 
@Test
void price_value_must_be_positive() {                                  
    assertThatThrownBy(() -> new Price(-1, "EUR"))
        .isInstanceOf(InvalidPriceException.class)
        .hasMessage("Price must have a positive value.");
}

Dans cet exemple, nous utilisons une valeur (-1) représentative d'un ensemble de valeurs (les nombres négatifs) pour valider le cas d'erreur autour de la négation du prix. Par un simple test, nous nous confortons dans l'idée que le comportement est correctement géré.

Tout du moins, en apparence…

Point négatif n°1 - La non-exhaustivité

Selon Edsger W. Dijkstra, "les tests prouvent la présence d'anomalies, mais jamais leur absence".

En nous basant uniquement sur des valeurs représentatives, nous acceptons indirectement le fait que notre code peut échouer sur d'autres valeurs. Même avec une implémentation juste du code de production, la lecture seule de ce test ne peut pas nous conforter dans l'idée que l'implémentation se comporte comme attendue.

L'idéal serait de pouvoir tester l'entièreté des cas possibles pour un champ d'application donné. Mais, dans le meilleur des cas, cela reste compliqué. Et bien souvent, c’est tout bonnement impossible.

Point négatif n°2 - Le souci du détail

Un test peut parfois se montrer trop verbeux. Sa véritable intention peut être occultée par des informations inutiles.

Pour reprendre notre exemple précédent, il est précisé que le prix testé est en euros. Le but étant de vérifier la positivité de la valeur du prix, préciser sa monnaie est inutile. Pire encore, le simple fait que cette monnaie soit renseignée peut donner l'impression que cette information est nécessaire, la mettant au même niveau d'importance que la valeur du prix à tester.

Seules les informations pertinentes et utiles aux tests devraient être mises en avant (ne serait-ce que pour sa lisibilité et sa compréhension). Le reste se doit d'être aussi générique que possible.

Point négatif n°3 - L’explosion combinatoire

D’un point de vue code, une fonction à deux arguments est plus facile à tester qu’une fonction à trois ou quatre arguments. Et le simple fait d’en ajouter peut amener à augmenter considérablement le nombre de tests à rédiger. Malheureusement, on peut rarement se contenter de fonctions disposant d’un faible nombre d’arguments. Tester unitairement toutes leurs possibilités peut donc s’avérer difficile.

Si, dans ce type de situation, l’écriture des tests peut être harassante, ne serait-il pas plus confortable de les générer ?

Quelles solutions ?

Ces inconvénients gravitent autour d'une seule chose : le choix délibéré des valeurs de test.

Factuellement, l'assertion d'une généralité ne peut être validée avec des valeurs délibérément choisies, mais doit être vérifiée à partir de n'importe quelle valeur. Générer aléatoirement des valeurs de test, tout en ayant la capacité de reproduire facilement ces valeurs en cas d'erreur, permettrait de se concentrer davantage sur le comportement du code.

Cette génération peut nous être offerte par le Property-based Testing.

Property-based Testing : l'approche adéquate ?

La notion de propriété est fortement présente dans le domaine des mathématiques, et sa définition reste inchangée dans le domaine du développement :

Une propriété (aussi appelé “invariant”) est une caractéristique d'un objet considérée comme étant toujours vérifiée.

Contrairement aux tests basés sur les exemples, le Property-based Testing a pour finalité de tester le comportement de votre code.

Au lieu de s'appuyer sur des valeurs prédéfinies pour vérifier l'exactitude d'un résultat, un test de propriété générera de façon pseudo-aléatoire une multitude de valeurs en entrée. Chacune de ces valeurs sera testée pour s'assurer que le code se comporte de manière adéquate.

Aussi, les tests de propriété vivent à travers le temps. Chaque nouvelle exécution des tests entraîne une nouvelle génération de valeurs à tester. Autrement dit, même avec une implémentation correcte pour 99,9% des cas, un test de propriété continuera à tester votre implémentation avec des valeurs constamment différentes et finira par trouver LE cas non couvert (si il existe).

Le Property-based Testing... par l'exemple

Illustrons cette pratique par un cas simple : l'implémentation du chiffre de César (nous ne nous limiterons qu'à une seule lettre lors du chiffrement/déchiffrement).

Le chiffre de César est un système de chiffrement :

  • symétrique : la même clé est utilisée pour chiffrer (cipher) et déchiffrer (decipher),
  • circulaire : une fois arrivé à la lettre Z, le chiffrement reprend à la lettre A (chiffrer la lettre Z avec la clé 2 donnera B).

Ces deux caractéristiques sont les propriétés de ce système de chiffrement. Qu'importe les valeurs (lettres et clés) à chiffrer et déchiffrer, ces deux comportements doivent toujours être vérifiés.

Par une approche TDD, nous pourrions obtenir ces tests unitaires :

// Exemple Java / AssertJ
 
CaesarCipher cut = new CaesarCipher();
 
@Test
void cipher_key_must_be_positive() {
    assertThatThrownBy(() -> cut.cipher('A', -1))
          .isInstanceOf(IllegalArgumentException.class)
          .hasMessage("Cipher key must be positive.");
}
 
@Test
void decipher_key_must_be_positive() {
    assertThatThrownBy(() -> cut.decipher('A', -1))
        .isInstanceOf(IllegalArgumentException.class)
          .hasMessage("Decipher key must be positive.");
}
 
@Test
void cipher_a_character() {
    assertThat(cut.cipher('C', 3)).isEqualTo('F');
}
 
@Test
void decipher_a_character() {
    assertThat(cut.decipher('G', 3)).isEqualTo('D');
}
 
@Test
void ciphering_must_be_circular() {
    assertThat(cut.cipher('Y', 4)).isEqualTo('C');
}
 
@Test
void deciphering_must_be_circular() {
    assertThat(cut.decipher('B', 5)).isEqualTo('W');
}

Ici, chaque test utilise des valeurs fixes et choisies par le développeur en fonction du périmètre à couvrir. Après implémentation, on peut se rassurer sur le fait que notre code répond aux cas de tests rédigés, mais peut-être pas aux autres qui pourraient survenir.

La non-exhaustivité des tests unitaires ne doit pas aboutir à leur remplacement, mais à leur complétion. Pour tester une généralité de notre code, il est possible d'être plus "global" dans la rédaction des tests et de s'abstraire des valeurs prédéfinies.

Pour se conforter dans l'idée que notre implémentation est correcte, rajoutons des tests de propriété :

// Exemple Java / JUnit-QuickCheck / AssertJ
 
@Property
public void caesar_ciphering_must_be_symetric(
    @InRange(minChar = 'A', maxChar = 'Z') char initialChar,
    @InRange(minInt = 0) int key
) {
    char encodedChar = cut.cipher(initialChar, key);
    char decodedChar = cut.decipher(encodedChar, key);
 
    assertThat(decodedChar).isEqualTo(initialChar);
}
 
@Property
public void encoding_must_be_circular(
    @InRange(minChar = 'A', maxChar = 'Z') char initialChar,
    @InRange(minInt = 26) int key
) {
    char encoded = cut.encode(initialChar, key);
    char encodedModulo = cut.encode(initialChar, key % 26);
 
    assertThat(encodedModulo).isEqualTo(encoded);
}

Rédigés avec la librairie JUnit-QuickCheck, ces deux tests génèrent un caractère compris entre A et Z (délimité grâce à l'annotation @InRange), ainsi qu'une clé de chiffrement définie avec une valeur minimale. Pour chacun de ces tests, un nombre conséquent de valeurs d'entrée est généré et chacune d'entre elles est testée.

Le test caesar_ciphering_must_be_symetric() vérifie que notre implémentation est conforme à un chiffrement symétrique. Autrement dit, appliquer une opération (ici, la fonction cipher) sur un caractère initial, puis son inverse (decipher) avec la même clé de chiffrement doit retourner la valeur initiale, quelle que soit cette valeur.

Le test encoding_must_be_circular() vérifie la circularité du chiffrement. Le chiffre de César se porte sur les 26 caractères de l'alphabet, donc une clé supérieure à ce nombre sera équivalente à ce même nombre modulo 26 (26 -> 0, 27 -> 1, ...).

Le passage au vert de ces tests au fil des compilations nous soulagent sur notre implémentation. Aussi, ils détecteront précocement les cas aux limites maladroitement oubliés.

Un test de propriété peut-il remplacer un test unitaire ?

Dans de rares cas, un test de propriété peut remplacer un test unitaire. Pour reprendre l'exemple précédent, le test unitaire cipher_key_must_be_positive() a pour but de vérifier la génération d'une erreur si la clé est négative (représentée par la valeur -1). Par ce test, nous souhaitons dire :

Pour toute clé négative, une exception doit être levée.

Par un test de propriété, nous pouvons faire en sorte d'avoir une implémentation du test exprimant davantage cette intention :

// Exemple avec JUnit-QuickCheck
 
@Property
public void cipher_key_must_be_positive(char anyChar, @InRange(maxInt = 0) int anyNegativeKey) {
    assertThatThrownBy(() -> cut.cipher(anyChar, anyNegativeKey))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessage("Cipher key must be positive.");
}

Mais généralement, un test de propriété non complété par des tests unitaires ne suffit pas. Ces deux types de tests sont complémentaires et couvrent deux choses bien distinctes. La présence de tests unitaires assurent l'exactitude du besoin à couvrir. Tester le comportement ne signifie pas que votre code répond précisément au besoin attendu.

Les deux tests de propriété rédigés plus haut (caesar_ciphering_must_be_symetric() et encoding_must_be_circular()) en sont un bon exemple. Sans test unitaire, l'implémentation suivante suffit à faire passer ces deux tests sans pour autant répondre aux cas d'usage souhaités :

public char cipher(char character, int key) {
    return character;
}
 
public char decipher(char character, int key) {
    return character;
}

Mon test a détecté un cas d'erreur

Dans le cas d'une implémentation erronée, il peut se passer des jours, des semaines, voire des mois avant que votre test ne vire au rouge. Et comme tout test en échec, une analyse est nécessaire.

Les valeurs d'entrée étant générées aléatoirement, la question de la reproductibilité peut se poser. Il faut savoir que les frameworks de tests de propriété n'abandonnent pas le développeur dès la survenue d'un cas d'erreur, mais l'accompagnent dans sa résolution.

La première aide fournie par un framework de test de propriété est la notion de seed. A chaque lancement d'un test de propriété, une seed est générée et est utilisée pour générer nos valeurs d'entrée. En complément des valeurs fautives, la seed sera fournie au développeur pour reproduire indéfiniment le cas d'erreur.

// Exemple avec JUnit-QuickCheck
 
@Property
void check_prime_number(@When(seed = 6623370160022926616L) int number) {
    // ... mon test de propriété
}

La notion de réduction (ou shrinking) offre un confort supplémentaire dans la résolution d'un test en échec. Cette fonctionnalité sera chargée de simplifier la valeur générée mise en cause pour la rendre plus compréhensible par l'humain.

Reprenons le test de propriété encoding_must_be_circular(). Si la circularité du chiffrement n'est pas prise en compte dans votre implémentation, ce test passera pour les valeurs de clé de chiffrement allant de 0 à 25, mais pas au-delà de ces valeurs. En testant une valeur de clé strictement supérieure à 25, le test de propriété échouera. Le framework appliquera donc une stratégie visant à faire tendre cette valeur le plus possible vers 0 (dans le cas d'un nombre entier). Dans ce cas précis, la plus petite valeur fautive que nous pouvons trouver correspond au premier nombre entier supérieur à 25, c'est-à-dire 26.

Les "inconvénients" du Property-based Testing

Un test de propriété qui passe ne résulte pas forcément d'une implémentation correcte. Comme ce type de test peut échouer à tout moment, le feedback n'est donc pas immédiat. C'est l'une des raisons pour lesquelles un test de propriété n'est pas exécuté qu'une seule fois, mais plusieurs centaines de fois à chaque compilation.

Étant donné que ces tests sont exécutés massivement, on peut légitimement s'interroger sur la rapidité d'exécution. Légèrement plus longue qu'un test unitaire, elle reste cependant très rapide et n’empêche en rien une exécution systématique.

Conclusion

Le Property-based Testing nous pousse à réfléchir plus profondément à l'implémentation de notre code. Cette approche se concentre sur le comportement du code testée, et non le résultat retourné par celui-ci.

Il n'a pas pour ambition de remplacer les tests unitaires, mais de les compléter. C'est un contrat que vous vous passez à vous-même pour valider la justesse de votre implémentation. Et chacune de ces exécutions vous donne une preuve supplémentaire de cette justesse.

Au premier abord, trouver les propriétés d'une fonction peut s'avérer difficile. Une bonne première étape pour se lancer peut être d'identifier les tests unitaires substituables à des tests de propriété (comme le cas d'erreur présenté plus haut).

Certes, en cas d'anomalie difficilement détectable, le feedback n'est pas forcément immédiat. Néanmoins, il est toujours préférable que cette anomalie tombe tardivement dans les mains du développeur plutôt que dans celles de son consommateur.