tominardi.fr

La qualité dans un projet web, Partie 5 - Tests unitaires

29 janvier 2023

On reprend la rédaction de cette série sur la qualité avec un point élémentaire : les tests unitaires. Élémentaires ? C'est ce que nous allons voir.

Note : L'écriture de cet article a été assistée par ChatGPT. Les points abordés étant tellement classiques, j'en ai profité pour jouer un peu avec la célèbre IA, pour me donner des exemples ou une trame pour les définitions. Au final, je me suis surtout appuyé sur lui pour m'aider à ne rien oublier. Je raconterai sans doute ça dans un autre article. Merci également à Jean pour la relecture. Bonne lecture !

Le dossier

Les tests unitaires

tldr; Avant de démarrer, un petit rappel du contexte : cet article fait partie d'une série d'articles dans laquelle j'évoque les bonnes pratiques que je mets en place lorsque je démarre un nouveau projet. Ainsi, il n'a pas vocation à rentrer dans le détail du test unitaire, mais vise à vous encourager à mettre en place vos tests unitaires avant de commencer votre code, et d'établir votre stratégie.

Si vous souhaitez un gros guide bien gras sur les tests unitaires en python, je vous conseille l'excellent site de Sam et Max et leur série un gros guide bien gras sur les tests unitaires en python. Je suis d'ailleurs ravi de voir qu'ils ont republié leur site, qui est une mine d'or pour tout ce qui concerne le python.

On est bon ? On peut débuter ? OK ChatGPT, c'est quoi, un test unitaire ?

Les tests unitaires : les fondamentaux

Tester, c'est douter, disait le sage, en oubliant de préciser que douter est souvent salutaire, à bonne dose.

Les tests unitaires sont un outil essentiel pour assurer la qualité de votre code en s'assurant qu'il fonctionne correctement dans des conditions précises. Le principe est le suivant : vous écrivez du code de test qui va exécuter le code que vous avez écrit, et vérifier que le résultat retourné est celui attendu.

En Python, il existe de nombreux frameworks de tests unitaires tels que unittest, pytest, doctest etc. Ils permettent de définir des fonctions ou des méthodes spécifiques, appelées "tests", qui vérifient le comportement attendu de votre code.

Voici un exemple de test unitaire simple avec unittest:

import unittest

def add(a, b):
    return a + b

class TestAdd(unittest.TestCase):
    def test_add(self):
        result = add(10, 5)
        self.assertEqual(result, 15)

Les avantages à écrire des tests unitaires sont multiples. On peut par exemple citer :

Mesure de la couverture

coverage est un outil de mesure de couverture de code en python. Il permet de mesurer quelles parties de votre code sont effectivement exécutées lors des tests unitaires. Il permet également de générer des rapports de couverture de code qui peuvent vous aider à identifier les parties de votre code qui ne sont pas testées, lignes par lignes.

Pour utiliser coverage dans votre projet, vous devez d'abord l'installer en utilisant pip :

pip install coverage

Ensuite, vous pouvez exécuter vos tests en utilisant la commande coverage run pour exécuter vos tests avec la couverture de code activée :

coverage run -m unittest discover

Une fois que vos tests ont été exécutés, vous pouvez utiliser la commande coverage report pour générer un rapport de couverture de code :

coverage report
coverage html  # génère un rapport html qui permet d'avoir le détail des lignes testées, par module.

Coverage retourne le pourcentage de code testé. L'argument --fail-under permet de considérer que la couverture n'est pas suffisante en dessous d'un certain seuil et retourne alors un code d'erreur. C'est utile pour la CI ou pour les hooks de pré-commit, deux notions que nous aborderons plus tard. Lorsqu'on établit sa stratégie de couverture, on s'accorde en général sur un pourcentage de code testé. Celui-ci peut-être haut (95 à 100% de code testé), mais il faudra garder en tête de bien se concentrer sur le code qu'on cherche réellement à tester.

Je m'explique : votre projet peut embarquer des scripts externes que vous ne souhaitez pas forcément tester. Vous pouvez exclure des fichiers ou des répertoires via la configuration de votre projet. Voici un extrait d'un de mes setup.cfg :

[coverage:run]
omit=
    */.local/*
    */commands/*
    *__init__.py
    */tests/*

Dans certains cas, vous voudrez également ignorer certaines lignes. Le commentaire # pragma: no cover permet de signaler à l'outil coverage qu'une ligne de code ou un bloc de code ne doit pas être pris en compte dans les calculs de couverture de code. On peut être amené à l'utiliser sur du code mort, du code généré automatiquement ou du code temporaire. Dans l'exemple suivant, j'ai choisi d'ignorer une branche parce qu'il s'agit d'un fichier de configuration lié à un environnement spécifique et que prendre en compte ce cas là avait vraiment peu d'intérêt, surtout vu le code.

try:
    with open(os.path.join('home', TARGET_ENV, 'secrets.json'), encoding='utf-8') as f:
        secrets = json.loads(f.read())
except FileNotFoundError:  # pragma: no cover
    secrets = json.loads('{}')

J'aurais pu arriver à un résultat similaire en baissant le niveau de couverture attendu. En le faisant de cette manière, je garde un niveau d'exigence de couverture haut, et je traite seulement certains cas en exception.

Mettre en place ses tests dès le démarrage du projet

Une approche censée du problème qui nous intéresse serait de s'occuper des tests après avoir écrit le code. Et donc de reporter les réponses aux différentes questions posées à la fin. À première vue, cela pourrait avoir plusieurs avantages : choisir l'outillage de test en fonction de la réalité du code ayant été écrit, se concentrer d'abord sur l'essentiel et gagner en productivité, etc.

En réalité, qu'on choisisse d'écrire ses tests avant ou après avoir écrit le code, les choix et la mise en place de l'outillage doit se faire en amont. Il est en effet plus facile et moins coûteux de mettre en place les tests et les outils de test au début du développement plutôt qu'à la fin. Lorsque le projet est encore en développement, il est plus aisé de comprendre les exigences et les fonctionnalités attendues, et, de ce fait, de les tester. Si vous attendez d'avoir terminé, la rédaction des tests va être laborieuse, et obtenir un bon niveau de couverture va vous sembler une épreuve insurmontable. Vous allez associer l'exercice du test à une tâche longue et compliquée, ce qui ne vous encouragera pas à changer de stratégie à l'avenir. Vous allez garder un niveau d'exigence faible, l'effort de test ne semblant pas en valoir la chandelle.

Donc, avant de démarrer, vous devez :

  1. Choisir un outil de test
  2. Choisir un outil de mesure de la couverture
  3. Choisir une stratégie de tests : À quel moment écrire les tests, et de quelle manière ?
  4. Être capable de jouer les tests facilement

Je vous conseille par exemple de démarrer en écrivant un DummyTest, c'est-à-dire un test idiot qui vous permet de vérifier que le framework fonctionne bien :

import unittest

class TestDummy(unittest.TestCase):
    def test_1_equal_1(self):
        self.assertTrue(1 == 1)

Il vous permettra uniquement de vérifier que vos commandes lancent bien les tests et la couverture. Une fois que vous aurez écrit des vrais tests, pensez à le supprimer.

Les pièges avec les TU

Le zéro bug, ça n'existe pas. Ce n'est pas parce que vous avez une couverture de code à 100% qu'on est libre de bugs.

Il est important de ne pas oublier de tester les cas d'erreur et les cas limites. L'exemple le plus simple que j'ai trouvé pour illustrer ça, c'est le suivant :

import unittest

def divide(a, b):
    return a / b

class TestDivide(unittest.TestCase):
    def test_divide(self):
        result = divide(10, 5)
        self.assertEqual(result, 2)

C'est super, on test 100% des lignes de la méthode, on a l'impression que tout est OK, nos tests sont au vert.

Et puis un jour, quelqu'un fait divide(5, 0). Et là, c'est le drame.

Qu'est-ce qu'on apprend ? On ne doit pas focaliser toute son attention sur l'outil coverage. Ce n'est qu'un outil qui nous donne une direction et des idées de quoi tester.

Un autre problème va être de prendre une approche tests unitaires un peu trop bornée à sa définition d'école :

En programmation informatique, le test unitaire (ou « T.U. », ou « U.T. » en anglais) est une procédure permettant de vérifier le bon fonctionnement d'une partie précise d'un logiciel ou d'une portion d'un programme (appelée « unité » ou « module »). (wikipedia)

Une interprétation assez répendue du test unitaire est qu'il faut rédiger un test pour chaque méthode, comme autant d'unités indépendantes, en limitant au maximum la dépendance à d'autres unités. Dans un contexte de la vie réelle, cela peut vite devenir lourd. On peut se retrouver avec des tests trop nombreux, compliqués à maintenir, pour lesquels, surtout, on perd de vue l'aspect métier et la compréhension de ce qui est vraiment testé.

Mais cette approche "unitaire" à la façon "je test chaque fonction de mon code indépendament" est justement un piège à éviter. Il faut voir les choses à un niveau un petit peu plus haut. Illustrons ça.

Un projet Django classique est constitué de modèles qui représentent les données et la manière d'y accéder, et de vues, qui récupèrent ces données et les retourne à l'utilisateur à l'aide de templates ou autre formateurs (une API Rest par exemple). Une stratégie de test pour un projet Django peut-être de tester principalement les vues, qui testeront les modèles en cascade.

Par exemple, pour l'application Django suivante :

from django.db import models
from django.views import generic

class Item(models.Model):
    name = models.CharField(_('name'), max_length=255)
    creation_date = models.DateField(_('creation date'))

class ItemListView(generic.ListView):
    model = Item

Simulez simplement une requête http sur la vue affichant la liste :

from django.urls import reverse_lazy
from django.test import TestCase

class TestViews(TestCase):
    def test_list_item(self):
        response = self.client.get(reverse_lazy('item-list'))
        self.assertEqual(response.status_code, 200)
        # et tous les tests sur les résultats

Plutôt que des tests unitaires sur le modèle et d'autres, redondants, sur la vue. Des tests unitaires qui, en plus, dans ce cas précis, vous feront utiliser des méthodes Django que vous ne réécrivez même pas et qui sont déjà testées au niveau du framework.

Ca ressemble à du test d'intégration, d'un certain point de vue c'est le cas. Mais le débat de fond, qui mériterait un article entier, concerne la définition de l'unité de code, du module, tel que donné dans la définition wikipedia des tests unitaires. Ici je considère que mon unité de code est mon module Django, et c'est très acceptable que mon test soit réalisé en cascade. Dans d'autres cas, si je personnalise mon modèle par exemple, j'aurais envie d'écrire des tests unitaires spécifiques au niveau du modèle.

Le TDD, l'ATTD, et tout ces trucs là, qu'est-ce que j'en fais ?

Comme dit plus tôt, et très basiquement, le Test Driven Development consiste à organiser sa conception sur les tests, afin de s'appuyer dessus pour rédiger le code. La rédaction du test nous permet de nous interroger sur le résultat attendu, et donc à concevoir une API de haut niveau pour son module, son application. On réfléchi d'abord à ce dont on a besoin, en bout de ligne, puis à la manière dont on arrive au résultat. Et on implémente le code au fil de l'eau. En plus il y a un côté très pythonique : on fait d'abord fonctionner le code, on l'optimise après.

Pour plus de détail sur le TDD, je vous propose un peu de lecture avec cet excellent article qui revient sur l'histoire et les spécificités du TDD.

Ici, pour faire très simple, imaginons que j'ai envie de coder une calculatrice (ce qui serait un peu stupide, parce qu'un langage de programmation est grosso-modo une calculatrice, mais admettons).

Je pourrais imaginer que cette calculatrice puisse s'utiliser comme ça :

import unitest
from ma_calculatrice import Calculatrice

class TestDivide(unittest.TestCase):
    def setUp(self):
        self.la_calculatrice = Calculatrice()

    def test_add(self):
        self.assertEqual(self.la_calculatrice.add(4, 5), 9)

    def test_subtract(self):
        self.assertEqual(la_calculatrice.subtract(6, 3), 3)

    def test_multiply(self):
        self.assertEqual(la_calculatrice.multiply(5, 5), 25)

    def test_divide(self):
         self.assertEqual(la_calculatrice.divide(4, 2), 2)

On peut arriver au même résultat avec de la conception papier, mais le fait de passer par des outils de tests unitaires va nous apporter plusieurs avantages :

L'approche ATTD, pour Acceptance Tests Driven Development, oriente plutôt les tests sur des considérations métier que techniques. La limite est fine : vos tests unitaires devraient toujours refléter des considérations métiers, documenter des comportements attendus, etc.

ChatGPT l'explique de la manière suivante :

TDD (Test Driven Development) et ATDD (Acceptance Test Driven Development) sont tous les deux des méthodes de développement de logiciels qui mettent l'accent sur les tests en tant qu'outil de développement. Cependant, ils se concentrent sur des types de tests différents.

TDD se concentre sur les tests unitaires, qui sont des tests qui vérifient le comportement d'une petite unité de code, généralement une fonction ou une méthode. Les développeurs écrivent d'abord des tests unitaires pour une fonctionnalité donnée, puis écrivent le code pour faire passer ces tests. Le but est de garantir que le code fonctionne comme prévu et qu'il n'y a pas de régression lors de l'ajout de nouvelles fonctionnalités.

ATTD, en revanche, se concentre sur les tests d'acceptation, qui sont des tests qui vérifient que le système répond aux besoins des utilisateurs finaux. Les tests d'acceptance sont généralement écrits par les utilisateurs finaux ou les représentants d'un domaine d'application, et ils décrivent les comportements et les résultats attendus du système. Les développeurs utilisent ces tests pour développer le système de manière à répondre aux besoins des utilisateurs finaux.

En résumé, la différence entre TDD et ATDD est que TDD se concentre sur les tests unitaires pour garantir que le code fonctionne comme prévu, tandis qu'ATTD se concentre sur les tests d'acceptation pour garantir que le système répond aux besoins des utilisateurs finaux. Les deux approches peuvent être utilisées ensemble pour garantir un développement de logiciel de qualité.

Les bons outils

Avant de clore ce chapitre sur le test unitaire, voici les liens vers les frameworks de tests python dont j'ai parlé au début :

C'est tout pour aujourd'hui

Et voilà pour l'entrée en matière sur les tests unitaires. La prochaine fois, nous parlerons de la gestion des données de test. Il y a beaucoup à dire.

Tumblr
Pinterest
LinkedIn
WhatsApp

<< La qualité dans un projet web, Partie 4 - Code Linting >> Note de service

2022 - tominardi.fr