Tests unitaires en Python : guide complet avec exemples
Le test Python avec pytest, TDD, le mock et la couverture
Le test unitaire garantit que votre code Python fonctionne correctement et continue de fonctionner à mesure que votre projet évolue. Ce guide complet aborde tout ce que vous devez savoir sur le test unitaire en Python, des concepts de base aux techniques avancées.

Pourquoi le test unitaire est important
Le test unitaire apporte de nombreux avantages aux développeurs Python :
- Détection précoce des bugs : Détecter les bugs avant qu’ils ne parviennent en production
- Qualité du code : Vous oblige à écrire du code modulaire et testable
- Confiance lors du refactor : Modifier le code en toute sécurité sachant que les tests détecteront les régressions
- Documentation : Les tests servent de documentation exécutable sur la manière dont le code devrait fonctionner
- Développement plus rapide : Les tests automatisés sont plus rapides que le test manuel
- Meilleur design : Écrire du code testable conduit à une meilleure architecture
Comprendre les fondamentaux du test unitaire
Qu’est-ce qu’un test unitaire ?
Un test unitaire vérifie la plus petite partie testable d’une application (généralement une fonction ou une méthode) en isolation. Il doit être :
- Rapide : S’exécuter en millisecondes
- Isolé : Indépendant des autres tests et des systèmes externes
- Répétable : Produire les mêmes résultats à chaque fois
- Auto-validé : Passer ou échouer clairement sans inspection manuelle
- Timely : Écrit avant ou en parallèle du code
La pyramide des tests
Un ensemble de tests sain suit la pyramide des tests :
/\
/ \ Tests E2E (Peu)
/____\
/ \ Tests d'intégration (Quelques-uns)
/_____ ___\
/ \ Tests unitaires (Beaucoup)
/_____ __ ___\
Les tests unitaires forment la base - ils sont nombreux, rapides et donnent des retours rapides.
Comparaison des cadres de test Python
unittest : Le cadre intégré
La bibliothèque standard de Python inclut unittest, inspiré de JUnit :
import unittest
class TestCalculator(unittest.TestCase):
def setUp(self):
"""Exécuté avant chaque test"""
self.calc = Calculator()
def tearDown(self):
"""Exécuté après chaque test"""
self.calc = None
def test_addition(self):
result = self.calc.add(2, 3)
self.assertEqual(result, 5)
def test_division_by_zero(self):
with self.assertRaises(ZeroDivisionError):
self.calc.divide(10, 0)
if __name__ == '__main__':
unittest.main()
Avantages :
- Intégré, pas besoin d’installation
- Bon pour les développeurs familiers avec les cadres xUnit
- Enterprise-friendly, bien établi
Inconvénients :
- Syntaxe verbeuse avec du code de rembourrage
- Exige des classes pour l’organisation des tests
- Gestion des fixtures moins flexible
pytest : Le choix moderne
pytest est le cadre de test tiers le plus populaire :
import pytest
def test_addition():
calc = Calculator()
assert calc.add(2, 3) == 5
def test_division_by_zero():
calc = Calculator()
with pytest.raises(ZeroDivisionError):
calc.divide(10, 0)
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
])
def test_addition_parametrized(a, b, expected):
calc = Calculator()
assert calc.add(a, b) == expected
Avantages :
- Syntaxe simple, pythonique
- Système de fixtures puissant
- Écosystème de plugins excellent
- Meilleure signalisation d’erreur
- Test paramétré intégré
Inconvénients :
- Nécessite l’installation
- Moins connu des développeurs d’autres langues
Installation :
pip install pytest pytest-cov pytest-mock
Écrire vos premiers tests unitaires
Construisons un exemple simple à partir de zéro en utilisant le développement dirigé par les tests (TDD). Si vous êtes nouveau en Python ou si vous avez besoin d’un rappel rapide sur la syntaxe et les fonctionnalités du langage, consultez notre feuille de rappel Python pour un aperçu complet des fondamentaux de Python.
Exemple : Fonctions d’utilité de chaîne
Étape 1 : Écrire le test d’abord (Rouge)
Créer test_string_utils.py :
import pytest
from string_utils import reverse_string, is_palindrome, count_vowels
def test_reverse_string():
assert reverse_string("hello") == "olleh"
assert reverse_string("") == ""
assert reverse_string("a") == "a"
def test_is_palindrome():
assert is_palindrome("racecar") == True
assert is_palindrome("hello") == False
assert is_palindrome("") == True
assert is_palindrome("A man a plan a canal Panama") == True
def test_count_vowels():
assert count_vowels("hello") == 2
assert count_vowels("HELLO") == 2
assert count_vowels("xyz") == 0
assert count_vowels("") == 0
Étape 2 : Écrire le code minimal pour passer (Vert)
Créer string_utils.py :
def reverse_string(s: str) -> str:
"""Inverser une chaîne"""
return s[::-1]
def is_palindrome(s: str) -> bool:
"""Vérifier si la chaîne est un palindrome (en ignorant la casse et les espaces)"""
cleaned = ''.join(s.lower().split())
return cleaned == cleaned[::-1]
def count_vowels(s: str) -> int:
"""Compter les voyelles dans une chaîne"""
return sum(1 for char in s.lower() if char in 'aeiou')
Étape 3 : Exécuter les tests
pytest test_string_utils.py -v
Résultat :
test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED
Techniques de test avancées
Utilisation des fixtures pour la configuration des tests
Les fixtures fournissent une configuration de test réutilisable :
import pytest
from database import Database
@pytest.fixture
def db():
"""Créer une base de données de test"""
database = Database(":memory:")
database.create_tables()
yield database # Fournir au test
database.close() # Nettoyage après le test
@pytest.fixture
def sample_users(db):
"""Ajouter des utilisateurs d'exemple à la base de données"""
db.add_user("Alice", "alice@example.com")
db.add_user("Bob", "bob@example.com")
return db
def test_get_user(sample_users):
user = sample_users.get_user_by_email("alice@example.com")
assert user.name == "Alice"
def test_user_count(sample_users):
assert sample_users.count_users() == 2
Portée des fixtures
Contrôler la durée de vie des fixtures avec des portées :
@pytest.fixture(scope="function") # Par défaut : exécuté pour chaque test
def func_fixture():
return create_resource()
@pytest.fixture(scope="class") # Une fois par classe de test
def class_fixture():
return create_resource()
@pytest.fixture(scope="module") # Une fois par module
def module_fixture():
return create_resource()
@pytest.fixture(scope="session") # Une fois par session de test
def session_fixture():
return create_expensive_resource()
Mocker des dépendances externes
Utilisez le mocking pour isoler le code des dépendances externes :
from unittest.mock import Mock, patch, MagicMock
import requests
class WeatherService:
def get_temperature(self, city):
response = requests.get(f"https://api.weather.com/{city}")
return response.json()["temp"]
# Test avec mock
def test_get_temperature():
service = WeatherService()
# Mock de la fonction requests.get
with patch('requests.get') as mock_get:
# Configurer la réponse du mock
mock_response = Mock()
mock_response.json.return_value = {"temp": 72}
mock_get.return_value = mock_response
# Test
temp = service.get_temperature("Boston")
assert temp == 72
# Vérifier l'appel
mock_get.assert_called_once_with("https://api.weather.com/Boston")
Utilisation du plugin pytest-mock
pytest-mock fournit une syntaxe plus propre :
def test_get_temperature(mocker):
service = WeatherService()
# Mock avec pytest-mock
mock_response = mocker.Mock()
mock_response.json.return_value = {"temp": 72}
mocker.patch('requests.get', return_value=mock_response)
temp = service.get_temperature("Boston")
assert temp == 72
Test paramétré
Tester efficacement plusieurs scénarios :
import pytest
@pytest.mark.parametrize("input,expected", [
("", True),
("a", True),
("ab", False),
("aba", True),
("racecar", True),
("hello", False),
])
def test_is_palindrome_parametrized(input, expected):
assert is_palindrome(input) == expected
@pytest.mark.parametrize("number,is_even", [
(0, True),
(1, False),
(2, True),
(-1, False),
(-2, True),
])
def test_is_even(number, is_even):
assert (number % 2 == 0) == is_even
Test des exceptions et de la gestion d’erreur
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Impossible de diviser par zéro")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError, match="Impossible de diviser par zéro"):
divide(10, 0)
def test_divide_by_zero_with_message():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert "zéro" in str(exc_info.value).lower()
# Tester que aucune exception n'est levée
def test_divide_success():
result = divide(10, 2)
assert result == 5.0
Test du code asynchrone
Le test du code asynchrone est essentiel pour les applications Python modernes, surtout lorsqu’on travaille avec des API, des bases de données ou des services d’IA. Voici comment tester les fonctions asynchrones :
import pytest
import asyncio
async def fetch_data(url):
"""Fonction asynchrone pour récupérer des données"""
await asyncio.sleep(0.1) # Simuler une appel d'API
return {"status": "success"}
@pytest.mark.asyncio
async def test_fetch_data():
result = await fetch_data("https://api.example.com")
assert result["status"] == "success"
@pytest.mark.asyncio
async def test_fetch_data_with_mock(mocker):
# Mock de la fonction asynchrone
mock_fetch = mocker.AsyncMock(return_value={"status": "mocked"})
mocker.patch('module.fetch_data', mock_fetch)
result = await fetch_data("https://api.example.com")
assert result["status"] == "mocked"
Pour des exemples pratiques de test du code asynchrone avec des services d’IA, consultez notre guide sur l’intégration d’Ollama avec Python, qui inclut des stratégies de test pour les interactions avec les modèles de langage.
Couverture du code
Mesurer combien de votre code est testé :
En utilisant pytest-cov
# Exécuter les tests avec la couverture
pytest --cov=myproject tests/
# Générer un rapport HTML
pytest --cov=myproject --cov-report=html tests/
# Afficher les lignes manquantes
pytest --cov=myproject --cov-report=term-missing tests/
Configuration de la couverture
Créer .coveragerc :
[run]
source = myproject
omit =
*/tests/*
*/venv/*
*/__pycache__/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
@abstractmethod
Bonnes pratiques de couverture
- Viser 80 % + de couverture pour les chemins critiques
- Ne pas s’obséder sur 100 % - se concentrer sur des tests significatifs
- Tester les cas limites pas seulement les cas heureux
- Exclure le code de rembourrage des rapports de couverture
- Utiliser la couverture comme un guide et non comme un objectif
Organisation des tests et structure du projet
Structure recommandée
myproject/
├── myproject/
│ ├── __init__.py
│ ├── module1.py
│ ├── module2.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Fixtures partagées
│ ├── test_module1.py
│ ├── test_module2.py
│ ├── test_utils.py
│ └── integration/
│ ├── __init__.py
│ └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt
conftest.py pour les fixtures partagées
# tests/conftest.py
import pytest
from myproject.database import Database
@pytest.fixture(scope="session")
def test_db():
"""Base de données de test à l'échelle de la session"""
db = Database(":memory:")
db.create_schema()
yield db
db.close()
@pytest.fixture
def clean_db(test_db):
"""Base de données propre pour chaque test"""
test_db.clear_all_tables()
return test_db
Configuration de pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--strict-markers
--cov=myproject
--cov-report=term-missing
--cov-report=html
markers =
slow: marque les tests comme lents
integration: marque les tests comme des tests d'intégration
unit: marque les tests comme des tests unitaires
Bonnes pratiques pour le test unitaire
1. Suivre le modèle AAA
Arrange-Act-Assert rend les tests clairs :
def test_user_creation():
# Arrange
username = "john_doe"
email = "john@example.com"
# Act
user = User(username, email)
# Assert
assert user.username == username
assert user.email == email
assert user.is_active == True
2. Un seul assertion par test (recommandation, pas une règle)
# Bon : Test ciblé
def test_user_username():
user = User("john_doe", "john@example.com")
assert user.username == "john_doe"
def test_user_email():
user = User("john_doe", "john@example.com")
assert user.email == "john@example.com"
# Aussi acceptable : Assertions liées
def test_user_creation():
user = User("john_doe", "john@example.com")
assert user.username == "john_doe"
assert user.email == "john@example.com"
assert isinstance(user.created_at, datetime)
3. Utiliser des noms descriptifs pour les tests
# Mauvais
def test_user():
pass
# Bon
def test_user_creation_with_valid_data():
pass
def test_user_creation_fails_with_invalid_email():
pass
def test_user_password_is_hashed_after_setting():
pass
4. Tester les cas limites et les frontières
def test_age_validation():
# Cas valides
assert validate_age(0) == True
assert validate_age(18) == True
assert validate_age(120) == True
# Cas limites
assert validate_age(-1) == False
assert validate_age(121) == False
# Cas limites
with pytest.raises(TypeError):
validate_age("18")
with pytest.raises(TypeError):
validate_age(None)
5. Gardez les tests indépendants
# Mauvais : Les tests dépendent de l'ordre
counter = 0
def test_increment():
global counter
counter += 1
assert counter == 1
def test_increment_again(): # Échoue si exécuté seul
global counter
counter += 1
assert counter == 2
# Bon : Les tests sont indépendants
def test_increment():
counter = Counter()
counter.increment()
assert counter.value == 1
def test_increment_multiple_times():
counter = Counter()
counter.increment()
counter.increment()
assert counter.value == 2
6. Ne pas tester les détails d’implémentation
# Mauvais : Tester l'implémentation
def test_sort_uses_quicksort():
sorter = Sorter()
assert sorter.algorithm == "quicksort"
# Bon : Tester le comportement
def test_sort_returns_sorted_list():
sorter = Sorter()
result = sorter.sort([3, 1, 2])
assert result == [1, 2, 3]
7. Tester les cas d’utilisation réels
Lorsque vous testez des bibliothèques qui traitent ou transforment des données, concentrez-vous sur des scénarios réels. Par exemple, si vous travaillez avec le web scraping ou la conversion de contenu, consultez notre guide sur la conversion HTML en Markdown avec Python, qui inclut des stratégies de test et des comparaisons de benchmarks pour différentes bibliothèques de conversion.
Intégration avec la CI
Exemple GitHub Actions
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9, 3.10, 3.11, 3.12]
steps:
- uses: actions/checkout@v3
- name: Configurer Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Installer les dépendances
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Exécuter les tests
run: |
pytest --cov=myproject --cov-report=xml
- name: Télécharger la couverture
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
Test des fonctions serverless
Lorsque vous testez des fonctions AWS Lambda ou des applications serverless, envisagez des stratégies de test d’intégration en plus des tests unitaires. Notre guide sur la création d’une AWS Lambda en mode dual avec Python et Terraform couvre les approches de test pour les applications Python serverless, y compris comment tester les gestionnaires Lambda, les consommateurs SQS et les intégrations API Gateway.
Checklist des bonnes pratiques de test
- Écrire des tests avant ou en parallèle du code (TDD)
- Gardez les tests rapides (< 1 seconde par test)
- Rendre les tests indépendants et isolés
- Utiliser des noms descriptifs pour les tests
- Suivre le modèle AAA (Arrange-Act-Assert)
- Tester les cas limites et les conditions d’erreur
- Mocker les dépendances externes
- Viser une couverture de code de 80 % +
- Exécuter les tests dans le pipeline CI/CD
- Revoir et refactoriser régulièrement les tests
- Documenter les scénarios de test complexes
- Utiliser des fixtures pour les configurations courantes
- Paramétrer les tests similaires
- Garder les tests simples et lisibles
Schémas de test courants
Tester les classes
class TestUser:
@pytest.fixture
def user(self):
return User("john_doe", "john@example.com")
def test_username(self, user):
assert user.username == "john_doe"
def test_email(self, user):
assert user.email == "john@example.com"
def test_full_name(self, user):
user.first_name = "John"
user.last_name = "Doe"
assert user.full_name() == "John Doe"
Tester avec des fichiers temporaires
import pytest
from pathlib import Path
@pytest.fixture
def temp_file(tmp_path):
"""Créer un fichier temporaire"""
file_path = tmp_path / "test_file.txt"
file_path.write_text("test content")
return file_path
def test_read_file(temp_file):
content = temp_file.read_text()
assert content == "test content"
Tester la génération de fichiers
Lorsque vous testez du code qui génère des fichiers (comme des PDF, des images ou des documents), utilisez des répertoires temporaires et vérifiez les propriétés des fichiers :
@pytest.fixture
def temp_output_dir(tmp_path):
"""Fournir un répertoire de sortie temporaire"""
output_dir = tmp_path / "output"
output_dir.mkdir()
return output_dir
def test_pdf_generation(temp_output_dir):
pdf_path = temp_output_dir / "output.pdf"
generate_pdf(pdf_path, content="Test")
assert pdf_path.exists()
assert pdf_path.stat().st_size > 0
Pour des exemples complets de test de génération de PDF, consultez notre guide sur la génération de PDF en Python, qui couvre les stratégies de test pour différentes bibliothèques de génération de PDF.
Tester avec Monkeypatch
def test_environment_variable(monkeypatch):
monkeypatch.setenv("API_KEY", "test_key_123")
assert os.getenv("API_KEY") == "test_key_123"
def test_module_attribute(monkeypatch):
monkeypatch.setattr("module.CONSTANT", 42)
assert module.CONSTANT == 42
Liens utiles et ressources
- Documentation pytest
- Documentation unittest
- Plugin pytest-cov
- Plugin pytest-mock
- Documentation unittest.mock
- Test Driven Development by Example - Kent Beck
- Python Testing with pytest - Brian Okken
Ressources liées
Fondamentaux Python et bonnes pratiques
- Feuille de rappel Python - Référence complète pour la syntaxe Python, les structures de données et les modèles courants
Tester des cas d’utilisation spécifiques en Python
- Intégrer Ollama avec Python - Stratégies de test pour les intégrations LLM et les appels asynchrones aux services d’IA
- Convertir HTML en Markdown avec Python - Approches de test pour le web scraping et les bibliothèques de conversion de contenu
- Générer des PDF en Python - Stratégies de test pour la génération de PDF et la sortie de fichiers
Test serverless et cloud
- Créer une AWS Lambda en mode dual avec Python et Terraform - Stratégies de test pour les fonctions Lambda, les consommateurs SQS et les intégrations API Gateway
Le test unitaire est une compétence essentielle pour les développeurs Python. Quel que soit votre choix entre unittest ou pytest, l’essentiel est d’écrire des tests de manière cohérente, de les maintenir et de les intégrer à votre flux de travail de développement. Commencez par des tests simples, adoptez progressivement des techniques avancées comme le mocking et les fixtures, et utilisez des outils de couverture pour identifier le code non testé.