Aller au contenu

Maitriser Unittest - Partie 1

Mise à jour :

logo python

Si vous avez suivi mon précédent guide sur les tests Python, vous savez déjà que le TDD consiste à écrire vos tests avant même d’écrire une ligne de code. Avec le TDD, chaque fonctionnalité est soigneusement définie, testée et optimisée en suivant le cycle Red-Green-Refactor. Mais pour mettre en pratique cette approche, il faut un outil capable de structurer ces tests. C’est là que unittest entre en jeu.

unittest, la bibliothèque standard de Python pour les tests unitaires, est l’outil parfait pour appliquer le TDD. Pourquoi ? Parce qu’il est robuste, bien documenté, et surtout intégré directement dans Python. Vous n’avez pas besoin d’installer de librairie supplémentaire : tout est prêt à l’emploi.

Codez votre premier test

Avant de plonger dans l’utilisation de unittest, la bonne nouvelle, c’est qu’il n’y a rien à installer ! En effet, unittest fait partie intégrante de la bibliothèque standard de Python. Tout ce dont vous avez besoin, c’est une installation fonctionnelle de Python. Maintenant, voyons comment organiser votre environnement pour bien démarrer.

Organiser vos fichiers et dossiers pour les tests

Une bonne organisation de vos fichiers est essentielle pour un projet maintenable. Voici une structure classique que j’aime utiliser :

  • Répertoiremon_projet/
    • Répertoiresrc/ # Contient le code source
      • exemple.py # Exemple de fichier de code
    • Répertoiretests/ # Contient vos tests unitaires
      • test_exemple.py # Tests pour exemple.py
      • init .py # Indique que c’est un package Python
    • requirements.txt # Optionnel, pour gérer les dépendances

Avec cette structure :

  • Le dossier src/ contient votre code principal.
  • Le dossier tests/ est dédié à vos fichiers de tests. Chaque fichier de test correspond idéalement à un fichier dans le dossier src/.

Configurer votre environnement pour unittest

Créez un fichier de test pour vous assurer que tout fonctionne correctement. Voici un exemple minimaliste dans tests/test_exemple.py :

import unittest
class TestSetup(unittest.TestCase):
def test_environment(self):
self.assertEqual(1 + 1, 2)
if __name__ == '__main__':
unittest.main()

Ensuite, lancez le fichier avec cette commande :

Terminal window
python -m unittest .

Si tout est bien configuré, vous verrez une sortie similaire à celle-ci :

.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK

Les bases de unittest : assertions et structure

Maintenant que votre environnement est prêt, il est temps de plonger dans les bases de unittest. Dans cette partie, nous allons découvrir les assertions, qui sont au cœur des tests, et la manière de structurer vos fichiers pour que tout reste clair et efficace.

Comprendre les assertions

Les assertions sont des commandes utilisées pour vérifier que le résultat attendu correspond bien au résultat obtenu. Si une assertion échoue, le test est marqué comme échoué. Voici quelques assertions courantes que vous utiliserez souvent :

  • assertEqual(a, b) : Vérifie que a est égal à b. Exemple :

    self.assertEqual(2 + 2, 4)
  • assertNotEqual(a, b) : Vérifie que a n’est pas égal à b.

  • assertTrue(expr) : Vérifie que expr est vrai. Exemple :

    self.assertTrue(3 > 2)
  • assertFalse(expr) : Vérifie que expr est faux.

  • assertIs(a, b) : Vérifie que a et b sont le même objet (par identité).

  • assertIsNone(expr) : Vérifie que expr est None.

  • assertRaises(exception, callable, *args, **kwargs) : Vérifie qu’une exception spécifique est levée lors de l’appel d’une fonction ou méthode.

Avec ces assertions, vous pouvez couvrir presque tous les cas possibles dans vos tests.

Appliquer le cycle TDD avec unittest

Le Test-Driven Development (TDD) est une approche méthodique où les tests guident le développement de votre code. Avec unittest, ce processus devient fluide et intuitif. Dans ce chapitre, je vais vous montrer comment suivre le cycle Red-Green-Refactor avec des exemples concrets.

Le cycle TDD : Red-Green-Refactor

  1. Red : Écrire un test qui échoue Vous commencez par écrire un test pour une fonctionnalité que vous souhaitez implémenter. Comme le code n’existe pas encore (ou est incomplet), ce test échouera. Cet échec est essentiel pour vérifier que le test détecte bien les problèmes.

  2. Green : Écrire juste assez de code pour réussir le test Ensuite, vous implémentez le minimum de code nécessaire pour que le test passe. L’idée ici n’est pas d’écrire du code parfait, mais de prouver que la fonctionnalité de base fonctionne.

  3. Refactor : Améliorer le code sans casser le test Une fois que le test passe, vous pouvez améliorer votre code : le rendre plus lisible, optimiser les performances, ou éliminer les redondances. Les tests déjà écrits garantissent que votre refactorisation n’introduit pas de bugs.

Exemple pratique : Une fonction de calcul d’intérêts

Prenons un cas simple : une fonction qui calcule des intérêts simples. Voici comment appliquer le TDD.

Étape 1 : Red - Écrire un test qui échoue

Commençons par écrire un test pour une fonction appelée calculer_interets. Cette fonction n’existe pas encore, mais nous définissons ce qu’elle est censée faire :

tests/text_interets.py
import unittest
from src.interets import calculer_interets # Importer la fonction à tester
class TestInterets(unittest.TestCase):
def test_calculer_interets(self):
resultat = calculer_interets(1000, 0.05, 2)
self.assertEqual(resultat, 100) # 1000 * 0.05 * 2
if __name__ == '__main__':
unittest.main()

Créer le fichier du code en ne déclarant que la fonction :

src/interets.py
def calculer_interets(capital, taux, duree):
# L'implémentation initiale peut être minimale pour le cycle Green.
pass # Notre fonction ne fait rien

En exécutant ce test, vous obtiendrez une erreur du type :

F
======================================================================
FAIL: test_calculer_interets (test_exemple.TestInterets.test_calculer_interets)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/bob/Projets/unittest/test_exemple.py", line 8, in test_calculer_interets
self.assertEqual(resultat, 100) # 1000 * 0.05 * 2
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: None != 100
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (failures=1)

Étape 2 : Green - Implémenter juste assez de code

Créons maintenant une version minimale de la fonction pour faire passer le test :

def calculer_interets(capital, taux, duree):
return capital * taux * duree

En relançant le test, voici le résultat :

.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK

Le test passe. Mission accomplie pour cette étape !

Étape 3 : Refactor - Améliorer le code

Regardons s’il y a des améliorations possibles. Par exemple, nous pourrions ajouter une validation pour les entrées négatives ou des taux supérieurs à 1 (ce qui serait irréaliste pour un taux d’intérêt annuel).

Ajoutons un nouveau test pour vérifier ce comportement :

def test_valeurs_negatives(self):
with self.assertRaises(ValueError):
calculer_interets(-1000, 0.05, 2)

En réexécutant tous les tests, vous validez que la refactorisation n’a pas cassé les fonctionnalités existantes.

.F
======================================================================
FAIL: test_valeurs_negatives (test_exemple.TestInterets.test_valeurs_negatives)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/bob/Projets/unittest/test_exemple.py", line 11, in test_valeurs_negatives
with self.assertRaises(ValueError):
AssertionError: ValueError not raised
----------------------------------------------------------------------
Ran 2 tests in 0.000s
FAILED (failures=1)

Codons notre fonction pour qu’elle lève le raise attendu :

def calculer_interets(capital, taux, duree):
if capital < 0 or taux < 0 or duree < 0:
raise ValueError("Les valeurs doivent être positives.")
return capital * taux * duree

On relance les tests :

..
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK

Yes

Les bénéfices du cycle TDD avec unittest

  1. Développement guidé par les fonctionnalités : Vous écrivez du code en réponse à des besoins précis, ce qui évite de coder des fonctionnalités inutiles.
  2. Moins de bugs : Les tests vous alertent immédiatement si une modification casse une fonctionnalité existante.
  3. Code maintenable : Le processus de refactorisation garantit que votre code reste propre et facile à comprendre.

Un conseil pour bien démarrer

Au début, il peut être tentant d’écrire une grande partie du code avant les tests. Résistez à cette tentation ! Respecter le cycle Red-Green-Refactor vous aidera à développer un code plus robuste, tout en vous habituant à penser aux tests comme une partie intégrante du développement.

Éviter les pièges courants avec unittest

Même si unittest est un outil puissant, il est facile de tomber dans des pièges qui rendent vos tests moins efficaces, voire inutiles. Dans ce chapitre, nous allons examiner les erreurs courantes et les meilleures pratiques pour les éviter.

Écrire des tests trop dépendants du code

Le piège : Vos tests sont si étroitement liés à l’implémentation actuelle qu’une petite modification (comme un changement de nom de méthode ou de structure) casse de nombreux tests. Cela rend leur maintenance difficile et décourage l’écriture de tests.

Comment l’éviter :

  • Testez le comportement, pas l’implémentation. Concentrez-vous sur ce que le code doit faire, et non sur comment il le fait.
  • Si possible, utilisez des interfaces stables ou des fonctions publiques comme points d’entrée pour vos tests.

Exemple mauvais :

self.assertEqual(mon_objet._attribut_cache, 42) # Ne testez pas un attribut interne.

Exemple correct :

self.assertEqual(mon_objet.get_valeur(), 42) # Testez la méthode publique.

Ne pas tester les cas limites

Le piège : Vous testez uniquement les scénarios “heureux”, c’est-à-dire les cas où tout fonctionne parfaitement, en oubliant les situations inhabituelles ou les erreurs possibles.

Comment l’éviter :

  • Ajoutez des tests pour les entrées non valides, comme des valeurs nulles, des nombres négatifs, ou des chaînes vides.
  • Simulez des erreurs système, comme une base de données inaccessible ou un fichier introuvable.

Exemples à tester :

  • Que se passe-t-il si on passe None à votre fonction ?
  • Votre fonction retourne-t-elle une exception appropriée pour un taux d’intérêt négatif ?
def test_valeurs_invalides(self):
with self.assertRaises(ValueError):
calculer_interets(-1000, 0.05, 2)

Ignorer la lisibilité des tests

Le piège : Vos tests sont complexes, avec des noms de méthodes peu explicites ou des blocs de code durs à lire. Les tests deviennent aussi difficiles à comprendre que le code qu’ils testent.

Comment l’éviter :

  • Donnez des noms explicites à vos tests pour indiquer clairement leur objectif.
  • Utilisez des commentaires ou des docstrings pour expliquer des scénarios complexes.

Exemple mauvais :

def test_1(self):
# Un nom de méthode inutile qui ne dit rien sur le test.

Exemple correct :

def test_calcul_interets_valeurs_positives(self):
"""Test du calcul d'intérêts avec des valeurs positives standard."""
self.assertEqual(calculer_interets(1000, 0.05, 2), 100)

Tester plusieurs choses dans un seul test

Le piège : Vous combinez plusieurs vérifications dans un seul test. Si une vérification échoue, il est difficile de savoir exactement où se situe le problème.

Comment l’éviter :

  • Chaque test doit avoir un objectif unique. Divisez les tests si nécessaire.
  • Utilisez des classes ou des fichiers séparés pour tester des fonctionnalités distinctes.

Exemple mauvais :

def test_calculations(self):
self.assertEqual(addition(1, 2), 3)
self.assertEqual(soustraction(5, 3), 2)

Exemple correct :

def test_addition(self):
self.assertEqual(addition(1, 2), 3)
def test_soustraction(self):
self.assertEqual(soustraction(5, 3), 2)

Ne pas exécuter les tests régulièrement

Le piège : Vous écrivez des tests, mais vous les exécutez rarement, ou uniquement avant une mise en production. Cela augmente le risque de bugs.

Comment l’éviter :

  • Exécutez les tests à chaque modification majeure du code.
  • Intégrez les tests dans un pipeline CI/CD pour les exécuter automatiquement à chaque commit.

Exemple : Avec Gitlab CI, configurez un fichier .gitlab-ci.yml pour automatiser vos tests :

stages:
- test
test:
stage: test
script:
- python -m unittest discover -s tests

Conclusion

Félicitations ! Vous avez maintenant une solide compréhension des bases de unittest : de l’écriture de tests simples, à leur structuration en suites bien organisées, tout en évitant les pièges courants. Vous avez également appris à exécuter et regrouper vos tests pour garantir un workflow fluide et maintenable.

Mais le chemin ne s’arrête pas là. Les projets réels sont rarement isolés et impliquent souvent des dépendances externes : bases de données, API, ou autres services tiers. Tester ces interactions peut vite devenir compliqué, mais heureusement, nous avons des outils comme les mocks et les stubs pour simuler ces dépendances et garder nos tests rapides et fiables.

Dans la seconde partie de ce guide, nous plongerons dans l’univers du mocking avec unittest.mock. Vous apprendrez à isoler vos tests, simuler des comportements complexes, et valider les interactions entre vos modules et leurs dépendances.

Alors, prêt à pousser vos compétences en tests encore plus loin ? 🚀 On se retrouve dans la prochaine partie !