Maîtriser pytest : guide complet du développeur Python (1/3)

Partie 1 : Fondamentaux et premiers pas

Dans le monde du développement Python, la qualité du code est une préoccupation constante. Les tests automatisés constituent la pierre angulaire de cette qualité, permettant de détecter les bugs au plus tôt et d'assurer la fiabilité des applications. Parmi les nombreux outils disponibles, pytest s'est imposé comme le framework de test de référence dans l'écosystème Python.

Cette série d'articles en trois parties vous guidera à travers tous les aspects de pytest, de ses fondamentaux jusqu'aux pratiques les plus avancées. Dans cette première partie, nous explorerons les bases essentielles pour démarrer avec pytest. Nous commencerons par comprendre ce qui distingue pytest des autres frameworks de test comme unittest et nose, avant de plonger dans sa syntaxe intuitive et ses premières fonctionnalités. À travers des exemples concrets, vous découvrirez comment écrire vos premiers tests et tirer parti des assertions puissantes de pytest. Nous aborderons également l'importance de la vérification des types dans vos tests, un aspect souvent négligé mais crucial pour la robustesse de votre code.

Que vous soyez débutant en test unitaire ou développeur expérimenté cherchant à approfondir vos connaissances, cet article vous fournira les bases solides nécessaires pour commencer à utiliser pytest efficacement dans vos projets.

Comparatif des frameworks de test en Python

Dans ce guide, nous avons fait le choix de nous concentrer sur pytest, qui représente aujourd'hui la solution la plus aboutie, selon nous, pour les tests en Python. Pour bien comprendre les avantages qui ont motivé ce choix, examinons l'évolution des frameworks de test Python à travers leurs trois principales générations.

unittest : l'approche traditionnelle

unittest s'inspire fortement de JUnit, le framework de test Java. Il fait partie de la bibliothèque standard Python, ce qui signifie qu'il est toujours disponible sans installation supplémentaire. Cependant, sa syntaxe héritée de Java impose une structure rigide qui ne correspond pas toujours à la philosophie Python.

Par exemple, avec unittest, un test simple ressemble à ceci :

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        self.calculator = Calculator()

    def test_addition(self):
        self.assertEqual(self.calculator.add(2, 2), 4)

Cette approche orientée objet, bien que structurée, est plus verbeuse.

nose : la recherche de simplicité

nose a émergé comme une alternative plus légère à unittest. Il a introduit une approche plus pythonique où les tests sont de simples fonctions. Le même test avec nose devient :

def test_addition():
    calculator = Calculator()
    assert calculator.add(2, 2) == 4

Malheureusement, nose n'est plus maintenu depuis 2016, ce qui pose des problèmes de compatibilité avec les versions récentes de Python.

pytest : le standard moderne

pytest combine la simplicité de nose avec des fonctionnalités puissantes. Il offre :

  • Des assertions simples et intuitives,
  • Des messages d'erreur détaillés qui facilitent le débogage,
  • Un système de fixtures avec une injection plus flexible que unittest,
  • Un riche écosystème de plugins.
  • Une compatibilité native avec les tests unittest existants, permettant une migration progressive des projets. Les classes TestCase et leurs méthodes de test sont automatiquement détectées et exécutées par pytest, qui supporte également les décorateurs @unittest.skip ainsi que les méthodes setUp/tearDown

Voici le même test en pytest :

def test_addition():
    assert calculator.add(2, 2) == 4

pytest s'impose aujourd'hui comme le choix optimal.

Premiers pas avec pytest

Installation et configuration initiale

Avant tout, il faut installer pytest. Dans un environnement virtuel Python, utilisez simplement pip :

pip install pytest

Si vous utilisez Poetry (que nous verrons plus en détail plus tard), la commande sera :

poetry add pytest --group dev

Structure d'un test simple

Un test pytest dans sa forme la plus simple est une fonction dont le nom commence par "test_". Prenons un exemple concret avec une fonction de calcul basique :

# Dans math_operations.py
def add_numbers(a: int, b: int) -> int:
    return a + b

# Dans test_math_operations.py
def test_add_numbers():
    # Given (Étant donné)
    a = 5
    b = 3
    expected_result = 8
    
    # When (Quand)
    result: int = add_numbers(a, b)
    
    # Then (Alors)
    assert result == expected_result

Le pattern Given-When-Then et son importance

Cette structure en trois parties, également connue sous le nom de "Arrange-Act-Assert" dans certaines communautés de développement, représente une approche méthodique pour concevoir des tests clairs et maintenables. Chaque partie a un rôle spécifique:

Given (Étant donné) : Cette première étape établit le contexte du test. C'est ici que nous préparons toutes les données et conditions nécessaires. Par exemple, si nous testons une fonction de calcul de prix avec remise, le Given inclura le prix initial et le pourcentage de remise.

When (Quand/Lorsque) : Cette étape représente l'action principale que nous testons. Elle devrait idéalement se limiter à un seul appel de fonction ou une seule opération. Si vous avez besoin de plusieurs actions, c'est souvent le signe que votre test fait trop de choses à la fois.

Then (Alors) : C'est la phase de vérification où nous nous assurons que le résultat correspond à nos attentes. Les assertions doivent être précises et ne vérifier que ce qui est directement lié à l'action du When.

Prenons un exemple plus élaboré :

def test_calculate_discounted_price():
    # Given
    original_price = 100
    discount_percentage = 20
    expected_price = 80
    
    # When
    result: int = calculate_discount(original_price, discount_percentage)
    
    # Then
    assert result == expected_price

Cette structure présente plusieurs avantages concrets :

  1. Chaque section a une responsabilité unique et claire
  2. Les tests deviennent auto-documentés et faciles à comprendre
  3. La maintenance est simplifiée car les modifications peuvent être ciblées précisément
  4. Les échecs de test sont plus faciles à diagnostiquer car nous savons exactement quelle partie pose problème

Les assertions dans pytest

Contrairement à unittest qui requiert des méthodes spécifiques comme assertEqual(), pytest s'appuie simplement sur le mot-clé assert natif de Python. Cette approche permet d'écrire des tests qui suivent la logique de votre code :

def test_string_operations():
    text = "Hello, World!"
    
    assert len(text) == 13
    assert text.upper() == "HELLO, WORLD!"
    assert "Hello" in text
    assert text.startswith("Hello")

La puissance de pytest se révèle lorsqu'une assertion échoue. Prenons un exemple qui semble simple mais révélateur :

def test_division():
    result = 10 / 3
    assert result == 3.33  # Will fail with clear message 

Le post-processeur de pytest procède en plusieurs étapes méthodiques. D'abord, il capture précisément les valeurs en jeu dans l'assertion - dans notre exemple, il obtient le résultat de 3.3333333333333335. Ensuite, il compare cette valeur avec celle attendue dans le test, soit 3.33. Enfin, il construit une représentation visuelle qui met en évidence la différence entre ces deux valeurs, permettant au développeur d'identifier rapidement la source de l'échec.

En cas d'échec du test, pytest génère un message qui montre la comparaison entre la valeur attendue et la valeur obtenue :

>       assert result == 3.33
E       assert 3.3333333333333335 == 3.33
E         +3.3333333333333335
E         -3.33

Cette sortie met en évidence non seulement que le test a échoué, mais aussi exactement comment : elle montre la valeur obtenue (3.3333333333333335) et la valeur attendue (3.33), permettant d'identifier immédiatement la nature de la différence. 

Les équipes de développement peuvent personnaliser ce comportement en créant leurs propres règles d'analyse et de formatage des échecs d'assertion. Par exemple, pour comparer des structures de données spécifiques à leur domaine métier ou pour adapter le format des messages d'erreur aux conventions de leur projet.

Gestion des exceptions

La vérification des cas d'erreur est aussi importante que celle du fonctionnement nominal d'une fonction. pytest propose deux approches pour vérifier la gestion des exceptions avec pytest.raises :

La méthode classique avec assert in permet de vérifier en détail l'exception levée. En capturant l'exception dans une variable via as excinfo, nous pouvons inspecter tous ses attributs, dont le message d'erreur :

def test_divide_by_zero():
    with pytest.raises(ValueError) as excinfo:
        divide(10, 0)
    assert "Cannot divide by zero" in str(excinfo.value)

La méthode simplifiée utilise le paramètre match. Plus concise, elle est idéale quand nous voulons simplement vérifier que l'exception est du bon type et contient le bon message :

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

Ces deux approches sont complémentaires : utilisez la première lorsque vous avez besoin d'effectuer des vérifications approfondies sur l'exception, et la seconde pour des cas plus simples où seuls le type et le message d'erreur vous intéressent.

Groupement des tests connexes

Pour organiser des tests liés, nous pouvons utiliser des classes. Bien que ce ne soit pas obligatoire avec pytest (contrairement à unittest), cela peut améliorer la lisibilité :

class TestCalculator:
    def test_addition(self):
        assert add_numbers(2, 3) == 5
    
    def test_substract(self):
        assert subtract_numbers(5, 3) == 2
    
    def test_multiply(self):
        assert multiply_numbers(4, 3) == 12

Exécution des tests

pytest offre plusieurs façons d'exécuter les tests. Les plus courantes sont :

# Exécute tous les tests du répertoire courant et des sous-répertoires
pytest

# Lance les tests d'un fichier spécifique uniquement
pytest test_math_operations.py

# Exécute une seule fonction de test
pytest test_math_operations.py::test_add_numbers

# Lance tous les tests d'une classe de test spécifique
pytest test_math_operations.py::TestCalculator

Le retour d'information immédiat

Un des grands avantages de pytest est la qualité de ses retours. Pour chaque test, vous obtenez :

  • Un point vert (.) pour un succès
  • Un F pour un échec
  • Un E pour une erreur
  • Un s pour un test sauté (skipped)

En cas d'échec, pytest fournit :

  • Le nom exact du test qui a échoué
  • La ligne de code problématique
  • Les valeurs attendues et obtenues
  • Une trace complète pour faciliter le débogage

Voici un exemple illustratif que je vais lancer depuis mon IDE :

import pytest

def test_success():
   # Test simple qui doit réussir
   assert 1 + 1 == 2

def test_failure():
   # Test qui échoue intentionnellement avec une assertion fausse
   assert 1 + 1 == 3

def test_error():
   # Test qui génère une erreur d'exécution
   # La variable undefined_variable n'existe pas, provoquant une erreur
   undefined_variable + 1

@pytest.mark.skip(reason="Not implemented yet")
def test_skipped():
   # Test ignoré car non encore implémenté
   pass

def test_comparison_failure():
   # Test de comparaison de dictionnaires qui échoue
   # Les dictionnaires diffèrent sur la valeur de 'age'
   expected = {"name": "Alice", "age": 30}
   actual = {"name": "Alice", "age": 25}
   assert expected == actual

def test_type_error():
   # Test qui génère une erreur de type intentionnelle
   # Impossible d'additionner une chaîne et un entier
   result = "2" + 2

Ces tests vont échouer :

  1. test_failure (assert 1 + 1 == 3 est faux)
  2. test_error (undefined_variable n'existe pas)
  3. test_comparison_failure (les dictionnaires ne sont pas égaux, age différent)
  4. test_type_error (impossible d'additionner str et int)

test_skipped sera ignoré, et test_success passera.

Voici l’output sur mon terminal:

Ainsi que le détail des tests:

Et un bref résumé de mes tests:

Lorsque j'utilise le mode verbeux :

  • . devient PASSED (test réussi)
  • F devient FAILED (test échoué)  
  • E devient ERROR (test en erreur)
  • s devient SKIPPED (test ignoré)

L'apport complémentaire de la vérification des types

Python étant un langage à typage dynamique, la gestion des types mérite une attention particulière. Il existe deux niveaux de vérification qui répondent à des besoins différents.

D'une part, mypy offre une vérification statique des types qui s'intègre directement dans le code source :

def add(a: int, b: int) -> int:
    return a + b
# Mypy détectera cette erreur lors de l'analyse du code
result = add("2", 3)  # Type error: Argument 1 has incompatible type "str"

Ces tests garantissent que les contrats de type sont respectés, même si la vérification statique venait à être désactivée ou contournée. Ils agissent comme un filet de sécurité indépendant qui valide non seulement les types, mais aussi le comportement attendu de la fonction.

Cette double approche crée un système robuste où :

  • Mypy offre un retour immédiat aux développeurs pendant l'écriture du code
  • Les tests automatisés garantissent le respect des contrats de type dans la durée
  • Les deux mécanismes se complètent pour assurer la qualité du code

En production, cette combinaison permet de détecter les problèmes de type le plus tôt possible tout en conservant des garanties fortes sur le respect des contrats établis.

Tester les types : au-delà de la vérification statique

Bien que mypy offre une première ligne de défense avec sa vérification statique, les tests de types ajoutent une couche de vérification dynamique essentielle. Pour être vraiment efficaces, ces tests doivent être séparés des tests de comportement.

def calculate_total_price(quantity: int, unit_price: float) -> float:
    return quantity * unit_price

# Test dédié aux contrats de types
def test_calculate_total_price_types():
    result = calculate_total_price(2, 10.5)
    assert isinstance(2, int), "La quantité doit être un entier"
    assert isinstance(10.5, float), "Le prix unitaire doit être un float"
    assert isinstance(result, float), "Le résultat doit être un float"

# Test du comportement métier
def test_calculate_total_price_behavior():
    assert calculate_total_price(2, 10.5) == 21.0
    assert calculate_total_price(0, 10.5) == 0.0

Les différences clés entre ces deux types de tests sont importantes :

  1. Tests de contrats de types :
    • Se concentrent uniquement sur les types des entrées/sorties
    • Vérifient que la fonction respecte ses annotations de type
    • Servent de documentation sur les types attendus
    • Un seul cas de test suffit généralement
  2. Tests de comportement :
    • Se concentrent sur la logique métier
    • Vérifient les résultats pour différents cas d'utilisation
    • Testent les cas limites et particuliers
    • Nécessitent plusieurs cas de test

Cette séparation s'inscrit dans une stratégie complète de gestion des types :

  • Mypy vérifie statiquement le code pendant le développement
  • Les tests de types vérifient dynamiquement les contrats à l'exécution
  • Les tests de comportement s'assurent que la logique fonctionne correctement

Ainsi, même si mypy est désactivé ou si les annotations sont incomplètes, les tests de types continuent de garantir le respect des contrats essentiels. Cette redondance intentionnelle renforce la robustesse de notre base de code.

Conclusion

Dans cette première partie, nous avons vu les fondamentaux de pytest, qui s'est imposé comme le framework de test Python de référence. Sa syntaxe claire, ses assertions puissantes et sa structure Given-When-Then facilitent l'écriture de tests robustes. L'intégration du typage statique et dynamique renforce la fiabilité du code testé.

Dans le prochain article de cette série, nous explorerons l'organisation des tests et l'utilisation des fixtures, des concepts clés pour structurer efficacement vos suites de tests à grande échelle.