Le chat d'octets

Photo de Gribouille

Les tests unitaires avec pytest

Quest-ce qu'un test unitaire ?

Un test unitaire est un test qui vérifie qu'une petite partie de code (souvent une fonction ou une méthode) fonctionne comme prévu. L'objectif est d'isoler cette partie du code pour s'assurer qu'elle se comporte correctement dans divers scénarios.

Objectifs des Tests Unitaires

  1. Validation du Comportement : Assurer que chaque unité de code fonctionne comme prévu.
  2. Détection Précoce des Bugs : Identifier les problèmes dès les premières phases du développement.
  3. Facilitation des Refactoring : Permettre de modifier le code en toute confiance, sachant que les tests vérifieront que le comportement reste inchangé.
  4. Documentation : Servir de documentation vivante sur la manière dont le code est censé fonctionner.

Caractéristiques des Tests Unitaires

Processus de Test Unitaire

  1. Écriture du Test : Avant ou après l'écriture du code, selon la méthodologie (TDD ou non).
  2. Exécution du Test : Le test est exécuté pour vérifier que le code se comporte comme attendu.
  3. Correction : Si le test échoue, le code est corrigé jusqu'à ce que le test passe.
  4. Refactorisation : Le code peut être amélioré en toute sécurité, les tests garantissant que le comportement reste correct.

Les outils pour les tests unitaires en Python

Python propose plusieurs bibliothèques pour écrire des tests :

Pourquoi utiliser pytest ?

Comment écrire un test unitaire avec pytest ?

Installation

pytest ne fait pas partie de la bibliothèque standard de Python. Il faut donc l’installer manuellement à l’aide de la commande suivante :

pip install pytest

Structure d'un projet

Pour les projets de grande envergure, il est recommandé d’organiser les tests dans un répertoire dédié, par exemple tests/.

Pour que pytest détecte automatiquement vos fichiers de test, ceux-ci doivent respecter la convention de nommage suivante : test_*.py. De plus, les noms des fonctions de test doivent commencer par test_

Un exemple de test

Dans cet exemple, nous allons tester les fonctions définies dans le fichier calculatrice.py, en utilisant des assertions pour vérifier que les résultats sont corrects. Les fichiers de test seront placés dans le même répertoire que calculatrice.py.

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

def soustraction(a, b):
    return a - b
# test_calculatrice.py
from calculatrice import addition, soustraction

def test_addition():
    assert addition(5, 10) == 15
    assert addition(-1, 1) == 0

def test_soustraction():
    assert soustraction(3, 2)  == 1
    assert soustraction(-1, 1) == -2

Exécuter les tests

Dans un terminal, nous exécutons l’une des deux commandes suivantes depuis le répertoire où se trouvent les tests :

pytest
pytest test_calculatrice.py

Résultat :

========= test session starts =================
collected 2 items

test_calculatrice.py                [100%]

======= 2 passed in 0.01s =====================

Pour un affichage plus détaillé des tests, nous ajoutons l’option -v (ou --verbose) à la commande pytest. Cela nous permet de voir le nom et le résultat de chaque test.

pytest -v
pytest -v test_calculatrice.py

Résultat :

========= test session starts =================
collected 2 items                   

test_calculatrice.py::test_addition PASSED       [ 50%]
test_calculatrice.py::test_soustraction PASSED   [100%]

======= 2 passed in 0.01s =====================

Quelques options de pytest

La commande pytest offre de nombreuses options pour personnaliser l'exécution des tests. Voici quelques exemples :

Nous pouvons obtenir une liste complète des options disponibles en exécutant pytest --help dans le terminal.

Assertions disponibles avec pytest

Avec pytest, nous utilisons directement assert, ce qui rend pytest très lisible.

Assertion Description
assert a == b Vérifie que a est égal à b
assert a != b Vérifie que a différent à b
assert a in b Vérifie que a est dans b
assert isinstance(a, b) Vérifie que a est une instance de b
assert func() raises Exception Vérifie qu'une exception est levée.

Tester les exceptions avec pytest.raises()

Si une fonction doit lever une exception, nous pouvons tester ce comportement avec pytest.raises.

# calculatrice.py
    
def division(a,b):
    if b == 0 :
        raise ValueError("Division par zéro impossible")
    return a / b
# test_calculatrice.py
import pytest
from calculatrice import division

# def test_division():
#    with pytest.raises(ValueError):
#        division(10, 0)

def test_division():
    with pytest.raises(ValueError, match="Division par zéro impossible"):
        division(10, 0)

Paramétrisation

Une fois que les bases du test unitaire avec pytest sont en place, nous pouvons aller un peu plus loin avec un outil très pratique : le décorateur @pytest.mark.parametrize. Ce décorateur nous permet d’automatiser des tests répétitifs en testant une même fonction avec plusieurs jeux de données. Plutôt que d’écrire plusieurs tests identiques, on les regroupe en un seul, plus clair et plus efficace. C’est un gain de temps important, et surtout, cela renforce la robustesse de notre code. En diversifiant facilement les cas testés, nous nous assurons que notre application se comporte correctement dans différentes situations.

Exemple

Dans l'exemple suivant pytest va tester la fonction addition avec 3 jeux de données différents.

@pytest.mark.parametrize("a, b, res_attendu", [
(5, 10, 15),
(-1, 1, 0),
(3, 2, 5)
])
def test_addition(a, b, res_attendu):
    assert addition(a, b) == res_attendu

Fixtures

Un autre outil très utile que nous utilisons avec pytest s’appelle une fixture. Derrière ce nom un peu technique se cache un concept simple : il s’agit de préparer à l’avance ce dont un test a besoin pour fonctionner correctement. Par exemple, si plusieurs tests ont besoin d’une base de données ou d’un objet spécifique, la fixture va se charger de tout mettre en place automatiquement avant chaque test. Cela nous évite de répéter le même code encore et encore, tout en gardant nos tests bien organisés. Les fixtures rendent nos tests plus clairs, plus fiables et plus faciles à maintenir sur le long terme.

Exemple

Dans cet exemple, nous allons utiliser pytest avec une fixture pour tester des données venant d’un fichier à propos de nous, les chats. Une fixture, c’est comme une préparation avant de commencer les tests : ici, elle charge un fichier contenant des infos sur nos noms, nos âges et nos comportements (comme attaquer des pieds humains, par exemple). Ces données sont ensuite transformées en une liste de dictionnaires, ce qui nous permet de vérifier facilement des éléments, comme le nombre de chats dans le fichier ou si un comportement spécifique, comme les attaques surprises, est bien mentionné. Cette approche rend les tests plus modulaires et réutilisables tout en permettant de travailler efficacement avec des données externes.

# Fichier data/chats_data.txt
Nom,Âge,Caractéristique
Gribouille,16,Maître du canapé et des siestes interminables
Fripouille,3,Spécialiste des câlins en pleine nuit
Lancelot,1,Chasseur de souris invisible
Halloween,9,Reine incontestée des fenêtres
Moustique,4,Adepte des attaques surprises sur les pieds humains
Canaille,2,Expert en étirement acrobatique
# test_data.py
import pytest

# Fixture qui charge les données sur les chats depuis le fichier texte
@pytest.fixture
def charger_donnees_chats():
    chats = []
    with open('data/chats_data.txt', 'r') as file:
        # Lire la première ligne pour les entêtes
        headers = file.readline().strip().split(',')
        for line in file:
            values = line.strip().split(',')
            chat = dict(zip(headers, values))
            chats.append(chat)
    return chats

# Test qui vérifie qu'il y a bien 6 chats dans le fichier
def test_nombre_de_chats(charger_donnees_chats):
    assert len(charger_donnees_chats) == 6

# Test qui vérifie qu'il existe un chat avec le comportement "attaques sur les pieds humains"
def test_comportement(charger_donnees_chats):
    comportements = [chat['Caractéristique'] for chat in charger_donnees_chats]
    assert "Adepte des attaques surprises sur les pieds humains" in comportements

# Test qui vérifie que tous les chats ont un âge valide (supérieur à 0)
def test_ages_valides(charger_donnees_chats):
    ages = [int(chat['Âge']) for chat in charger_donnees_chats]
    assert all(age > 0 for age in ages)

Conclusion

pytest est un outil puissant et flexible pour écrire, exécuter et organiser des tests. Il nous permet de structurer facilement nos tests, d'utiliser des assertions pour vérifier le comportement des fonctions, et d'obtenir un retour détaillé sur les résultats. De plus, avec ses nombreuses options, comme -v pour un affichage plus détaillé ou -k pour sélectionner des tests spécifiques, il s’adapte à toutes nos attentes.

Si tu veux gratter un peu plus loin, la documentation de pytest est là pour toi. C’est un peu comme mon coussin préféré : toujours là, toujours douillet, et prête à te tendre la patte quand ton code commence à ronronner de travers. Allez, jette un œil à la documentation officielle.