Aller au contenu
Développement medium

unittest : les tests unitaires natifs de Python

16 min de lecture

logo python

unittest est le framework de test intégré à la bibliothèque standard de Python. On y écrit des tests dans des classes héritant de unittest.TestCase, avec des méthodes d'assertion comme assertEqual. Son grand atout : rien à installer, il est livré avec Python. Ce guide part de zéro : structurer un projet, écrire et lancer un premier test, maîtriser les assertions, factoriser avec setUp/tearDown, appliquer le cycle TDD et éviter les pièges classiques.

Il s'adresse aux débutants comme à celles et ceux qui reprennent un projet utilisant unittest. Si vous démarrez un nouveau projet, pytest est souvent plus concis, mais unittest reste incontournable : il est partout, sans dépendance, et pytest sait d'ailleurs exécuter les tests unittest. Le code a été testé avec Python 3.12.

  • Structurer un projet avec un dossier tests/ dédié.
  • Écrire et lancer un test avec TestCase et la découverte automatique.
  • Utiliser les assertions essentielles (assertEqual, assertRaises...).
  • Factoriser la préparation avec setUp et tearDown.
  • Appliquer le cycle TDD Red-Green-Refactor sur un cas concret.
  • Éviter les pièges qui rendent les tests fragiles ou inutiles.

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.

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/.

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 la découverte automatique des tests avec cette commande :

Fenêtre de terminal
python -m unittest discover -s tests

L'option discover parcourt le dossier tests/ et exécute tous les fichiers test_*.py. Si tout est bien configuré, vous verrez une sortie similaire à celle-ci :

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

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.

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.

Quand plusieurs tests d'une même classe ont besoin du même contexte (un objet, une connexion, un fichier temporaire), on évite de le recréer dans chaque méthode grâce à setUp et tearDown. La méthode setUp s'exécute avant chaque test, tearDown après chaque test, ce qui garantit que chacun part d'un état propre et indépendant.

import unittest
class TestPanier(unittest.TestCase):
def setUp(self):
self.panier = Panier() # avant chaque test
def tearDown(self):
self.panier.vider() # après chaque test
def test_ajout(self):
self.panier.ajouter("pomme")
self.assertEqual(len(self.panier), 1)
def test_panier_vide(self):
self.assertEqual(len(self.panier), 0)

Chaque test reçoit un self.panier neuf : test_ajout ne peut pas influencer test_panier_vide. C'est l'équivalent, côté unittest, des fixtures de pytest.

Le Test-Driven Development (TDD) est une approche méthodique où les tests guident le développement de votre code. Avec unittest, ce processus reste simple. La suite applique le cycle Red-Green-Refactor sur un exemple concret.

  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

Section intitulée « 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.

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/test_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

Section intitulée « É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 !

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

Les deux tests passent : la fonction gère désormais le cas nominal et le cas d'erreur.

  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.

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.

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.

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.

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)

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)

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)

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
  • unittest est intégré à Python : aucune installation, on structure les tests en classes héritant de TestCase.
  • On lance la suite avec python -m unittest discover, qui trouve tous les test_*.py.
  • Les assertions (assertEqual, assertRaises, assertTrue...) sont le cœur des tests.
  • setUp/tearDown préparent et nettoient le contexte avant/après chaque test.
  • Le cycle TDD Red-Green-Refactor fait écrire le test avant le code.
  • Pièges à éviter : tester l'implémentation, ignorer les cas limites, multiplier les vérifications dans un seul test.
  • Depuis Python 3.12, les vieux alias (assertEquals...) sont supprimés : utilisez les noms modernes.

Les projets réels dépendent d'API et de bases de données. L'étape suivante consiste à isoler ces dépendances avec des mocks, ou à passer à pytest pour des tests plus concis.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn