Des tests unitaires

S’il y a bien une chose qui a changé dans le monde du développement entre mes débuts et aujourd’hui, ce sont les tests unitaires. Il y a vingt ans, seuls les projets sensibles en implémentaient, et encore pas tous, loin s’en faut, du fait du manque d’outil pour le faire facilement. De nos jours, tous les langages possèdent des bibliothèques de tests unitaires, tous les IDE proposent des facilités pour intégrer l’exécutions de ces tests à la chaine de build, des patterns de code comme l’injection de dépendance permettent de rendre testables des choses qui ne l’étaient pas auparavant, et les équipes sérieuses cherchent à atteindre la meilleure couverture possible.

Quoi tester ?

Je ne crois pas à l’intérêt d’une couverture à 100%. Une application typique contient de nombreuses fonctions triviales, comme les getters et les setters par exemple. Il n’y a aucun bénéfice à tester ce genre de code. Il y a aussi beaucoup de fonctions qui sont connectées au monde extérieur : lecture de fichier, appel de web service, interaction avec l’utilisateur… Par définition ces fonctions ne sont pas unitaires, mais agrègent des fonctionnalités fournies par des sous-modules. Elles sont donc difficilement accessibles aux tests unitaires et seront plutôt couvertes par des tests d’intégration ou des tests systèmes.

Une bonne candidate au test unitaire est une fonction pure : une fonction dont le résultat ne dépend uniquement que de ses paramètres d’entrée et rien d’autre. Toute la stratégie consiste à adapter ses habitudes de développement pour que le plus de fonctions possibles répondent à ce critère. Ce n’est pas compliqué, il faut juste bien penser l’architecture de son code. Je vais y revenir.

Pourquoi tester ?

La réponse triviale est évidemment : pour vérifier que ça fonctionne. Mais les tests unitaires apportent plus que cela.

D’abord, ils apportent une possibilité de couverture considérable. Imaginons une fonction calculant la signature numérique d’un message. Il se peut que cette fonction présente un bug qui se manifestera uniquement quand la taille du message sera un multiple de 64, moins 1. (C’est du vécu…) Si vous ne faites que des tests de l’application complète, vous passerez probablement à côté de ce bug parce qu’il sera difficile d’imaginer un scénario de test conduisant le système à avoir besoin de signer un message d’exactement cette taille. Et même si vous y parvenez, le travail pour remonter jusqu’à l’origine du bug sera fastidieux. Un test unitaire, lui, ne présente pas ces difficultés : vous appelez explicitement la fonction de signature avec un échantillon de messages aussi varié que vous le souhaitez et si un test échoue, vous savez immédiatement où se situe le problème. Autre exemple vécu : une fonction qui manipule du texte pouvant se présenter selon différents encodages. Pratiquement impossible à tester dans l’application finale, parce que cela nécessiterait des cas de tests dans différentes langues et sur des installation de Windows ou de macOS en russe, japonais, chinois, etc. ; mais très facile à tester avec des tests unitaires, puisqu’il suffit de récupérer des échantillons de texte dans toutes les langues et encodages voulus sur internet et de les passer à la fonction.

Une autre chose qu’apportent les tests unitaires est qu’ils ont valeur de spécification. Imaginons un test unitaire de la fonction abs(x), cela pourrait être :

ensure(abs(0) == 0);
for (int i = 0; i < 10000; i++) {
    int v = random();
    ensure(abs(v) == v);
    ensure(abs(-v) == v);
}

En lisant le test, on comprend ce que fait la fonction testée, ou du moins ce qu’on attend d’elle. Ce n’est bien sûr pas aussi clair qu’une spécification écrite détaillée, mais dans un monde où la plupart des projets n’ont aucune spécification détaillée du tout, c’est mieux que rien !

Enfin, les tests unitaires sont un garde fou. Dans la vie d’un projet, il se produit régulièrement des changements majeurs : refactoring pour pouvoir implémenter une nouvelle fonctionnalité, portage sur une autre plateforme (x86 vers ARM, ou bien little endian vers big endian), mise à jour de la chaîne de build, changement de compilateur, etc. Si la partie métier du projet est couverte par des tests unitaires, vous pouvez vérifier en un instant que tout fonctionne comme avant. De même, si un nouveau développeur arrive et casse quelque chose parce qu’il ne connait pas bien le projet, il en est immédiatement informé.

Transformer des tests systèmes en tests unitaires

Comme je le disais, les fonctions testables unitairement sont typiquement des fonctions pures. Or, dans un projet classique, la plupart des fonctions métiers ne sont pas pures : leur comportement ne dépend pas seulement de ce qu’on leur passe en paramètre, mais aussi de choses extérieures telles que le contenu d’une base de données ou d’un fichier, l’heure ou la date, les paramètres régionaux qui influent sur le formatage des nombres, etc. C’est pour cela que les cas de tests contiennent souvent des pré-conditions : il s’agit des étapes à effectuer avant de pouvoir jouer le test pour que le système soit dans l’état attendu. Il peut s’agir de régler l’heure, la date ou les paramètres régionaux à des valeurs précises, ou bien d’injecter dans la base un jeu de données précis.

Un cas concret : une fonction qui pour effectuer sa tâche a besoin de la date courante. Classiquement, on va appeler la fonction time(NULL) (ou équivalent) dans le code de la fonction pour récupérer cette information. Mais ce faisant, on rend la fonction impossible à tester puisque l’un des paramètres influant sur le résultat que l’on veut tester ne peut pas être injecté dans le test. Ce paramètre dépend d’une circonstance extérieure ni maitrisable ni reproductible : le moment où le testeur exécute le test.

La solution réside dans l’injection de dépendance. Comme son nom l’indique, il s’agit d’injecter dans un objet (ou dans une fonction) tout ce dont il a besoin pour produire son résultat. Dans notre exemple, au lieu d’appeler la fonction time(NULL) directement, la fonction métier appellera les méthodes d’un objet qui lui sera passé en paramètre et servant à récupérer l’heure. En situation réelle, cet objet sera un objet qui retournera vraiment la date courante, tandis que pour un test unitaire, ce sera un objet qui retournera une date constante prédéfinie. En C++, on pourrait écrire :

class TimeProvider {
    virtual long getTime() = 0;
};

class SystemTime : public TimeProvider {
    long getTime() { return time(NULL); }
};

class FakeTime : public TimeProvider {
    long getTime() { return 1529249062; }
};

Et la fonction à tester :

int MyFunction(TimeProvider & t, ...) {
    // do some computation using t.getTime() and
    // other parameters
}

Bien sûr, il s’agit d’un exemple trivial pour illustrer le propos, mais le principe est facile à généraliser. (Dans un cas aussi simple que celui-ci, on pourrait tout aussi bien passer directement la date à la fonction, plutôt que de lui passer un objet servant à récupérer la date !)

Un problème que l’on rencontre souvent est que les tests ne sont pas situés dans la même unité de compilation que le code testé. Du coup, certaines fonctions que l’on aimerait tester ne sont pas accessibles, soit parce qu’elles ne sont pas exportées, soit parce qu’elles sont déclarées private ou protected dans une classe. Dans ces cas-là, j’utilise généralement de la compilation conditionnelle pour modifier temporairement la visibilité. Par exemple :

class MyClass {

#if UNIT_TESTING
    public:
#else
    private:
#endif
        int MyFunction(int param);

};

Boîte noire ou boîte blanche ?

Il y a deux stratégies de test. Dans le mode boîte noire, le test est écrit à partir de la spécification sans connaissance de l’implémentation exacte de la fonction à tester. Ceci peut être obtenu soit en faisant écrire le test par une personne différente de celle qui a écrit le code (c’est même obligatoire dans le domaine médical), soit en écrivant le test avant de développer la fonction à tester (c’est le principe du Test-Driven Development). À l’inverse, dans le mode boîte blanche, le test est écrit en ayant connaissance de l’implémentation de la fonction à tester.

Les deux stratégies ont leurs avantages et leurs inconvénients. La boîte noire permet souvent de découvrir des problèmes dans des cas limites auxquels le développeur n’a pas pensé, ou bien de s’apercevoir que la spécification n’est pas assez précise et qu’elle a été interprétée différemment par le testeur et le développeur. En revanche, la boite blanche permet d’être plus spécifique et rationnel. En voyant, par exemple, les conditions de toutes les instructions if d’une fonction, il est possible de faire une analyse par domaine, c’est à dire de déterminer les valeurs pivots où la fonction change de comportement. Cela évite d’écrire des tests redondants qui couvrent en fait la même branche de code.

De toute façon, on n’a bien souvent pas le choix : la stratégie de test est imposée par la culture de l’entreprise, ses habitudes de travail, ou sa politique QA.

Outils de test

Tous les IDE et frameworks de développement modernes incluent des outils pour effectuer des tests unitaires et les intégrer à la chaine de build. À chaque compilation, les tests sont joués et vous êtes avertis en cas de problème.

Personnellement, faisant essentiellement du développement mobile ou embarqué, je ne suis pas à l’aise avec ces outils. En effet, leur principe de fonctionnement est de compiler le code de test et les fonctions à tester pour la plateforme sur laquelle vous travaillez, c’est à dire x86 sous macOS, Linux ou Windows, afin d’y exécuter les tests en direct depuis l'IDE. Or, votre code tournera en production sur un processeur ARM sous iOS ou Android ou un quelconque micro-système temps réel. En théorie, c’est le boulot du compilateur de s’assurer que le code fonctionne de façon identique quelle que soit la machine cible ; en réalité, il y a souvent de petites divergences. Un bon exemple sont les opérateurs de décalage de bit << et >> : ils se comportent différemment entre les processeurs x86 et ARM pour les valeurs de décalage supérieures ou égales à la largeur d’un int.

Aussi, j’utilise souvent un petit framework minimaliste maison qui embarque les tests unitaires dans l’application finale et qui les joue à chaque lancement de l’application. Bien sûr, c’est moins confortable que ce que permet un IDE ou un outil d’intégration continue comme Jenkins. En revanche, non seulement mes tests tournent dans des conditions identiques à la production, mais de plus, je suis certain d’être averti immédiatement dès qu’un test ne passe plus. Quelques #if suffisent par compilation conditionnelle pour exclure ce framework et les tests unitaires lors de la build finale.