Bonnes pratiques C++ (4/n)

Ce billet appartient à une série consacrée aux bonnes pratiques de développement C++. Certains sont toutefois applicables à d'autres langages. Aujourd'hui : les tests unitaires et la documentation.

Mettez en place des tests unitaires

C’est un changement majeur dans le développement logiciel ces vingt dernières années : l’écriture des tests fait partie désormais intégrante du travail du développeur. Tous les langages offrent des bibliothèques de tests unitaires, les IDE proposent des facilités pour intégrer ces tests du mieux possible à l’environnement de travail et certaines équipes prônent le Test Driven Development, qui consiste à écrire les tests avant même de commencer à écrire le code.

Pourquoi tester ? Une réponse triviale est évidemment : pour vérifier que le code fonctionne. Mais en réalité, c’est presque une raison accessoire. Les tests unitaires apportent bien plus que cela.

  • Ils ont valeur de spécification. En lisant les tests unitaires d’une fonction, on comprend quel est le comportement attendu de cette fonction. Bien sûr, ce n’est pas aussi précis et immédiatement accessible qu’une spécification fonctionnelle, mais dans un monde où l’immense majorité des projets sont développés « à l’arrache » sans spécification détaillée, c’est mieux que rien.
  • Ils offrent 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 testez que l’application finale, vous passerez probablement à côté de ce bug parce qu’il y a peu de chance que vos scénarios conduisent à avoir besoin de signer un message de cette taille exactement. Dans un test unitaire en revanche, vous appelez explicitement la fonction de signature avec un échantillon de messages aussi varié que vous le souhaitez ; et comme vous savez pour l’avoir implémentée que la fonction de signature fonctionne par bloc de 64 octets, vous aurez la présence d’esprit d’effectuer des tests unitaires avec quelques messages autour de cette taille pivot. Un autre exemple est le cas de la fonction permettant de gérer différents encodages de texte. Ce genre de fonction est complexe à tester au sein de l’application finale, parce que cela implique de jouer des cas de test dans différentes langues et sur des machines dont l’OS est configuré en russe, chinois, japonais, etc. En revanche, c’est facile à faire depuis un test unitaire, il suffit de récupérer sur internet des échantillons de textes dans les diverses langues et encodages nécessaires et de construire un test avec.
  • Ils protègent des régressions. Dans la vie d’un projet, il se produit régulièrement des changements majeurs : réorganisation importante du code, portage vers une nouvelle plateforme (x86 vers ARM, ou bien little endian vers big endian), changements dans la chaîne de build, mise à jour du compilateur, etc. Si la majeure partie du projet est couverte par des tests unitaires, vous pouvez vérifier en un instant que tout refonctionne comme avant. De même, si un nouveau développeur arrive et casse quelque chose parce qu’il ne connait pas bien le code, il en est immédiatement informé.

Je ne crois pas à l’intérêt d’une couverture à 100 %. Une application typique contient de nombreuses fonctions triviales, comme les accesseurs sur les variables membres d’un objet, ou bien les gestionnaires d’événements qui ne font qu’appeler une fonction de la couche métier. Il n’y a aucun bénéfice à tester un code aussi simple et ne contenant aucune logique. 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 plusieurs sous-modules. Elles ne sont donc pas accessibles aux tests unitaires et seront plutôt couvertes par des tests systèmes.

Une bonne candidate au test unitaire est une fonction pure. Le résultat d’une telle fonction ne dépend que de ses paramètres d’entrée, elle n’a aucun autre effet que de retourner une valeur et si elle est appelée n fois avec les mêmes paramètres, elle retournera n fois le même résultat. Il faut noter que cela exclut de fait les fonctions s’appuyant sur le « monde extérieur » puisque dès que votre code appelle la fonction time(), effectue une requête dans une base de données, appelle un web service ou lit un fichier sur le disque, son comportement dépend de facteurs externes qu’il n’est pas possible de maitriser dans le contexte d’un test unitaire. Toute la stratégie consiste donc à adopter des habitudes de développement permettant d’écrire le plus possible de fonctions répondant à ce critère de pureté. Un concept essentiel d’architecture logicielle pour y parvenir est l’injection de dépendances.

Imaginons une fonction qui effectue un long calcul et qui doit écrire dans un fichier de log les heures de début et de fin de ce calcul :

void doSomethingWithLog() {
    log(time(nullptr), "starting...");
    doSomeLengthyOperation();
    log(time(nullptr), "done.");
}

Le problème de cette implémentation est qu’elle ne peut pas faire l’objet d’un test unitaire, puisque le résultat obtenu dépend de l’heure à laquelle la fonction est appelée. De plus, le fichier de log contient probablement déjà des lignes provenant des exécutions précédentes, son contenu est donc difficilement testable.

L’injection de dépendance résout le problème en injectant en paramètre tout ce dont la fonction a besoin pour effectuer sa tâche ; l’idée étant d’injecter des objets implémentant le comportement attendu lorsque l’on se trouve dans l’application réelle, et d’injecter des objets mock up au comportement reproductible lorsque l’on se trouve dans un test unitaire. Voici comment procéder.

Définissons d’abord des classes abstraites permettant de récupérer l’heure courante et d’écrire une ligne de texte. (Traditionnellement, de telles classes ne contenant que des fonctions virtuelles pures sont appelées interfaces et leur nom est préfixé par un I majuscule.)

class IClock {
public:
    virtual time_t now() = 0;
};

class ILogger {
public:
    virtual void log(time_t, char const *) = 0;
};

En se basant sur ces deux interfaces, nous pouvons maintenant réécrire notre fonction initiale de telle sorte que les tâches de récupération de l’heure courante et d’écriture dans le log ne soient plus effectuées directement, mais grâce aux objets passés en paramètre :

void doSomethingWithLog(ILogger & lg, IClock & clk) {
    lg.log(clk.now(), "starting...");
    doSomeLengthyOperation();
    lg.log(clk.now(), "done.");
}

À l’intérieur de l’application, on propose des implémentations concrètes de ces deux interfaces qui effectuent réellement les tâches prévues, et on les passe en paramètre à la fonction :

class SystemClock : public IClock {
public:
    time_t now() {
        return time(nullptr);
    }
};

class RealLogger : public ILogger {
public:
    void log(time_t time, char const * message) {
        std::cout << time
                  << " "
                  << message
                  << std::endl;
    }
};

int main() {
    SystemClock clk;
    RealLogger lg;
    doSomethingWithLog(lg, clk);
    return EXIT_SUCCESS;
}

En revanche, pour les tests unitaires, on propose une implémentation concrète du logger qui ne fait que mémoriser ce qu’on lui passe, et une implémentation concrète de l’horloge qui retourne des valeurs fixes connues à l’avance :

class FakeClock : public IClock {
private:
    time_t time_ = 0;

public:
    time_t now() {
        return ++time_;
    }
};

class FakeLogger : public ILogger {
private:
    std::ostringstream out_;

public:
    void log(time_t time, char const * message) {
        out_ << time
             << " "
             << message
             << std::endl;
    }

    std::string getLog() const {
        return out_.str();
    }
};

void Test() {
    FakeClock clk;
    FakeLogger lg;
    doSomethingWithLog(lg, clk);
    assert(lg.getLog() == "1 starting...\n2 done.\n")
}

Utilisée à bon escient, cette technique d’injection de dépendance permet de rendre testable la majeure partie du code métier d’une application. Cela représente un petit effort initial, mais vous en serez récompensé au centuple au premier refactoring majeur !

Concernant l’écriture des tests en eux-mêmes, il existe deux stratégies. 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 certains domaines sensibles), 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 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...else d’une fonction, il est possible d’effectuer une analyse par domaine, c’est-à-dire de déterminer les valeurs pivots où la fonction change de comportement. Cela permet à la fois de ne pas louper des branches de code à tester, et de ne pas perdre du temps à écrire des tests redondants qui couvrent en réalité la même branche.

Enfin, concernant l’implémentation, il existe un assez grand nombre de framework C++ dédiés aux tests unitaires. J’ai l’habitude d’utiliser GoogleTest mais les autres sont très bien également. Un critère de choix peut être l’intégration avec votre IDE favori : par exemple, Xcode est capable de repérer les tests unitaires écrits avec XCTest et affiche directement dans l’éditeur de code en regard de chaque fonction si elle échoue ou si elle passe les tests. L'IDE Qt Creator fait de même avec les tests écrits avec QtTest.

Documentez votre code

La meilleure documentation est le code lui-même. Une documentation séparée n’est le plus souvent qu’une paraphrase du code, autrement dit, c’est-à-dire de l’information dupliquée et toute information dupliquée finit par diverger. De plus, l’immense majorité des développeurs, quand ils arrivent sur une application inconnue, ont le réflexe de se plonger dans le code plutôt que de se plonger dans une documentation séparée. Mais pour que le code puisse faire office de documentation, encore faut-il respecter un certain nombre de bonnes pratiques.

Utilisez des noms significatifs pour toutes les variables, fonctions, classes, fichiers et autres composants du code. Les conventions de nommage apportent déjà une précision technique, comme reconnaître immédiatement une constante du préprocesseur au fait qu’elle est entièrement en majuscule, un nom de classe au fait qu’il est écrit en CamelCase, ou une variable membre au fait qu’elle commence par m_ ; utiliser des noms significatifs apporte une précision fonctionnelle, c’est-à-dire indiquer à quoi sert telle fonction ou ce que stocke telle variable.

Une méthode sert à effectuer une action sur un objet. Choisissez donc toujours un nom qui commence par un verbe d’action. Exemples : getValue, updateQuery, writeDataToFile, etc. Et choisissez bien ! J’ai déjà vu des méthodes qui retournaient void et dont le nom commençait par get, ce qui est légèrement déstabilisant…

Soyez cohérents dans les dénominations : utilisez le même nom partout pour désigner un même concept. Par exemple, si votre application fait du chiffrement, n’appelez pas les fonctions correspondantes cipher dans certaines parties du code et encrypt dans d’autres. À l’extrême, j’ai déjà vu des projets où un concept donné portait un nom différent dans l’interface utilisateur, dans le code et dans la documentation ! Inutile de dire que cela rend la compréhension du projet très complexe pour les nouveaux venus… Il arrive aussi, lorsque le code évolue, que le rôle d’une méthode ou d’une variable change légèrement ; dans ce cas, renommez-la pour lui donner un nom en accord avec sa nouvelle fonctionnalité. La personne qui relira le code dans dix ans vous remerciera. Pensez également à nommer vos fichiers du même nom que la classe qu’ils contiennent : il est plus facile de s’y retrouver si la classe Foo se trouve effectivement dans les fichiers foo.h et foo.cpp.

Structurez spécifiquement votre code pour augmenter les occasions de le documenter. Par exemple, au lieu d’écrire un if...else avec une condition mystérieuse, pré-calculez cette condition dans une variable au nom explicite et testez cette variable dans la condition du if. Ceci est particulièrement important si la condition peut être trompeuse à première vue. Par exemple :

if (user.age >= 18) {
    // do something
}

Au premier abord, on peut penser que ce test sert à vérifier que l’utilisateur est majeur. En revanche, si vous écrivez :

bool canDrive = user.age >= 18;
if (canDrive) {
    // do something
}

Alors on comprend que le véritable test porte sur la possibilité de passer le permis de conduire, ce qui pourra avoir son importance le jour où l’application sera portée dans un pays où l’âge de la majorité ne correspond pas à l’âge requis pour conduire un véhicule.

Documentez plutôt le « pourquoi » que le « comment ». Un commentaire qui ne fait que paraphraser le code qui se trouve au-dessous n’a aucun intérêt :

// on incrémente i
i++;

En revanche, un commentaire qui explique quel est l’algorithme implémenté et pourquoi cet algorithme a été choisi plutôt qu’un autre apporte de l’information utile. Au besoin, ajoutez des liens vers de la documentation extérieure : si vous avez une classe qui implémente la lecture et l’écriture de fichiers textes au format UTF-8, ajoutez un commentaire avec un lien vers la page Wikipedia expliquant l’encodage UTF-8 ; si vous avez une fonction qui implémente base64, ajoutez un lien vers la RFC 4648.

Si vous développez un framework ou une bibliothèque, fournissez-en le mode d’emploi : comment l’installer, comment l’intégrer à son application, ce qu’il apporte, quelle est son architecture générale, quelles sont ses limitations… Par exemple : est-ce qu’il est thread safe, quel est l’encodage du texte attendu par les paramètres de type std::string, quelles méthodes peuvent envoyer des exceptions, etc. J’ai vu beaucoup de bugs provoqués par des frameworks mal utilisés parce que mal documentés ! Vous pouvez générer une partie de cette documentation automatiquement avec un outil tel que Doxygen, qui est capable d’analyser votre code C++ pour en extraire les symboles, en construire le diagramme de classe, la liste des méthodes de chaque classe, et bien d’autres choses encore.

Enfin, cela peut sembler un détail mais ça ne l’est pas du tout : présentez correctement votre code. De même que personne n’a envie de lire un bouquin dont la mise en page est bancale ou la police de caractère trop fantaisiste, aucun développeur n’a envie de lire du code mal présenté, mal indenté, mal aéré. Indentez correctement et toujours de la même façon ; tout comme un écrivain insère un retour à la ligne et crée un nouveau paragraphe pour introduire une nouvelle idée, insérez une ligne vide entre deux blocs de code qui implémentent des choses différentes ; aérez avec des espaces autour des mots clefs et des opérateurs ; etc.