Guide complet des tests d’exceptions et d’erreurs avec pytest en Python

Dans le développement logiciel, tester la gestion des exceptions et des erreurs est crucial. En effectuant des tests appropriés, vous pouvez améliorer la fiabilité de votre code et prévenir les défaillances dues à des erreurs inattendues. Cet article explique en détail comment tester efficacement les exceptions et la gestion des erreurs en utilisant pytest, le framework de test pour Python. Nous aborderons les étapes, de la configuration de base aux tests des exceptions personnalisées et la gestion de multiples exceptions.

Sommaire

Configuration de base de pytest

pytest est un framework de test puissant pour Python, facile à installer et à utiliser. Suivez les étapes ci-dessous pour configurer pytest.

Installation de pytest

Tout d’abord, vous devez installer pytest. Utilisez la commande suivante pour l’installer via pip.

pip install pytest

Structure de répertoire de base

Il est recommandé d’organiser le répertoire de tests du projet comme suit :

my_project/
├── src/
│   └── my_module.py
└── tests/
    ├── __init__.py
    └── test_my_module.py

Création du premier fichier de test

Ensuite, créez un fichier Python pour les tests. Par exemple, créez un fichier nommé test_my_module.py dans le répertoire tests et ajoutez le contenu suivant.

def test_example():
    assert 1 + 1 == 2

Exécution des tests

Pour exécuter les tests, lancez la commande suivante dans le répertoire racine du projet.

pytest

Cela permettra à pytest de détecter automatiquement les fichiers de test dans le répertoire tests et de les exécuter. Si les tests réussissent, la configuration de base est terminée.

Méthode de test des exceptions

pytest propose des moyens très pratiques pour vérifier si des exceptions sont correctement levées. Voici comment tester qu’une exception spécifique est levée.

Tester les exceptions avec pytest.raises

Pour vérifier qu’une exception est levée, utilisez le gestionnaire de contexte pytest.raises. L’exemple suivant teste la levée d’une exception de division par zéro.

import pytest

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

Ce test vérifie que l’opération 1 / 0 génère une exception ZeroDivisionError.

Vérification des messages d’exception

Il est parfois nécessaire de vérifier que l’exception levée contient un message d’erreur spécifique. Utilisez le paramètre match pour cela.

def test_zero_division_message():
    with pytest.raises(ZeroDivisionError, match="division by zero"):
        1 / 0

Ce test vérifie que l’exception ZeroDivisionError est levée et que le message d’erreur contient « division by zero ».

Tester plusieurs exceptions

Il est possible de tester plusieurs exceptions dans un seul cas de test, par exemple pour vérifier que différentes exceptions se produisent dans des conditions différentes.

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

    with pytest.raises(TypeError):
        '1' + 1

Ce test vérifie d’abord que ZeroDivisionError est levée, puis que TypeError se produit.

En effectuant correctement les tests des exceptions, vous pouvez garantir que votre code gère les erreurs de manière appropriée sans comportement inattendu.

Validation des messages d’erreur

Il est essentiel de vérifier que certains messages d’erreur sont présents dans les tests. Voici comment valider les messages d’erreur avec pytest.

Vérification d’un message d’erreur spécifique

Vous pouvez tester non seulement qu’une exception est levée, mais aussi que le message de cette exception correspond à un texte précis, en utilisant le paramètre match de pytest.raises.

import pytest

def test_value_error_message():
    def raise_value_error():
        raise ValueError("This is a ValueError with a specific message.")

    with pytest.raises(ValueError, match="specific message"):
        raise_value_error()

Ce test vérifie que l’exception ValueError est levée et que son message contient « specific message ».

Validation des messages d’erreur avec des expressions régulières

Lorsque les messages d’erreur sont générés dynamiquement ou pour vérifier une correspondance partielle, les expressions régulières peuvent être utiles.

def test_regex_error_message():
    def raise_type_error():
        raise TypeError("TypeError: invalid type for operation")

    with pytest.raises(TypeError, match=r"invalid type"):
        raise_type_error()

Ce test vérifie que le message de l’exception TypeError contient l’expression « invalid type ». En passant une expression régulière au paramètre match, il est possible de valider une correspondance partielle.

Validation des messages d’erreur personnalisés

Les exceptions personnalisées peuvent également être validées de la même manière pour vérifier leurs messages d’erreur.

class CustomError(Exception):
    pass

def test_custom_error_message():
    def raise_custom_error():
        raise CustomError("This is a custom error message.")

    with pytest.raises(CustomError, match="custom error message"):
        raise_custom_error()

Ce test vérifie que l’exception CustomError est levée et que son message contient « custom error message ».

La validation des messages d’erreur est importante pour garantir la cohérence des messages d’erreur fournis aux utilisateurs et la précision des informations de débogage. Utilisez pytest pour réaliser ces tests de manière efficace.

Test de la gestion de multiples exceptions

Lorsqu’une fonction peut lever plusieurs exceptions, il est important de tester que chacune est gérée correctement. Voici comment utiliser pytest pour tester différentes gestions d’exceptions de manière groupée.

Vérification de plusieurs exceptions dans un seul test

Si une fonction peut lever différentes exceptions selon les conditions, il est possible de tester la gestion de chacune d’elles dans le même cas de test.

import pytest

def error_prone_function(value):
    if value == 0:
        raise ValueError("Value cannot be zero")
    elif value < 0:
        raise TypeError("Value cannot be negative")
    return True

def test_multiple_exceptions():
    with pytest.raises(ValueError, match="Value cannot be zero"):
        error_prone_function(0)

    with pytest.raises(TypeError, match="Value cannot be negative"):
        error_prone_function(-1)

Ce test vérifie que la fonction error_prone_function lève une ValueError si la valeur est 0 et une TypeError si elle est négative.

Tests d’exceptions paramétrés

Pour tester efficacement qu’une fonction lève différentes exceptions, vous pouvez utiliser des tests paramétrés.

@pytest.mark.parametrize("value, expected_exception, match_text", [
    (0, ValueError, "Value cannot be zero"),
    (-1, TypeError, "Value cannot be negative")
])
def test_error_prone_function(value, expected_exception, match_text):
    with pytest.raises(expected_exception, match=match_text):
        error_prone_function(value)

Ce test paramétré permet de tester différentes valeurs pour le paramètre value et de vérifier que les exceptions et les messages correspondants sont corrects.

Test des exceptions avec une méthode personnalisée

Vous pouvez également créer une méthode personnalisée pour tester efficacement les exceptions levées par une fonction.

def test_custom_multiple_exceptions():
    def assert_raises_with_message(func, exception, match_text):
        with pytest.raises(exception, match=match_text):
            func()

    assert_raises_with_message(lambda: error_prone_function(0), ValueError, "Value cannot be zero")
    assert_raises_with_message(lambda: error_prone_function(-1), TypeError, "Value cannot be negative")

Ce test utilise la méthode assert_raises_with_message pour vérifier que la fonction lève les bonnes exceptions avec les bons messages d’erreur.

Tester plusieurs exceptions dans un seul test permet de réduire la duplication de code et d’améliorer la maintenabilité des tests. Utilisez les fonctionnalités de pytest pour réaliser efficacement les tests de gestion des exceptions.

Test des exceptions personnalisées

Définir et utiliser des exceptions personnalisées permet de clarifier la gestion des erreurs dans l’application et de mieux traiter les situations d’erreur spécifiques. Voici comment tester les exceptions personnalisées.

Définition des exceptions personnalisées

Commencez par définir une exception personnalisée. Héritez de la classe d’exception intégrée de Python pour créer une nouvelle classe d’exception.

class CustomError(Exception):
    """Classe de base pour les exceptions personnalisées"""
    pass

class SpecificError(CustomError):
    """Exception personnalisée pour des erreurs spécifiques"""
    pass

Fonction levant une exception personnalisée

Ensuite, créez une fonction qui lève l’exception personnalisée dans certaines conditions.

def function_that_raises(value):
    if value == 'error':
        raise SpecificError("An error occurred with value: error")
    return True

Test des exceptions personnalisées

Utilisez pytest pour tester que les exceptions personnalisées sont correctement levées.

import pytest

def test_specific_error():
    with pytest.raises(SpecificError, match="An error occurred with value: error"):
        function_that_raises('error')

Ce test vérifie que la fonction function_that_raises lève une SpecificError avec le message attendu.

Test de plusieurs exceptions personnalisées

Lors de l’utilisation de plusieurs exceptions personnalisées, testez que chacune est correctement gérée.

class AnotherCustomError(Exception):
    """Une autre exception personnalisée"""
    pass

def function_with_multiple_custom_errors(value):
    if value == 'first':
        raise SpecificError("First error occurred")
    elif value == 'second':
        raise AnotherCustomError("Second error occurred")
    return True

def test_multiple_custom_errors():
    with pytest.raises(SpecificError, match="First error occurred"):
        function_with_multiple_custom_errors('first')

    with pytest.raises(AnotherCustomError, match="Second error occurred"):
        function_with_multiple_custom_errors('second')

Ce test vérifie que la fonction function_with_multiple_custom_errors lève les bonnes exceptions personnalisées pour les valeurs d’entrée fournies.

Vérification des messages des exceptions personnalisées

Il est également important de vérifier que les messages d’erreur des exceptions personnalisées sont corrects.

def test_custom_error_message():
    with pytest.raises(SpecificError, match="An error occurred with value: error"):
        function_that_raises('error')

Ce test vérifie que l’exception SpecificError contient le message « An error occurred with value: error ».

Les tests des exceptions personnalisées permettent de garantir que l’application peut gérer les situations d’erreur spécifiques de manière adéquate, ce qui améliore la qualité et la fiabilité du code.

Exemple avancé : Test des erreurs d’une API

Lors du développement d’une API, la gestion des erreurs est essentielle. Pour que le client reçoive des messages d’erreur appropriés, il est nécessaire de tester la gestion des erreurs. Voici comment tester la gestion des erreurs d’une API avec pytest.

Exemple d’API avec FastAPI

Tout d’abord, définissons un endpoint FastAPI simple. Cet exemple crée un endpoint qui génère des erreurs spécifiques.

from fastapi import FastAPI, HTTPExceptionapp = FastAPI()

@app.get("/items/{item_id}")
async def read_item(item_id: int):
    if item_id == 0:
        raise HTTPException(status_code=400, detail="Item ID cannot be zero")
    if item_id < 0:
        raise HTTPException(status_code=404, detail="Item not found")
    return {"item_id": item_id, "name": "Item Name"}

Ce endpoint renvoie une erreur 400 si item_id est 0, et une erreur 404 si item_id est négatif.

Test des erreurs d’une API avec pytest et httpx

Utilisons pytest et httpx pour tester les erreurs de l’API.

import pytest
from httpx import AsyncClient
from main import app

@pytest.mark.asyncio
async def test_read_item_zero():
    async with AsyncClient(app=app, base_url="http://test") as ac:
        response = await ac.get("/items/0")
    assert response.status_code == 400
    assert response.json() == {"detail": "Item ID cannot be zero"}

@pytest.mark.asyncio
async def test_read_item_negative():
    async with AsyncClient(app=app, base_url="http://test") as ac:
        response = await ac.get("/items/-1")
    assert response.status_code == 404
    assert response.json() == {"detail": "Item not found"}

Ces tests envoient des requêtes à /items/0 et /items/-1 et vérifient respectivement les erreurs 400 et 404 avec les messages d’erreur appropriés.

Automatisation et optimisation des tests d’erreurs

Utilisez les tests paramétrés pour regrouper les différents cas de test d’erreurs.

@pytest.mark.asyncio
@pytest.mark.parametrize("item_id, expected_status, expected_detail", [
    (0, 400, "Item ID cannot be zero"),
    (-1, 404, "Item not found"),
])
async def test_read_item_errors(item_id, expected_status, expected_detail):
    async with AsyncClient(app=app, base_url="http://test") as ac:
        response = await ac.get(f"/items/{item_id}")
    assert response.status_code == expected_status
    assert response.json() == {"detail": expected_detail}

Ce test paramétré vérifie les différents item_id et les codes de statut attendus ainsi que les messages d’erreur, rendant les tests plus concis et faciles à maintenir.

Tester les erreurs d’une API permet de s’assurer que les clients reçoivent des messages d’erreur appropriés, améliorant ainsi la fiabilité de l’application.

Utilisation des fixtures pytest pour les tests d’erreurs

Les fixtures sont une fonctionnalité puissante de pytest pour gérer les configurations et les nettoyages de tests. Elles permettent d’améliorer la réutilisabilité et la lisibilité des tests d’erreurs.

Bases des fixtures

Commençons par les bases des fixtures pytest. Elles permettent de regrouper des tâches de configuration communes et de les réutiliser dans plusieurs tests.

import pytest

@pytest.fixture
def sample_data():
    return {"name": "test", "value": 42}

def test_sample_data(sample_data):
    assert sample_data["name"] == "test"
    assert sample_data["value"] == 42

Dans cet exemple, la fixture sample_data est définie et utilisée dans la fonction de test.

Utilisation d’une fixture pour la configuration d’une API

Dans les tests d’API, une fixture peut être utilisée pour configurer le client de test. L’exemple suivant montre comment configurer un AsyncClient httpx en tant que fixture.

import pytest
from httpx import AsyncClient
from main import app

@pytest.fixture
async def async_client():
    async with AsyncClient(app=app, base_url="http://test") as ac:
        yield ac

@pytest.mark.asyncio
async def test_read_item_zero(async_client):
    response = await async_client.get("/items/0")
    assert response.status_code == 400
    assert response.json() == {"detail": "Item ID cannot be zero"}

Dans ce test, la fixture async_client est utilisée pour configurer le client et envoyer les requêtes API.

Utilisation des fixtures pour tester plusieurs erreurs

Les fixtures permettent de rendre les tests d’erreurs plus efficaces en les combinant avec les tests paramétrés.

@pytest.mark.asyncio
@pytest.mark.parametrize("item_id, expected_status, expected_detail", [
    (0, 400, "Item ID cannot be zero"),
    (-1, 404, "Item not found"),
])
async def test_read_item_errors(async_client, item_id, expected_status, expected_detail):
    response = await async_client.get(f"/items/{item_id}")
    assert response.status_code == expected_status
    assert response.json() == {"detail": expected_detail}

Dans cet exemple, la combinaison de la fixture async_client et du test paramétré permet d’écrire les cas de test d’erreurs de manière concise.

Fixture pour la connexion à une base de données

Pour les tests nécessitant une connexion à une base de données, une fixture peut gérer la configuration et le nettoyage de la connexion.

import sqlalchemy
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker

DATABASE_URL = "sqlite+aiosqlite:///./test.db"

@pytest.fixture
async def async_db_session():
    engine = create_async_engine(DATABASE_URL, echo=True)
    async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
    async with async_session() as session:
        yield session
    await engine.dispose()

async def test_db_interaction(async_db_session):
    result = await async_db_session.execute("SELECT 1")
    assert result.scalar() == 1

Dans cet exemple, la fixture async_db_session gère la connexion à la base de données et effectue le nettoyage après les tests.

L’utilisation des fixtures permet de réduire la duplication du code de test et facilite la maintenance des tests. Exploitez les fixtures pytest pour réaliser des tests d’erreurs efficaces.

Conclusion

Les tests d’exceptions et d’erreurs avec pytest sont essentiels pour améliorer la qualité des logiciels. Cet article a couvert la configuration de base, les méthodes de test des exceptions, la validation des messages d’erreur, les tests de gestion de multiples exceptions, les tests d’exceptions personnalisées, les tests d’erreurs d’API, et l’utilisation des fixtures pour les tests d’erreurs.

En exploitant pleinement les fonctionnalités de pytest, vous pouvez effectuer des tests de gestion des erreurs de manière efficace et efficiente. Cela permet d’améliorer la fiabilité et la maintenabilité du code, tout en évitant les problèmes dus à des erreurs inattendues. Continuez à apprendre les techniques de test avec pytest pour développer des logiciels de meilleure qualité.

Sommaire