Des théories pour débusquer les bugs

Karl_Popper-187px

 

Je voudrais vous parler des Theory, un type de test unitaire introduit dans JUnit 4 et pour l’instant au stade expérimental. J’introduirai d’abord la théorie sous-jacente à travers un bref aperçu des thèses du philosophe Karl Popper. Ensuite, je vais essayer de donner une définition de ce que devrait être une théorie dans le contexte des tests unitaires. Pour finir, je montrerai l’intérêt des Theory dans un process d’intégration continue. Cet article est plutôt théorique, mais cette théorie est nécessaire car si on n’appréhende pas correctement le concept on écrira des tests de type Theory mais qui n’en seront pas.

De Karl Popper au projet Popper

L’induction est un mythe

Karl Popper est un philosophe du XXe dont une des thèses majeures est que l’induction est un mythe . En effet, l’induction consiste à partir de l’observation d’un certain nombre de cas particuliers pour déduire une règle générale. La physique, par exemple, est largement fondée sur l’induction. L’induction est par principe “optimiste”, c’est-à-dire qu’on part du principe que puisque le même événement s’est produit n fois, il n’y a pas de raison qu’il ne produise pas une n+1ème fois. Popper est dans une vision plus “pessimiste” : il estime que, même si la Vérité absolue existe, elle ne nous est pas accessible et que Nous (le genre humain) ne faisons que l’approcher. Dès lors, toutes nos théories (scientifiques) ne sont que des conjectures en attente d’être réfutées (ou falsifiées). C’est pour cette raison qu’il considère que le rôle de la science c’est d’arriver à réfuter les théories scientifiques. Dans ce sens, une théorie, pour être qualifiée de scientifique, devra être réfutable (ou falsifiable), c’est-à-dire qu’on doit être capable, grâce à l’expérience (scientifique), de trouver un contre-exemple à la théorie. Je n’en dirai pas davantage sur la philosophie de Popper et j’espère ne pas avoir trop déformé ses idées. L’objectif de cette section était d’introduire la notion d’une théorie réfutable et en attente d’être réfutée. La théorie du cygne noir est un exemple historique qui montre le danger des “certitudes” issues du raisonnement par induction.

Le parallèle avec nos tests automatisés est évident. En effet, dans nos tests, nous nous concentrons sur certaines valeurs supposées représenter le domaine, puis nous faisons l’hypothèse que puisque notre test fonctionne pour ces quelques valeurs, il fonctionnera aussi pour toutes les autres.

Fiabilité de nos données de test

Prenons le cas d’une méthode qui me renvoie pour une année donnée le jour de la fête de Pâques.

import java.time.LocalDate;

public class HolidaysHelper {
    //Source : http://www.aveol.fr/?p=608
    public LocalDate identifyEasterDay(int year) {
        int a = year / 100;
        int b = year % 100;
        int c = (3 * (a + 25)) / 4;
        int d = (3 * (a + 25)) % 4;
        int e = (8 * (a + 11)) / 25;
        int f = (5 * a + b) % 19;
        int g = (19 * f + c - e) % 30;
        int h = (f + 11 * g) / 319;
        int j = (60 * (5 - d) + b) / 4;
        int k = (60 * (5 - d) + b) % 4;
        int m = (2 * j - k - g + h) % 7;
        int n = (g - h + m + 114) / 31;
        int p = (g - h + m + 114) % 31;
        int day = p + 1;
        int month = n;

        return LocalDate.of(year, month, day);
    }
}

Pour tester cette méthode, je peux écrire des tests qui valident que :

La fête de Pâques pour les années 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015 et 2016 correspond respectivement aux dates 23 Mars 2008, 12 Avril 2009, 4 Avril 2010, 24 Avril 2011, 8 Avril 2012, 31 Avril 2013, 20 Avril 2014, 5 Avril 2015 et 27 Mars 2016)

Je peux rajouter également un test pour l’année 2000 afin d’être rassuré. Au total, ça fera dix tests pour une seule méthode, je pourrai être satisfait de la qualité du code et les indicateurs Sonar seront au vert. Cependant, puis-je réellement affirmer que ma méthode est fiable à 100%? Faut-il que j’écrive non pas dix mais vingt, trente, cent tests pour cette seule méthode? Et quand je l’aurais fait, qui me dit qu’en production ça ne sera pas la seule valeur absente de mes tests qui sera utilisée et qui lèvera une erreur? Bien sur, on peut toujours améliorer la qualité de l’échantillon. Mais même là, une fois cet échantillon établi, rien n’empêche qu’une des valeurs auxquelles je n’ai pas pensé me génère une erreur. Cependant, il est clair que ne peux pas énumérer toutes valeurs possibles dans le test.

Nous avons donc deux contraintes apparemment contradictoires :

  • le temps d’exécution d’un test unitaire n’est pas illimité et on ne peut donc pas tout tester. De toutes façons, je peux difficilement déclarer tous les entiers dans ma classe.
  • si on ne teste pas toutes les valeurs, on risque de découvrir les bugs seulement en production

Spécifier plutôt que qu’énumérer

Le problème de tout test, qu’il soit automatique ou non, c’est qu’on essaie de deviner le cas qui fera planter le code. Notre erreur, c’est de prendre le problème par le mauvais bout. Par exemple, si je reprends ma méthode ci-dessus, il est beaucoup plus naturel pour le développeur de dire :

Quelle que soit l’année, la date de Pâques est un dimanche et est entre les dates du 22 mars et du 25 avril

que de deviner les dates critiques susceptibles de causer un bug.

Il est beaucoup plus simple de définir un comportement attendu que de donner une liste de valeurs qui satisfont ce comportement.

Le projet Popper

Popper (le framework pas le philosophe 🙂 ) va nous aider à écrire des tests génériques appelés Theory .

Avant d’aller plus loin, il importe de faire un rappel sur la notion des tests paramétrés Parameterized tests. En effet, il peut être aisé de confondre les Theory et les Parameterized tests .

Un test paramétré est une méthode de test qui prend des paramètres. A l’exécution, on va injecter autant de jeux de paramètres que de fois où nous allons jouer le test. Lesdits jeux de données sont définis dans le test lui-même. Ce type de test est aujourd’hui supporté par la plupart des frameworks de tests unitaires, en particulier JUnit et TestNG.

Ce qui différencie fondamentalement une Theory d’un Parameterized tests classique, ce sont les donnés manipulées à chaque test. A chaque exécution de la Theory, le jeu de données n’est jamais, ou rarement, le même, car il est sélectionné dans un univers relativement grand. Par contre, à chaque exécution d’un Parameterized tests, on utilise une liste immuable de jeux de données.

On peut voir que le nom du projet Popper n’est pas anodin puisque les Theory reposent sur deux principes :

  • supposer que notre code est erroné
  • jouer des tests à l’infini jusqu’à constater une erreur

Le projet Popper a été absorbé par JUnit et c’est ce qui a donné l’apparition des théories dans JUnit.

Caractéristiques d’une Theory

Nous pourrions donc définir une Theory dans le contexte des tests informatiques comme un test paramétré dont les paramètres sont sélectionnés de manière aléatoire dans une population de la taille la plus importante possible.

Exemples de Theory

Notre exemple précédent est un bon candidat pour une Theory

Quelle que soit l’année, la date de Pâques est un dimanche et est entre les dates du 22 mars et du 25 avril

A la différence, le test vérifiant le test suivant n’est pas une théorie

La fête de Pâques pour les années 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015 et 2016 correspond respectivement aux dates 23 Mars 2008, 12 Avril 2009, 4 Avril 2010, 24 Avril 2011, 8 Avril 2012, 31 Avril 2013, 20 Avril 2014, 5 Avril 2015 et 27 Mars 2016)

En effet, ce test, même si il est utile et doit être conservé (peut-être en rendant l’échantillon plus représentatif), fonctionne sur un nombre limité de jeux de données (neuf ici). Par ailleurs, compte tenu de la taille de l’échantillon, la sélection aléatoire est biaisée.

D’ailleurs, si je devais écrire les assertions de ce test, je serais amené à écrire :

si année==2012, alors vérifier que jourDePaquesCalcule==8 Avril 2012

Ce type d’expression est le signe que votre test n’est pas une théorie. En effet, avec ce type d’expression, on ne peut pas jouer le test avec un nombre infini de valeur puisque chaque nouvelle valeur demanderait au préalable l’ajout d’un nouveau bloc if année==

La présence de blocs conditionnels if est le signe que le test n’est pas générique et donc que le type de test approprié n’est pas une Theory. Dans ce cas, il vaut mieux faire un test classique ou si on a plusieurs jeux de données, faire un test paramétré classique.

Les hypothèses

Dans la mesure où l’univers des jeux de données doit être infini, sinon de très grande taille, il peut être intéressant d’orienter l’échantillonnage vers un sous-ensemble de cet univers. L’intérêt est de ne pas tester tout le temps notre méthode avec des valeurs peu réalistes au regard du contexte métier.

Reprenons notre théorie énoncée plus haut :

Quelle que soit l’année, la date de Pâques est un dimanche et est entre les dates du 22 mars et du 25 avril

Le paramètre en entrée est un entier positif et en principe on pourrait tester notre méthode avec tous les entiers positifs qui existent. Cependant, au niveau fonctionnel, on sait qu’il est peu probable qu’on calcule la date de Pâques pour l’année 29786. Par ailleurs, si nous avons un univers de taille démesurée par rapport à l’ensemble des valeurs “réalistes”, il y a une probabilité très faible que nous tombions sur des années telles que 2000 ou 2017, qui sont finalement les valeurs qui nous intéressent le plus. Pour cette raison, il nous faut affiner notre univers.

Nous allons donc redéfinir notre théorie comme suit :

En supposant que l’année est un entier strictement positif
En supposant que l’année est un entier inférieur à 2500

La date de Pâques est un dimanche et est entre les dates du 22 mars et du 25 avril

Les deux premières lignes de la théorie ci-dessus représentent les hypothèses de la Theory. Cela signifie que pour les valeurs ne respectant pas ces hypothèses, on ne tentera même pas de vérifier la théorie.

Les Theory dans le contexte des tests unitaires

Les théories : un outil pour débusquer les bugs à priori

L’idée maîtresse des Theory, c’est l’approche pessimiste.

Un test qui passe ne signifie pas que le code est correct mais nous indique juste que nous n’avons pas encore pu constater l’imperfection du code.

L’ultime objectif des Theory, c’est de pouvoir débusquer les bugs de manière automatique et si possible avant que l’utilisateur final ne les trouve.

Ceci change notre rapport aux tests unitaires. En effet, traditionnellement, un test unitaire a deux utilités :

  • dans un process TDD, il va permettre de valider que le code écrit est conforme aux spécifications contenues dans les tests
  • lors d’une modification du code, il va permettre de valider que le nouveau code ne crée pas de régression par rapport aux spécifications existantes.

Avec les Theory, les tests unitaires ont une nouvelle fonction de découvreurs de bugs . En effet, au fil des exécutions, on va parcourir l’univers des valeurs possibles et que donc on a des chances de tomber sur la donnée qui va faire planter le test. C’est pour cette raison que le caractère aléatoire des jeux de données est important.

Impact sur l’intégration continue

Il existe un axiome de fait dans les équipes de développeurs :

En supposant que les tests sont vraiment unitaires et écrits dans les règles de l’art
alors les tests unitaires sont déterministes

Ce qui revient à dire :

En supposant que les tests sont vraiment unitaires et écrits dans les règles de l’art,
Si les tests unitaires s’exécutent avec 100% de succès à un instant T1
et que, entre ce moment et un second instant T2, le code n’a pas changé,
alors les tests unitaires s’exécuteront avec 100% de succès à ce second instant T2

Avec les théories, ce axiome n’est plus valable. En effet, un test peut s’exécuter avec succès pendant plusieurs semaines puis être en échec du jour au lendemain sans que le code ait changé mais simplement parce que on aura utilisé un jeu de données inédit. Une fois, que le test a été mis en échec, tout se passe comme pour un bug identifié en PROD : on corrige le code puis rajoute un test spécifique au jeu de données concerné. C’est la plus-value des Theory : c’est ce moment où le développeur ne subit plus les bugs mais devient proactif. Plutôt que d’attendre que le bug arrive en production, on cherche à le provoquer inlassablement sur l’intégration continue, jusqu’à ce qu’on y arrive.

Theory vs Parameterized Tests

Si les paramètres de la méthode testée sont issus d’un univers de petite taille (exemple un Enum), alors les Parameterized Tests doivent être préférés. Par contre, si les paramètres proviennent d’un univers relativement grand ou infini (une chaîne de caractères, un entier, etc.), alors il faudra préférer les Theory

Cependant, en pratique, il faudra mixer les deux. En effet, nous aurons toujours besoin de tests aux limites du domaine et sur certaines valeurs particulières. Avec les Theory, compte tenu du caractère aléatoire de la sélection, il existe une probabilité pour que ces valeurs critiques ne soient pas testées pendant plusieurs semaines ou mois.

Enfin, la Theory est supposée être générique, alors que souvent, on a besoin de tester certains aspects particuliers. Par exemple, même si j’ai une théorie qui vérifie que la date de Pâques est bien un dimanche, j’ai quand même besoin de vérifier si la date de Pâques de 2014 est bien le 20 Avril.

Il faut donc conserver ce type de test, sans doute sous forme de Parameterized Tests et les coupler avec une ou plusieurs Theory afin de laisser l’intégration continue trouver les bugs pour nous 🙂

Conclusion

Vous l’aurez compris, il ne s’agit pas de remplacer nos tests actuels par des Theory mais de les utiliser à bon escient selon le use case testé.
Maintenant que nous avons compris ce qu’était une théorie, il ne nous reste plus qu’à les mettre en place. Ceci sera l’objet d’un chapitre dans le tutoriel JUnit à venir. A ma connaissance, c’est aujourd’hui le seul framework à avoir implémenté ce type de tests. Si vous voulez en savoir plus sur les théories, vous pouvez commencer par ce document.

Nombre de vue : 169

COMMENTAIRES 7 commentaires

  1. fbiville dit :

    Très bon article 🙂

    Une remarque cependant : il me semble que les Theory de Popper/JUnit ressemblent beaucoup au “generative testing” de Clojure (et Haskell) si je me trompe pas.

    Donc ça existe ailleurs 😉

  2. Ubikuity dit :

    Merci, l’article très intéressant qui m’a permis de découvrir cette théorie 🙂

    Juste une remarque :
    “dans un process TDD […] Une fois, que le test a été mis en échec, tout se passe comme pour un bug identifié en PROD : on corrige le code puis rajoute un test spécifique au jeu de données concerné.”

    => Dans la logique TDD, je dirais plutôt :
    1. Ajoute le test spécifique au jeu de données
    2. On lance le test qui doit être rouge
    3. On corrige le code
    4. On lance le test qui devra être vert”
    (+ refactor pour les Boy Scouts)

  3. Paterne Gaye-Guingnido dit :

    @Ubikuity Effectivement, le test d’abord avant d’implémenter. Mon naturel est remonté 🙂

    Merci pour la correction.

  4. Paterne Gaye-Guingnido dit :

    @fbiville Je ne connaissais pas. Mais à vrai dire, je dois avouer que en écrivant que JUnit est”le seul framework à avoir implémenté ce type de tests”, j’avais en tête la comparaison avec TestNG.

    Merci pour la correction nécessaire.

  5. Rémi Doolaeghe dit :

    C’est une approche très intéressante pour fiabiliser avant une release. Par contre, ce qui me dérange, c’est la reproductibilité. Mettons qu’un test échoue. Comment peut-on le reproduire (et le corriger) si le jeu de données est choisi aléatoirement à chaque exécution des tests ?

  6. […] En réalité, notre théorie que nous venons de définir est analogue au premier test unitaire que nous avions créé dans la section « Pré-requis » : c’était techniquement un test, car il y avait l’annotation @Test, mais concrètement il ne faisait rien. Il en va de même pour notre théorie. Techniquement c’est une théorie, même si en vérité elle n’en est pas une. Si vous connaissez la notion de Theory, vous vous en êtes sûrement rendus compte sinon, je vous propose de lire cet article. […]

  7. odenier dit :

    Merci pour ce très bon tutoriel !!
    Un correctif : le jour de pâques en 2013 est le 31 Mars 2013

AJOUTER UN COMMENTAIRE