Skip to main content
Niveau : Expert

Chapitre 45 : Les Tests avec Pytest

Objectif

Ce module a pour but de vous initier à pytest, le framework de test le plus populaire et le plus puissant de l'écosystème Python. Vous apprendrez à écrire des tests simples, à utiliser les fixtures pour préparer le contexte de vos tests, et à comprendre la philosophie de pytest qui privilégie la simplicité et la lisibilité.

1. Pourquoi Écrire des Tests ?

Les tests automatisés sont une partie cruciale du développement logiciel moderne. Ils permettent de :

  • Vérifier la correction : S'assurer que votre code fait ce qu'il est censé faire.
  • Prévenir les régressions : Garantir que les nouvelles modifications n'ont pas cassé des fonctionnalités existantes.
  • Faciliter le refactoring : Vous pouvez modifier votre code en toute confiance, sachant que la suite de tests vous alertera si quelque chose ne va plus.
  • Servir de documentation : Les tests montrent comment votre code est censé être utilisé.

2. pytest vs unittest

unittest est le framework de test inclus dans la bibliothèque standard de Python. pytest est un package tiers qui s'est imposé comme le standard de facto grâce à ses nombreux avantages :

  • Syntaxe plus simple : Pas besoin de créer des classes qui héritent de unittest.TestCase. De simples fonctions suffisent.
  • Assertions plus claires : On utilise l'instruction assert standard de Python. pytest se charge de fournir des messages d'erreur détaillés en cas d'échec.
  • Découverte de tests puissante : pytest trouve et exécute automatiquement vos tests si vous suivez des conventions de nommage simples.
  • Fixtures : Un système d'injection de dépendances élégant et puissant pour gérer l'état et le contexte des tests.
  • Écosystème de plugins riche : Des centaines de plugins pour étendre les fonctionnalités de pytest (tests de couverture, tests asynchrones, intégration avec Django/Flask, etc.).

3. Écrire son Premier Test avec pytest

  1. Installation :

    pip install pytest
  2. Conventions de nommage :

    • Les fichiers de test doivent être nommés test_*.py ou *_test.py.
    • Les fonctions de test à l'intérieur de ces fichiers doivent être nommées test_*().
    • Les classes de test (optionnelles) doivent être nommées Test*.
  3. Exemple : Créez un fichier calculs.py avec une fonction à tester :

    # calculs.py
    def addition(a, b):
    return a + b

    Créez un fichier de test test_calculs.py dans le même dossier :

    # test_calculs.py
    from calculs import addition

    # Une simple fonction de test
    def test_addition():
    # L'assertion est simple et directe
    assert addition(2, 3) == 5
    assert addition(-1, 1) == 0
    assert addition(-1, -1) == -2
  4. Lancer les tests : Ouvrez un terminal dans le dossier et lancez simplement la commande pytest :

    pytest
  5. Résultat :

    ============================= test session starts ==============================
    ...
    collected 1 item

    test_calculs.py . [100%]

    ============================== 1 passed in ...s ===============================

    Le . indique qu'un test a réussi. Si un test échoue, vous verrez un F.

Exemple d'un test qui échoue

Modifions le test pour qu'il échoue :

# test_calculs.py
def test_addition_echec():
assert addition(2, 3) == 6 # Assertion incorrecte

pytest nous donne un rapport d'erreur très détaillé :

...
def test_addition_echec():
> assert addition(2, 3) == 6
E assert 5 == 6
E + where 5 = addition(2, 3)

test_calculs.py:10: AssertionError
=========================== 1 failed, 1 passed in ...s ===========================

pytest a ré-écrit l'assertion pour nous montrer exactement quelles valeurs ont été comparées. C'est beaucoup plus informatif qu'un simple AssertionError.

4. Les Fixtures : Gérer le Contexte des Tests

Les fixtures sont le cœur de la puissance de pytest. Une fixture est une fonction qui prépare des données ou un état nécessaire pour un test. pytest les exécute avant le test qui en a besoin et "injecte" leur résultat dans le test.

On déclare une fixture avec le décorateur @pytest.fixture.

Exemple simple de fixture

Imaginons que plusieurs tests ont besoin d'une liste de nombres de départ.

# test_liste.py
import pytest

@pytest.fixture
def liste_nombres():
"""Une fixture qui fournit une liste de nombres pour les tests."""
print("\n(Création de la liste via la fixture)")
return [1, 2, 3, 4, 5]

def test_longueur_liste(liste_nombres):
"""Teste la longueur de la liste fournie par la fixture."""
# 'liste_nombres' est le résultat de la fixture
assert len(liste_nombres) == 5

def test_somme_liste(liste_nombres):
"""Teste la somme de la liste fournie par la fixture."""
assert sum(liste_nombres) == 15
  • Pour utiliser une fixture, on l'ajoute simplement comme argument à la fonction de test.
  • pytest voit l'argument, cherche une fixture avec le même nom, l'exécute, et passe sa valeur de retour au test.
  • Par défaut, la fixture est exécutée une fois pour chaque test qui l'utilise, garantissant l'isolation des tests.

Portée des Fixtures (scope)

On peut contrôler la fréquence d'exécution d'une fixture avec l'argument scope.

  • scope='function' (défaut) : Exécutée une fois par test.
  • scope='class' : Exécutée une fois par classe de test.
  • scope='module' : Exécutée une fois par fichier de test.
  • scope='session' : Exécutée une fois pour toute la session de test.

C'est utile pour des opérations de configuration coûteuses (ex: connexion à une base de données, création d'un gros objet).

@pytest.fixture(scope='module')
def connexion_db():
"""Fixture coûteuse, exécutée une seule fois pour tout le module."""
print("\n(Connexion à la base de données...)")
db = ... # Code de connexion
yield db # 'yield' est utilisé pour le nettoyage
print("\n(Fermeture de la connexion à la base de données...)")
db.close()

L'utilisation de yield dans une fixture permet de séparer la partie "setup" (avant yield) de la partie "teardown" (après yield), qui sera exécutée à la fin de la portée de la fixture.

5. Paramétrer les Tests

Le décorateur @pytest.mark.parametrize permet d'exécuter le même test avec plusieurs jeux de données différents.

import pytest

@pytest.mark.parametrize("a, b, attendu", [
(2, 3, 5), # Test 1
(-1, 1, 0), # Test 2
(0, 0, 0), # Test 3
(-5, -5, -10), # Test 4
])
def test_addition_parametrise(a, b, attendu):
assert addition(a, b) == attendu

Ce code définit un seul test, mais pytest l'exécutera quatre fois, une pour chaque tuple de données. C'est une manière très propre et lisible de tester de nombreux cas.

6. Quelques Fonctionnalités Utiles

  • Tester les exceptions :

    def test_division_par_zero():
    with pytest.raises(ZeroDivisionError):
    1 / 0

    Le test réussit si une ZeroDivisionError est levée dans le bloc with.

  • Marqueurs (markers) : Organiser les tests en catégories.

    @pytest.mark.slow
    def test_tres_lent():
    ...

    @pytest.mark.network
    def test_appel_api():
    ...

    On peut ensuite lancer uniquement une catégorie de tests : pytest -m slow.

  • Fichier conftest.py : Un fichier spécial où l'on peut placer des fixtures pour qu'elles soient disponibles pour tous les tests d'un dossier et de ses sous-dossiers.

Conclusion

pytest est un outil qui transforme l'écriture de tests en une tâche agréable et productive. En utilisant des fonctions de test simples, des assertions claires, et le puissant système de fixtures, vous pouvez construire des suites de tests robustes et maintenables. L'investissement dans l'apprentissage de pytest est l'un des plus rentables pour un développeur Python souhaitant améliorer la qualité de son code.


Exercice 01 : Tester une Classe avec des Fixtures et parametrize

Objectif

Cet exercice a pour but de vous faire utiliser les fonctionnalités de base et intermédiaires de pytest, notamment les fixtures pour la préparation d'objets et @pytest.mark.parametrize pour tester plusieurs cas de figure.

Contexte

Vous allez développer une classe Portefeuille qui gère un solde. Vous écrirez ensuite une suite de tests avec pytest pour valider son comportement dans différentes situations : dépôt, retrait, et retrait non autorisé.

Énoncé

Étape 1 : Créer la classe à tester

Créez un fichier nommé portefeuille.py et définissez la classe Portefeuille.

# portefeuille.py

class SoldeInsuffisantError(Exception):
"""Exception levée lors d'un retrait supérieur au solde."""
pass

class Portefeuille:
"""Classe pour gérer un solde simple."""

def __init__(self, solde_initial: float = 0):
if solde_initial < 0:
raise ValueError("Le solde initial ne peut pas être négatif.")
self._solde = solde_initial

@property
def solde(self) -> float:
return self._solde

def deposer(self, montant: float):
if montant <= 0:
raise ValueError("Le montant du dépôt doit être positif.")
self._solde += montant

def retirer(self, montant: float):
if montant <= 0:
raise ValueError("Le montant du retrait doit être positif.")
if montant > self._solde:
raise SoldeInsuffisantError("Le retrait ne peut pas excéder le solde.")
self._solde -= montant

Étape 2 : Créer le fichier de test et les fixtures

Créez un fichier test_portefeuille.py.

  1. Importez pytest et la classe Portefeuille (ainsi que les exceptions).

  2. Créez deux fixtures :

    • Une fixture portefeuille_vide qui retourne une instance de Portefeuille avec un solde de 0.
    • Une fixture portefeuille_avec_20 qui retourne une instance de Portefeuille avec un solde de 20.

Étape 3 : Écrire les tests

  1. Test du solde initial :

    • Écrivez un test test_solde_initial qui utilise la fixture portefeuille_vide et vérifie que son solde est bien de 0.
    • Écrivez un test test_solde_initial_avec_montant qui utilise la fixture portefeuille_avec_20 et vérifie que son solde est bien de 20.
  2. Test de la méthode deposer :

    • Écrivez un test test_depot qui utilise la fixture portefeuille_vide, dépose 10, et vérifie que le nouveau solde est de 10.
  3. Test de la méthode retirer :

    • Écrivez un test test_retrait qui utilise la fixture portefeuille_avec_20, retire 10, et vérifie que le nouveau solde est de 10.
  4. Test des exceptions :

    • Écrivez un test test_retrait_solde_insuffisant qui utilise la fixture portefeuille_avec_20 et vérifie qu'un SoldeInsuffisantError est bien levé lorsqu'on essaie de retirer 30. Utilisez with pytest.raises(...).
    • Écrivez un test test_creation_solde_initial_negatif qui vérifie qu'une ValueError est levée lorsqu'on essaie de créer un Portefeuille avec un solde initial de -10.
  5. Paramétrer les tests :

    • Les tests pour les dépôts et retraits avec des montants invalides (négatifs ou nuls) sont de bons candidats pour la paramétrisation.
    • Créez un test test_depot_montant_invalide paramétré pour tester qu'un dépôt de 0 et de -10 lève bien une ValueError.
    • Créez un test test_retrait_montant_invalide paramétré de la même manière.

Étape 4 : Lancer les tests

  • Assurez-vous d'avoir pytest installé (pip install pytest).
  • Depuis votre terminal, dans le dossier du projet, lancez la commande pytest.

Résultat Attendu

Tous vos tests doivent passer. pytest devrait découvrir et exécuter tous les tests que vous avez écrits, y compris les cas paramétrés, et afficher un résumé indiquant que tous ont réussi.

============================= test session starts ==============================
...
collected 8 items

test_portefeuille.py ........ [100%]

============================== 8 passed in ...s ================================

(Le nombre de tests peut varier si vous avez structuré votre test paramétré différemment, mais tous doivent passer).

Cliquez ici pour voir un exemple de code de solution
# test_portefeuille.py

import pytest
from portefeuille import Portefeuille, SoldeInsuffisantError

# --- Fixtures ---

@pytest.fixture
def portefeuille_vide():
"""Retourne un portefeuille avec un solde de 0."""
return Portefeuille()

@pytest.fixture
def portefeuille_avec_20():
"""Retourne un portefeuille avec un solde de 20."""
return Portefeuille(20)

# --- Tests ---

def test_solde_initial(portefeuille_vide):
assert portefeuille_vide.solde == 0

def test_solde_initial_avec_montant(portefeuille_avec_20):
assert portefeuille_avec_20.solde == 20

def test_depot(portefeuille_vide):
portefeuille_vide.deposer(10)
assert portefeuille_vide.solde == 10

def test_retrait(portefeuille_avec_20):
portefeuille_avec_20.retirer(10)
assert portefeuille_avec_20.solde == 10

def test_retrait_solde_insuffisant(portefeuille_avec_20):
with pytest.raises(SoldeInsuffisantError):
portefeuille_avec_20.retirer(30)

def test_creation_solde_initial_negatif():
with pytest.raises(ValueError):
Portefeuille(-10)

@pytest.mark.parametrize("montant_invalide", [0, -10, -0.01])
def test_depot_montant_invalide(portefeuille_vide, montant_invalide):
with pytest.raises(ValueError):
portefeuille_vide.deposer(montant_invalide)

@pytest.mark.parametrize("montant_invalide", [0, -20])
def test_retrait_montant_invalide(portefeuille_avec_20, montant_invalide):
with pytest.raises(ValueError):
portefeuille_avec_20.retirer(montant_invalide)