Tests unitaires en Python avec pytest

Découvrez comment tester votre code avec le module pytest de Python.

Publié le par Gabriel Trouvé (mis à jour le )

58 minutes

Je pourrais reprendre l'introduction de mon article sur unittest. D'ailleurs, si vous l'avez lu, vous savez à quel point les tests sont indispensables pour garantir la stabilité de votre code.
Même si unittest fait très bien le travail, la communauté Python a largement adopté la bibliothèque pytest.

Dans ce guide, nous allons voir comment tester son code, les fixtures et les mocks.

Écrire son premier test

Commençons par installer pytest car, en effet, il s'agit d'une bibliothèque tierce.

pip install pytest
PYTHON

Pour cet exemple, nous allons créer une fonction très simple dans foo.py :

# foo.py
def saluer(nom):
    return f"Bonjour {nom}"
PYTHON

Si vous utilisez unittest, il aurait fallu importer le module, créer une classe héritant de TestCase, puis définir une méthode spécifique. Avec pytest, pas besoin de classe, on peut faire cela de manière plus directe dans test_foo.py :

from foo import saluer


def test_saluer():
    resultat = saluer("Patrick")
    assert resultat == "Bonjour Patrick"
PYTHON

À noter

Il n'y a même pas besoin d'importer pytest. Contrairement à unittest, où l'import est obligatoire pour hériter de la classe TestCase, pytest sert d'exécuteur. Tant que nous n'utilisons pas de fonctionnalités de la bibliothèque, le simple fait d'utiliser l'instruction native assert est suffisant.

Pour exécuter le test, ouvrez votre terminal et tapez :

pytest
PYTHON

pytest va automatiquement chercher, dans votre dossier, les fichiers commençant par test_ et exécuter les fonctions commençant par test_.

Le mot-clé assert natif

Dans l'exemple précédent, nous avons utilisé le mot-clé assert.

Qu'est-ce que assert en Python ?

En dehors de pytest, il est tout à fait possible d'utiliser assert pour vérifier des invariants pendant le développement. C'est un outil intégré à Python qui permet d'exprimer des conditions qui doivent toujours être vraies dans votre code. Si elles ne le sont pas, c'est qu'il y a un bug.

À noter

Un invariant est une promesse faite sur le code : une condition qui doit toujours être vraie si votre logique est correcte. L'instruction évalue une condition et, si elle est vraie, le programme continue normalement ; sinon, Python lève une AssertionError et arrête le script.

def appliquer_reduction(prix, reduction):
    # raise : validation des entrées — toujours actif
    if prix < 0:
        raise ValueError("Le prix ne peut pas être négatif.")
    if reduction < 0:
        raise ValueError("La réduction ne peut pas être négative.")

    nouveau_prix = prix - reduction

    # assert : invariant interne — désactivable avec -O
    # Si on arrive ici, les deux valeurs sont ≥ 0, donc c'est garanti.
    # Sert juste de filet de sécurité pendant le dev.
    assert nouveau_prix <= prix, "Anomalie logique : le prix a augmenté !"

    return nouveau_prix
PYTHON

Attention

Si vous exécutez votre script en mode optimisé (python -O mon_script.py), les assert seront ignorés. C'est pourquoi on ne les utilise pas pour valider les entrées des utilisateurs. C'est pour cette raison que raise est indispensable et qu' assert sert de garde-fou pendant le développement.

pytest fonctionne avec les assert pour les tests. Pas besoin d'utiliser des self.assertEqual(), self.assertTrue() ou self.assertIn() comme avec unittest ; il suffit d'écrire du code Python classique.

pytest et assert

Comme nous venons de le voir, en Python standard, en cas d'échec, un assert se contente de faire crasher le script. Mais quand un assert échoue avec pytest, ce dernier intercepte l'exception et réalise une introspection en vous montrant exactement ce qui diffère entre les deux valeurs comparées.

En effet, dans la console, pytest affichera un message très clair pour vous aider à déboguer. Prenons une fonction qui renvoie le prénom Patrick et une fonction de test test_prenom :

Exemple avec un test simple

Exemple avec un test simple

Tester les erreurs (exceptions) attendues

Il est parfois nécessaire de vérifier qu'une fonction lève bien une erreur quand on lui donne une mauvaise valeur. Vous le voyez venir ? L'exemple le plus simple : la division par zéro ! Avec pytest, on utilise un "context manager" avec pytest.raises. L'import de pytest devient donc nécessaire :

# main.py
def diviser(a, b):
    if b == 0:
        raise ValueError("La division par zéro est interdite !")
    return a / b

# test_diviser.py
import pytest

from main import diviser


def test_division_par_zero():
    # On s'assure que l'appel à la fonction lève bien une ValueError
    # On peut aussi vérifier que le message d'erreur est correct grâce à l'argument 'match'
    with pytest.raises(ValueError, 
                       match="La division par zéro est interdite !"):
        diviser(10, 0)
PYTHON

Les fixtures : LE super-pouvoir de pytest

Qu'est-ce qu'une fixture pytest ?

Une fixture est une fonction décorée qui fournit des données ou un état à vos tests. pytest utilise un fichier conftest.py, où l'on peut définir des fixtures pour qu'elles deviennent automatiquement disponibles pour tous les fichiers de test situés dans le même répertoire et dans tous les sous-dossiers. Aucun import n'est requis dans vos fichiers de test !

Attention, nous allons prendre un exemple assez complet avec deux fonctions pour lire et écrire dans un fichier.

# main.py
def lire_fichier(path):
    with open(path) as f:
        return f.read()


def ecrire_fichier(path, contenu):
    with open(path, "w") as f:
        f.write(contenu)
PYTHON

Un fichier conftest.py avec trois fixtures :

  • contenu_initial : retourne simplement une chaîne de caractères

  • fichier_temp : utilise la fixture intégrée tmp_path pour construire un chemin vers un fichier test.txt dans un dossier temporaire géré par pytest

  • avec_yield : illustre l'utilisation de yield à la place de return — le code avant le yield s'exécute en setup, le code après en teardown, une fois le test terminé. On ne fait rien de particulier ici, mais en pratique, c'est ce pattern que l'on utilise pour des ressources à cycle de vie : connexion à une base de données, client HTTP, fichier ouvert, etc.

Concernant le yield, je vous invite à regarder les exemples de la documentation officielle.

import pytest


@pytest.fixture
def contenu_initial():
    return "bonjour"


@pytest.fixture
def fichier_temp(tmp_path):
    path = tmp_path / "test.txt"
    return path


@pytest.fixture
def avec_yield():
    print("\n[SETUP] avant le test")
    yield
    print("\n[TEARDOWN] après le test")
PYTHON

Le fichier de tests utilise les deux fixtures fichier_temp et contenu_initial définies dans conftest.py. La fixture avec_yield n'est pas utilisée ici — elle servait uniquement d'illustration du pattern yield.

La puissance des fixtures pytest réside ici : il suffit de déclarer le nom de la fixture en paramètre de la fonction de test — pytest s'occupe de l'injecter automatiquement. Pas besoin d'import, pas d'instanciation manuelle. pytest détecte les fixtures disponibles dans conftest.py et les résout à l'exécution.

  • test_lire : reçoit fichier_temp, écrit dedans puis vérifie la lecture

  • test_ecrire_puis_lire : combine deux fixtures — fichier_temp pour le chemin et contenu_initial pour la valeur de base, ce qui montre qu'un test peut dépendre de plusieurs fixtures simultanément

from main import lire_fichier, ecrire_fichier


def test_lire(fichier_temp):
    ecrire_fichier(fichier_temp, "bonjour")
    assert lire_fichier(fichier_temp) == "bonjour"


def test_ecrire_puis_lire(fichier_temp, contenu_initial):
    ecrire_fichier(fichier_temp, contenu_initial + " monde")
    assert lire_fichier(fichier_temp) == "bonjour monde"
PYTHON

La durée de vie des fixtures : les scopes

Par défaut, une fixture est exécutée dès qu'un test l'utilise. Les fixtures sont réinitialisées à leur état initial pour chaque test. Mais imaginez une fixture qui se connecte à une base de données : on voudrait éviter de l'exécuter 50 fois si l'on a 50 tests qui utilisent cette fixture.

On utilise alors le paramètre scope :

  • scope="function" : le comportement par défaut, exécuté à chaque test

  • scope="module" : exécuté une seule fois par fichier de test

  • scope="session" : exécuté une seule fois pour toute la session de tests

Prenons un exemple de code classique pour illustrer le scope session :

# Cette connexion ne se fera qu'une seule fois pour toute la session
@pytest.fixture(scope="session")
def base_de_donnees():
    db = connect_to_database()
    yield db
    db.close()
PYTHON

Fixture automatique

Parfois, vous voulez qu'une fixture s'applique automatiquement avant chaque test sans avoir à la passer en paramètre. C'est très pratique pour des actions globales, comme forcer des variables d'environnement, forcer le mode synchrone de Celery pendant les tests, etc.

import pytest

@pytest.fixture(autouse=True)
def ma_fixture_globale():
    # Code qui va s'appliquer partout (avant chaque test)
    yield
    # Code de nettoyage éventuel (après chaque test)
PYTHON

Le paramétrage des tests pour éviter la répétition

Si l'on veut tester une fonctionnalité avec des données différentes, au lieu d'écrire plusieurs fois le même test, pytest propose une fonctionnalité : parametrize.

Reprenons notre exemple simple avec la fonction saluer :

# foo.py
def saluer(nom):
    return f"Bonjour {nom}"
PYTHON
# test_foo.py
import pytest
from foo import saluer


@pytest.mark.parametrize("nom, resultat_attendu", [
    ("Patrick", "Bonjour Patrick"),
    ("Sebastien", "Bonjour Sebastien"),
    ("Ely", "Bonjour Ely"),
])
def test_saluer_plusieurs_personnes(nom, resultat_attendu):
    assert saluer(nom) == resultat_attendu
PYTHON

pytest va générer trois tests distincts. Les noms des variables passés sous forme de chaîne de caractères dans le décorateur ("nom, resultat_attendu") doivent correspondre exactement aux arguments de notre fonction de test. Cela permet à pytest d'injecter les bonnes valeurs à chaque itération.

Mocker avec pytest

Par exemple, si votre code communique avec une API externe, vous ne voulez pas que vos tests dépendent de la connexion Internet. C'est ici que les mocks interviennent, car ils permettent de simuler une réponse. Et vous savez quoi ? pytest est compatible avec la bibliothèque standard de Python, ce qui permet d'utiliser unittest.mock sans rien installer de plus.

Imaginons une fonction qui interroge l'API météo wttr :

# main.py
import requests


def obtenir_meteo(ville):
    reponse = requests.get(f"https://wttr.in/{ville}?format=j1")
    if reponse.status_code == 200:
        data = reponse.json()
        print(data)  # Affiche les données pour le débogage
        temp = data['current_condition'][0]['temp_C']
        return f"Il fait {temp}°C à {ville}"
    return "Météo indisponible"


if __name__ == "__main__":
    ville = "Paris"
    print(obtenir_meteo(ville))
PYTHON

Créons un test en utilisant unittest.mock pour le mock :

# test_main.py
from unittest.mock import patch, Mock
from main import obtenir_meteo

# On "patch" (intercepte) la fonction requests.get là où elle est utilisée
@patch("main.requests.get")
def test_obtenir_meteo_succes(mock_get):
    # 1. On configure la fausse réponse (status 200 et un faux JSON)
    fausse_reponse = Mock()
    fausse_reponse.status_code = 200
    fausse_reponse.json.return_value = {
        "current_condition": [
            {
                "temp_C": "25",
                "temp_F": "77",
                "humidity": "55",
                "weatherDesc": [{"value": "Ensoleillé"}],
            }
        ]
    }

    # 2. On assigne cet objet simulé au retour de notre mock
    mock_get.return_value = fausse_reponse

    # 3. On exécute notre fonction
    resultat = obtenir_meteo("Paris")

    # 4. On vérifie le résultat et l'appel
    assert resultat == "Il fait 25°C à Paris"
    mock_get.assert_called_once_with("https://wttr.in/Paris?format=j1")
PYTHON

Expliquons le mock étape par étape :

  • @patch('main.requests.get') indique à Python le chemin de la fonction à patcher. requests.get est donc mise de côté et remplacée par un mock

  • On définit un paramètre mock_get qui représente notre mock, passé automatiquement par le décorateur @patch. À noter que le nom du paramètre est libre, mais mock_* est une bonne pratique

  • mock_get.return_value = fausse_reponse permet de configurer notre mock pour qu'il renvoie l'objet que l'on vient de créer

  • mock_get.assert_called_once_with(...) permet de s'assurer que le code a appelé la fonction avec la bonne URL

À noter

Si le vrai code appelle une méthode ou une fonction, on utilise return_value.
Si le vrai code accède à un attribut, on assigne directement une valeur.

fausse_reponse = Mock()
fausse_reponse.status_code = 200
fausse_reponse.json.return_value = {...}
PYTHON

À noter

Si vous avez déjà lu notre article sur unittest, ce qui suit n'a aucun secret pour vous 😛 ; vous pouvez passer à la suite.

Pourquoi patcher main.requests.get plutôt que requests.get ?

Dans notre cas, les deux fonctionneraient. Quand main.py importe requests puis appelle requests.get(), get est résolu au moment de l'appel. Donc on pourrait patcher requests.get directement sans problème.

Par contre, si le code source utilisait un import direct from requests import get, main.py posséderait sa propre référence vers get. Donc patcher requests.get ne fonctionnerait plus, et il faudrait cibler main.get.

La bonne pratique est donc de cibler la référence dans le module testé : main.requests.get.

La règle d'or à retenir : patcher là où la fonction est utilisée, pas là où elle est définie.

Simuler une erreur : side_effect

Il est possible que l'API tombe en panne et il faut aussi tester ce cas. Au lieu d'utiliser return_value pour simuler une réponse, on utilise side_effect pour lever une exception :

import pytest
import requests
from unittest.mock import patch
from main import obtenir_meteo

@patch("main.requests.get")
def test_obtenir_meteo_erreur(mock_get):
    # On simule une coupure internet
    mock_get.side_effect = requests.exceptions.ConnectionError()

    # On s'assure que notre code propage bien l'erreur attendue
    with pytest.raises(requests.exceptions.ConnectionError):
        obtenir_meteo("Londres")
PYTHON

Le plugin pytest-mock

À savoir qu'il est possible d'utiliser pytest-mock, qui fournit la fixture mocker. Elle permet de configurer les mocks directement dans la fonction :

pip install pytest-mock
PYTHON
def test_obtenir_meteo_erreur(mocker):
    mock_get = mocker.patch("main.requests.get")
    mock_get.side_effect = requests.exceptions.ConnectionError()

    with pytest.raises(requests.exceptions.ConnectionError):
        obtenir_meteo("Londres")
PYTHON

Cela permet de se passer du décorateur, dont le comportement peut parfois surprendre lorsque l'on empile plusieurs mocks. En effet, si l'on empile plusieurs @patch, les arguments doivent être déclarés dans l'ordre inverse :

# unittest.mock — les arguments s'accumulent à l'envers
@patch("main.requests.get")
@patch("main.cache.get")
@patch("main.logger.error")
def test_foo(mock_logger, mock_cache, mock_get):  # ordre inversé !
    ...

# pytest-mock — linéaire et dans l'ordre
def test_foo(mocker):
    mock_get = mocker.patch("main.requests.get")
    mock_cache = mocker.patch("main.cache.get")
    mock_logger = mocker.patch("main.logger.error")
    ...
PYTHON

Pour aller plus loin

Les marqueurs personnalisés

Imaginez avoir une multitude de tests dans votre projet ; certains peuvent être plus longs à s'exécuter. Il est possible de créer des marqueurs pour vos tests :

# main.py
import random

def lancer_de():
    return random.randint(1, 6)

if __name__ == "__main__":
    print("Vous avez lancé un dé et obtenu :", lancer_de())
PYTHON
import pytest
from main import lancer_de


def test_lancer_de_dans_intervalle():
    resultat = lancer_de()
    assert 1 <= resultat <= 6


@pytest.mark.lent
def test_lancer_de_plusieurs_fois():
    for _ in range(1000):
        resultat = lancer_de()
        assert 1 <= resultat <= 6
PYTHON

Je peux maintenant lancer les tests marqués comme lent ou les ignorer :

pytest -m "lent"
pytest -m "not lent"
PYTHON
Marqueurs pytest

Marqueurs pytest

J'ai bien un test qui passe sur les deux, mais j'ai un avertissement. Il faut déclarer vos marqueurs personnalisés dans un fichier de configuration pytest.ini :

# pytest.ini
[pytest]
markers =
    lent: Marque un test comme étant particulièrement lent à exécuter.
PYTHON

Ce fichier pytest.ini sert à configurer de nombreux autres comportements globaux de pytest.

La couverture de code

En installant pytest-cov, il est possible de savoir quel pourcentage de votre code a été parcouru par les tests.

pip install pytest-cov
PYTHON

Vous pouvez ensuite lancer cette commande :

pytest --cov
PYTHON

Voici un exemple avec l'une de mes applications ; je ne présente que le haut et le bas du rapport :

Haut du rapport

Haut du rapport

Bas du rapport

Bas du rapport

Vous avez ici un tableau avec plusieurs colonnes :

  • le nombre de lignes exécutables (Stmts)

  • le nombre de lignes qui n'ont pas été exécutées pendant vos tests (Miss)

  • le pourcentage de couverture (Cover)

Prochaine étape : intégrer pytest avec GitHub Actions pour déclencher les tests dès que vous pousserez votre code. Nous avons d'ailleurs un article sur le sujet 😁.

Bravo, tu es prêt à passer à la suite

Rechercher sur le site

Inscris-toi à Docstring

Pour commencer ton apprentissage.

Tu as déjà un compte ? Connecte-toi.