Code testing : Tests unitaires et bonnes pratiques sur un projet Python

Clément Prévosteau, Machine Learning Engineer, partage des éléments de réponses aux questions suivantes : Pourquoi tester ? Quels sont les différents types de tests ? Quelles méthodologies adopter ?

CBTW
L’Actualité Tech — Blog CBTW
10 min readApr 14, 2022

--

Code testing by @christinhumephoto

Cet article s’appuie sur le talk ‘Les tests sur un projet Python’, donné au sein de notre communauté Data.
Les éléments et bonnes pratiques partagées font également référence à l’ouvrage Clean Code : A Handbook of Agile Software Craftsmanship et sont aussi en partie applicables à d’autres langages.

Utilité des tests

Dans une logique de produire un code de qualité, il est nécessaire de réaliser des tests pour :

  • S’assurer que le code fonctionne avec le comportement attendu
  • Éviter les régressions et faciliter la maintenance du code. Il est important de pouvoir modifier son code sereinement, et les tests permettent d’alerter sur un éventuel problème et de faciliter son identification.
    Il faut donc mettre en place et réaliser des tests si l’on souhaite inscrire son code dans la durée.

Classification des tests

On peut classer les tests selon 3 types :

  • Test unitaire
    Il s’agit du test d’une fonction unitaire. C’est un test bas niveau, car on teste une fonction qui ne fait qu’une seule chose.
  • Test d’intégration
    Il s’agit d’un test de bas à haut niveau, avec différents composants
    La taille du composant dépend du projet. Un composant peut aller de la fonction unitaire à tout un back-end.
  • Test end-to-end
    Il s’agit du plus haut niveau de test. Il s’effectue sur l’intégralité d’une application et permet de tester le parcours utilisateur dans son ensemble (navigation, connexion, interactions, …). On vérifie ainsi que tous les composants fonctionnent les uns avec les autres.
    Dans le cas d’un pipeline de données, cela permet de s’assurer que toute la chaîne de transformation de données fonctionne correctement, par exemple entre la base de données et le code Python qui y fait appel pour la réalisation de prédiction.

Choix des tests

La stratégie de test équivaut à la répartition des différents types de tests sur un projet.
Généralement, il est préférable de réaliser beaucoup de tests unitaires, un peu moins de tests d’intégration et encore moins de tests end-to-end.
Effectivement les tests unitaires sont rapides et faciles à écrire, rapides à exécuter et donc peu coûteux. Par contre sur les tests d’intégration, on est sur des choses plus haut niveau. Il faut donc bien réfléchir en amont à ce qu’on attend comme comportement et à ce qu’on veut tester. Ces tests demandent plus de réflexion. Et les tests end-to-end encore davantage.
Là où les tests unitaires peuvent être mis en place rapidement et fréquemment, les tests end-to-end sont plus longs à réaliser. Ils font aussi appel à plusieurs éléments, avec potentiellement des échanges réseaux. Ils peuvent donc prendre plusieurs minutes à s’exécuter, contre quelques secondes pour les tests unitaires.

Dans les projets, la stratégie de tests est donc souvent d’avoir une couverture du code bas niveau par des tests unitaires qui s’approche de 100%. Et pour les tests d’intégration et end-to-end, cela dépend des points stratégiques ou critiques. Il y a une réflexion sur les parties du code qui valent le coup d’être testées. Il faut trouver le bon équilibre entre le coût et ce qui est nécessaire.
A minima sur un projet Python, il est utile de tester le main du projet. S’il faut n’en faire qu’un, c’est le test d’intégration de base à réaliser. Avec un jeu de données et un temps d’exécution raisonnable, cela permet de s’assurer du bon fonctionnement du pipeline lors des différentes étapes. Et même si ce n’est pas réalisé sur toutes les données, ça reste suffisant pour limiter les risques.

Focus sur les tests unitaires

Il n’y a pas de test unitaire sans fonction unitaire.
Une fonction unitaire est une fonction en code bas niveau, qui ne fait qu’une seule chose. Et d’ailleurs toute fonction ne devrait faire qu’une seule chose.

‘Function should do one thing, should do it well and should do it only.

Faire une seule chose signifie que la fonction ne contient que du code d’un seul niveau d’abstraction.

Dans une fonction unitaire, il faut donc un seul niveau d’abstraction. Si on mélange les différents niveaux, alors la fonction ne fait plus qu’une seule chose et ce n’est pas une fonction unitaire.
S’il est nécessaire d’utiliser un mock pour tester une fonction unitaire, cela veut aussi probablement dire que la fonction n’est pas unitaire et qu’elle mélange plusieurs niveaux d’abstractions. Il faut alors rassembler le code bas niveau dans une ou plusieurs fonctions unitaires intermédiaires.

Une autre façon de voir que sa fonction n’est pas unitaire est la complexité à réaliser un test. Quand on essaie de faire un test et qu’il devient compliqué de réfléchir à tous les cas possibles, cela veut dire que la fonction fait sûrement plusieurs choses. Plus la fonction est complexe, plus les cas possibles vont être exponentiels et nécessiter du temps. Il vaut alors mieux redécouper en plusieurs fonctions unitaires plus simples à tester.

Il peut tout de même rester compliqué de prévoir tous les cas d’usage en amont. On peut alors commencer par spécifier un seul cas spécifique, puis ajouter des cas plus généralistes ensuite. C’est ce que nous apprend la méthode du Test Development Driven.
On écrit un test simple, puis le code minimal pour que la fonction passe. Puis on ajoute des valeurs avec assez d’exemples pour que la fonction soit plus générale. Le test et le code sont complexifiés petit à petit.

Il est aussi important de garder les fonctions de petite taille, avec un idéal autour de 5 lignes. Entre 10 et 20 lignes il est sûrement possible de faire plus court. Et au-delà de 20 lignes c’est forcément trop grand.
L’idée est que quand un lecteur lit une fonction, il puisse la comprendre clairement, facilement et rapidement. S’il y a trop d’étapes (ou de lignes) le lecteur va se perdre dans la compréhension générale de la fonction. Il vaut mieux rassembler ces différentes étapes sous forme de quelques grandes étapes logiques (ou sous-fonctions bien nommées). Laissant le soin au lecteur d’aller regarder les définitions de ces sous-fonctions s’il a besoin de plus de détails.

Approches et outils de code testing

Approche FIRSST

Les tests doivent être autant que possible :

Fast
Un test doit être rapide à exécuter, pour qu’il puisse être exécuté régulièrement. Plus un bug est détecté tôt, plus il est facile de le corriger.

Independent
Un test ne doit pas dépendre d’un autre test.

  • Indépendant dans l’exécution : Un test ne doit pas avoir besoin qu’un autre test réussisse pour pouvoir s’exécuter.
    Il est facile d’écrire des tests qui dépendent du test précédent, mais ce n’est pas souhaité. Car si un test échoue, il doit échouer pour une seule raison. Donc si plusieurs tests échouent pour la même raison, ce n’est pas pertinent. On doit être capable d’identifier le test déclaré comme échoué, selon la raison de l’échec. Alors que si pour une simple raison, il y a une trentaine de tests qui échouent, il sera plus compliqué de savoir quelle est l’origine du bug.
    Il faut donc des tests indépendants dans leur exécution, pour que quand il y ait un bug, un seul test échoue. Cela permet de mieux identifier et comprendre le problème.
  • Indépendant dans l’évolution : Un test doit être facile à modifier sans avoir à toucher à d’autres tests. Les inputs des tests ne doivent donc pas être partagés. Un test usuel comprend un jeu de données, qui permet de vérifier qu’avec tel input, j’obtiens bien tel output.
    Lorsque les inputs sont liés entre eux et utilisés dans plusieurs tests, cela nuit à la maintenabilité des tests. Car pour modifier un test et l’input d’un test, il faudra potentiellement changer le comportement d’un autre test.
    Donc pour chaque test son input personnalisé, adapté au test.
    Il suffit d’ailleurs de mettre juste les données nécessaires pour que le test passe avec tous les cas possibles. Il n’y a pas besoin de créer des cas qui ne sont pas utiles pour le test en question. Ce qui peut arriver, si on utilise un input en entrée de plusieurs tests.
  • Indépendant dans les résultats : Deux tests ne doivent pas pouvoir échouer pour les mêmes raisons, d’autant plus pour les tests unitaires.
    On teste un comportement avec un test et cela signifie aussi qu’on ne teste pas plusieurs fois un même comportement. Par exemple, un test d’intégration ne doit pas tester ce qui a déjà été testé avec des tests unitaires.

Repeatable : Un test unitaire doit pouvoir être répété peu importe l’environnement de test. S’il échoue une fois, il doit échouer systématiquement sans modification. Un test qui échoue en production doit donc aussi échouer en développement.
Pour les tests d’intégration et les tests end-to-end cela peut être différent selon les environnements de test.

Self-Validating : Le résultat d’un test doit être direct. Soit le test a réussi, soit il a échoué. Son résultat est un résultat booléen et doit s’afficher clairement. Ce qui est automatique avec l’outil Pytest.

Self-Documentation : Un test (unitaire) bien écrit peut suffire comme documentation de la fonction testée.
Il est donc nécessaire de définir dans son test ce qui est attendu sur le comportement de la fonction. Ainsi on comprend le rôle de la fonction rien qu’en lisant le test. C‘est la même idée que dans l’approche TDD, où on écrit d’abord la définition de la fonction en termes de comportement, avant même d’écrire le moindre code. Un test qui se suffit comme documentation est donc un test complet.

Timely : Un test doit être écrit au bon moment, c’est-à-dire juste avant d’écrire le code de production (TDD). Il faut aussi réfléchir en amont pour n’écrire que ce qui est utile à son fonctionnement. On réfléchit d’abord au rôle précis de la fonction et au comportement attendu, plutôt que de commencer à l’écrire et à se perdre dans les détails. Cela nous oblige à écrire des fonctions faciles à tester. En écrivant du code sans penser aux tests en amont, on peut ensuite se rendre compte qu’il est finalement difficilement testable.
Cette approche a souvent pour bénéfice d’avoir des fonctions, et plus généralement du code, bien découplé.

Framework Pytest

Pytest est un framework de test avec les caractéristiques suivantes :

  • Il est basé sur une syntaxe proche de Python avec l’utilisation d’assert.
  • Toutes les fonctions qui commencent par test_ sont considérées comme des tests. Un test est donc une fonction dans laquelle il y a un assert.
  • Des fixtures modulaires sont disponibles pour gérer les ressources de tests et leur durée de vie.
  • Il a une architecture faite pour être utilisée avec des plug-ins, et donc il y a plein de packages qui permettent d’étendre les possibilités.
  • Il regroupe une grande communauté d’utilisateurs.

Convention Given When Then

Une convention qui peut être utile pour rendre les tests plus lisibles est la convention Given When Then.
L’idée est de séparer son test en trois parties afin d’organiser tous ses tests de la même manière :

  • Given : Partie où on définit les inputs et les outputs attendus de la fonction testée
  • When : Partie où la fonction est exécutée
  • Then : Partie où sont réalisées les vérifications ou assert statements

Utiliser une telle convention permet de rendre les tests facilement lisibles, car ils sont toujours organisés de la même manière.
Quand on est habitué à cette convention, on lit et on comprend très rapidement n’importe quel test l’utilisant.
Il y a plein de manières d’écrire un test, mais cette convention permet d’être clair et de savoir où chercher l’information.

Pandas

Et pour écrire un test avec des dataframes avec Pandas, voici quelques bonnes pratiques :

Sur la partie Input dataframe (Given) :

  • Ne spécifier que les colonnes nécessaires au fonctionnement de la fonction.
    Souvent on va passer par un pipeline de code, mais pour la fonction en question on ne va avoir besoin que de 2 ou 3 colonnes pour fonctionner. Dans ce cas-là, il ne faut mettre que ces colonnes-là. Cela permet d’être explicite et de comprendre que pour que la fonction fonctionne il n’y a que ces colonnes qui rentrent en compte.
  • Définir les dataframes à l’intérieur du test.
    Cela permet d’avoir toutes les informations nécessaires à la compréhension en lisant le test.
  • Ne pas partager des inputs dataframes entre plusieurs tests. Chaque input dataframe devrait être construit de manière spécifique à son test.
  • Dans un dataframe les lignes représentent les différents cas possibles pour la fonction. Il faut donc bien un cas par ligne et utiliser autant de lignes que nécessaires pour avoir tous les cas possibles.
    Et si ce n’est pas clair de comprendre de quel cas il s’agit, ne pas hésiter à mettre un commentaire à côté de la ligne.
  • Pour écrire un dataframe dans son test, il faut également plutôt utiliser ce format :

Ce qui rend les choses très lisibles, presque comme dans un tableau Excel.

Sur la partie Testing dataframes (Then) :

  • Ne pas utiliser un assert, mais utiliser :
  • Pandas va tester les index, donc il s’attend à ce que les index des dataframes soient exactement les mêmes. Sauf que quand on définit un ouput, on ne définit pas forcément un numéro d’index. Pour pallier cela, on peut utiliser .reset_index(drop=True) sur le dataframe.

En vidéo

Retrouvez l’intégralité du talk de Clément sur notre chaîne Youtube :

Nous publions régulièrement des articles sur des sujets de développement produit web et mobile, data et analytics, sécurité, cloud, hyperautomatisation et digital workplace.
Suivez-nous pour être notifié des prochains articles et réaliser votre veille professionnelle.

Retrouvez aussi nos publications et notre actualité via notre newsletter, ainsi que nos différents réseaux sociaux : LinkedIn, Twitter, Youtube, Twitch et Instagram

Vous souhaitez en savoir plus ? Consultez notre site web et nos offres d’emploi.

--

--

CBTW
L’Actualité Tech — Blog CBTW

Nos experts partagent leur vision et leur veille en développement web et mobile, data et analytics, sécurité, cloud, hyperautomation et digital workplace.