Modèles de tests Python
Stratégies complètes de test pour les applications Python avec pytest, la méthodologie TDD et les bonnes pratiques.
Quand activer
- Écrire du nouveau code Python (suivez TDD : rouge, vert, refactor)
- Concevoir des suites de tests pour des projets Python
- Réviser la couverture de tests Python
- Mettre en place l'infrastructure de test
Philosophie de base des tests
Développement piloté par les tests (TDD)
Suivez toujours le cycle TDD :
- ROUGE : Écrivez un test qui échoue pour le comportement souhaité
- VERT : Écrivez le code minimal pour faire passer le test
- REFACTOR : Améliorez le code tout en gardant les tests verts
# Étape 1 : Écrire un test qui échoue (ROUGE)
def test_add_numbers():
result = add(2, 3)
assert result == 5
# Étape 2 : Implémentation minimale (VERT)
def add(a, b):
return a + b
# Étape 3 : Refactoriser si nécessaire (REFACTOR)
Exigences de couverture
- Objectif : 80 %+ de couverture de code
- Chemins critiques : 100 % de couverture requis
- Utilisez
pytest --covpour mesurer la couverture
pytest --cov=mypackage --cov-report=term-missing --cov-report=html
Fondamentaux de pytest
Structure de test de base
import pytest
def test_addition():
"""Test d'addition de base."""
assert 2 + 2 == 4
def test_string_uppercase():
"""Test de mise en majuscule d'une chaîne."""
text = "hello"
assert text.upper() == "HELLO"
def test_list_append():
"""Test d'ajout dans une liste."""
items = [1, 2, 3]
items.append(4)
assert 4 in items
assert len(items) == 4
Assertions
# Égalité
assert result == expected
# Inégalité
assert result != unexpected
# Valeurs booléennes
assert result # Vrai
assert not result # Faux
assert result is True # Exactement True
assert result is False # Exactement False
assert result is None # Exactement None
# Appartenance
assert item in collection
assert item not in collection
# Comparaisons
assert result > 0
assert 0 <= result <= 100
# Vérification de type
assert isinstance(result, str)
# Test d'exception (approche préférée)
with pytest.raises(ValueError):
raise ValueError("message d'erreur")
# Vérifier le message d'exception
with pytest.raises(ValueError, match="entrée invalide"):
raise ValueError("entrée invalide fournie")
# Vérifier les attributs d'exception
with pytest.raises(ValueError) as exc_info:
raise ValueError("message d'erreur")
assert str(exc_info.value) == "message d'erreur"
Fixtures
Utilisation de base des fixtures
import pytest
@pytest.fixture
def sample_data():
"""Fixture fournissant des données d'exemple."""
return {"name": "Alice", "age": 30}
def test_sample_data(sample_data):
"""Test utilisant la fixture."""
assert sample_data["name"] == "Alice"
assert sample_data["age"] == 30
Fixture avec configuration/nettoyage
@pytest.fixture
def database():
"""Fixture avec configuration et nettoyage."""
# Configuration
db = Database(":memory:")
db.create_tables()
db.insert_test_data()
yield db # Fournir au test
# Nettoyage
db.close()
def test_database_query(database):
"""Test des opérations de base de données."""
result = database.query("SELECT * FROM users")
assert len(result) > 0
Portées des fixtures
# Portée de fonction (par défaut) - s'exécute pour chaque test
@pytest.fixture
def temp_file():
with open("temp.txt", "w") as f:
yield f
os.remove("temp.txt")
# Portée de module - s'exécute une fois par module
@pytest.fixture(scope="module")
def module_db():
db = Database(":memory:")
db.create_tables()
yield db
db.close()
# Portée de session - s'exécute une fois par session de test
@pytest.fixture(scope="session")
def shared_resource():
resource = ExpensiveResource()
yield resource
resource.cleanup()
Fixture avec paramètres
@pytest.fixture(params=[1, 2, 3])
def number(request):
"""Fixture paramétrée."""
return request.param
def test_numbers(number):
"""Le test s'exécute 3 fois, une fois pour chaque paramètre."""
assert number > 0
Utilisation de plusieurs fixtures
@pytest.fixture
def user():
return User(id=1, name="Alice")
@pytest.fixture
def admin():
return User(id=2, name="Admin", role="admin")
def test_user_admin_interaction(user, admin):
"""Test utilisant plusieurs fixtures."""
assert admin.can_manage(user)
Fixtures autouse
@pytest.fixture(autouse=True)
def reset_config():
"""S'exécute automatiquement avant chaque test."""
Config.reset()
yield
Config.cleanup()
def test_without_fixture_call():
# reset_config s'exécute automatiquement
assert Config.get_setting("debug") is False
Conftest.py pour les fixtures partagées
# tests/conftest.py
import pytest
@pytest.fixture
def client():
"""Fixture partagée pour tous les tests."""
app = create_app(testing=True)
with app.test_client() as client:
yield client
@pytest.fixture
def auth_headers(client):
"""Générer des en-têtes d'authentification pour les tests API."""
response = client.post("/api/login", json={
"username": "test",
"password": "test"
})
token = response.json["token"]
return {"Authorization": f"Bearer {token}"}
Paramétrisation
Paramétrisation de base
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("world", "WORLD"),
("PyThOn", "PYTHON"),
])
def test_uppercase(input, expected):
"""Le test s'exécute 3 fois avec différentes entrées."""
assert input.upper() == expected
Paramètres multiples
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, 200, 300),
])
def test_add(a, b, expected):
"""Test d'addition avec plusieurs entrées."""
assert add(a, b) == expected
Paramétriser avec des IDs
@pytest.mark.parametrize("input,expected", [
("[email protected]", True),
("invalid", False),
("@no-domain.com", False),
], ids=["email-valide", "sans-arobase", "sans-domaine"])
def test_email_validation(input, expected):
"""Test de validation d'email avec des IDs de test lisibles."""
assert is_valid_email(input) is expected
Fixtures paramétrées
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
def db(request):
"""Test avec plusieurs backends de base de données."""
if request.param == "sqlite":
return Database(":memory:")
elif request.param == "postgresql":
return Database("postgresql://localhost/test")
elif request.param == "mysql":
return Database("mysql://localhost/test")
def test_database_operations(db):
"""Le test s'exécute 3 fois, une fois pour chaque base de données."""
result = db.query("SELECT 1")
assert result is not None
Marqueurs et sélection de tests
Marqueurs personnalisés
# Marquer les tests lents
@pytest.mark.slow
def test_slow_operation():
time.sleep(5)
# Marquer les tests d'intégration
@pytest.mark.integration
def test_api_integration():
response = requests.get("https://api.example.com")
assert response.status_code == 200
# Marquer les tests unitaires
@pytest.mark.unit
def test_unit_logic():
assert calculate(2, 3) == 5
Exécuter des tests spécifiques
# Exécuter uniquement les tests rapides
pytest -m "not slow"
# Exécuter uniquement les tests d'intégration
pytest -m integration
# Exécuter les tests d'intégration ou lents
pytest -m "integration or slow"
# Exécuter les tests marqués comme unitaires mais pas lents
pytest -m "unit and not slow"
Configurer les marqueurs dans pytest.ini
[pytest]
markers =
slow: marque les tests comme lents
integration: marque les tests comme tests d'intégration
unit: marque les tests comme tests unitaires
django: marque les tests comme nécessitant Django
Moquerie (Mocking) et correction (Patching)
Moquerie de fonctions
from unittest.mock import patch, Mock
@patch("mypackage.external_api_call")
def test_with_mock(api_call_mock):
"""Test avec une API externe simulée."""
api_call_mock.return_value = {"status": "success"}
result = my_function()
api_call_mock.assert_called_once()
assert result["status"] == "success"
Moquerie de valeurs de retour
@patch("mypackage.Database.connect")
def test_database_connection(connect_mock):
"""Test avec une connexion de base de données simulée."""
connect_mock.return_value = MockConnection()
db = Database()
db.connect()
connect_mock.assert_called_once_with("localhost")
Moquerie d'exceptions
@patch("mypackage.api_call")
def test_api_error_handling(api_call_mock):
"""Test de gestion d'erreur avec une exception simulée."""
api_call_mock.side_effect = ConnectionError("Erreur réseau")
with pytest.raises(ConnectionError):
api_call()
api_call_mock.assert_called_once()
Moquerie de gestionnaires de contexte
@patch("builtins.open", new_callable=mock_open)
def test_file_reading(mock_file):
"""Test de lecture de fichier avec un open simulé."""
mock_file.return_value.read.return_value = "contenu du fichier"
result = read_file("test.txt")
mock_file.assert_called_once_with("test.txt", "r")
assert result == "contenu du fichier"
Utilisation d'autospec
@patch("mypackage.DBConnection", autospec=True)
def test_autospec(db_mock):
"""Test avec autospec pour détecter les mauvaises utilisations de l'API."""
db = db_mock.return_value
db.query("SELECT * FROM users")
# Ceci échouerait si DBConnection n'a pas de méthode query
db_mock.assert_called_once()
Simuler des instances de classe
class TestUserService:
@patch("mypackage.UserRepository")
def test_create_user(self, repo_mock):
"""Test de création d'utilisateur avec un dépôt simulé."""
repo_mock.return_value.save.return_value = User(id=1, name="Alice")
service = UserService(repo_mock.return_value)
user = service.create_user(name="Alice")
assert user.name == "Alice"
repo_mock.return_value.save.assert_called_once()
Simuler une propriété
@pytest.fixture
def mock_config():
"""Créer une simulation avec une propriété."""
config = Mock()
type(config).debug = PropertyMock(return_value=True)
type(config).api_key = PropertyMock(return_value="test-key")
return config
def test_with_mock_config(mock_config):
"""Test avec des propriétés de configuration simulées."""
assert mock_config.debug is True
assert mock_config.api_key == "test-key"
Test de code asynchrone
Tests asynchrones avec pytest-asyncio
import pytest
@pytest.mark.asyncio
async def test_async_function():
"""Test de fonction asynchrone."""
result = await async_add(2, 3)
assert result == 5
@pytest.mark.asyncio
async def test_async_with_fixture(async_client):
"""Test asynchrone avec fixture asynchrone."""
response = await async_client.get("/api/users")
assert response.status_code == 200
Fixture asynchrone
@pytest.fixture
async def async_client():
"""Fixture asynchrone fournissant un client de test asynchrone."""
app = create_app()
async with app.test_client() as client:
yield client
@pytest.mark.asyncio
async def test_api_endpoint(async_client):
"""Test utilisant une fixture asynchrone."""
response = await async_client.get("/api/data")
assert response.status_code == 200
Simuler des fonctions asynchrones
@pytest.mark.asyncio
@patch("mypackage.async_api_call")
async def test_async_mock(api_call_mock):
"""Test de fonction asynchrone avec simulacre."""
api_call_mock.return_value = {"status": "ok"}
result = await my_async_function()
api_call_mock.assert_awaited_once()
assert result["status"] == "ok"
Test des exceptions
Tester les exceptions attendues
def test_divide_by_zero():
"""Test que la division par zéro lève ZeroDivisionError."""
with pytest.raises(ZeroDivisionError):
divide(10, 0)
def test_custom_exception():
"""Test d'exception personnalisée avec message."""
with pytest.raises(ValueError, match="entrée invalide"):
validate_input("invalid")
Tester les attributs d'exception
def test_exception_with_details():
"""Test d'exception avec attributs personnalisés."""
with pytest.raises(CustomError) as exc_info:
raise CustomError("erreur", code=400)
assert exc_info.value.code == 400
assert "erreur" in str(exc_info.value)
Test des effets de bord
Test des opérations sur les fichiers
import tempfile
import os
def test_file_processing():
"""Test de traitement de fichier avec fichier temporaire."""
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
f.write("contenu de test")
temp_path = f.name
try:
result = process_file(temp_path)
assert result == "traité : contenu de test"
finally:
os.unlink(temp_path)
Test avec la fixture tmp_path de pytest
def test_with_tmp_path(tmp_path):
"""Test utilisant la fixture de chemin temporaire intégrée de pytest."""
test_file = tmp_path / "test.txt"
test_file.write_text("bonjour le monde")
result = process_file(str(test_file))
assert result == "bonjour le monde"
# tmp_path est automatiquement nettoyé
Test avec la fixture tmpdir
def test_with_tmpdir(tmpdir):
"""Test utilisant la fixture tmpdir de pytest."""
test_file = tmpdir.join("test.txt")
test_file.write("données")
result = process_file(str(test_file))
assert result == "données"
Organisation des tests
Structure de répertoire
tests/
├── conftest.py # Fixtures partagées
├── __init__.py
├── unit/ # Tests unitaires
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_utils.py
│ └── test_services.py
├── integration/ # Tests d'intégration
│ ├── __init__.py
│ ├── test_api.py
│ └── test_database.py
└── e2e/ # Tests de bout en bout
├── __init__.py
└── test_user_flow.py
Classes de test
class TestUserService:
"""Regrouper les tests connexes dans une classe."""
@pytest.fixture(autouse=True)
def setup(self):
"""Configuration exécutée avant chaque test de cette classe."""
self.service = UserService()
def test_create_user(self):
"""Test de création d'utilisateur."""
user = self.service.create_user("Alice")
assert user.name == "Alice"
def test_delete_user(self):
"""Test de suppression d'utilisateur."""
user = self.service.create_user("Bob")
self.service.delete_user(user.id)
assert self.service.get_user(user.id) is None
Bonnes pratiques
Rédaction de tests efficaces
- Un test = une responsabilité : ne testez qu'un seul comportement par test
- Les tests doivent être rapides : utilisez la moquerie pour les opérations lentes
- Les tests doivent être déterministes : évitez les valeurs aléatoires ou les dates
- Les tests doivent être isolés : ne partagez pas d'état entre les tests
- Nommage clair : les noms des tests doivent décrire ce qu'ils testent
- Structure Arrange-Act-Assert : organisez vos tests clairement
Exemple d'utilisation de Arrange-Act-Assert
def test_user_discount():
"""Test du calcul de remise utilisateur."""
# Arrange
user = User(name="Alice", total_purchases=1000)
discount_calculator = DiscountCalculator()
# Act
discount = discount_calculator.calculate(user)
# Assert
assert discount == 15.0 # 15 % de remise
Débogage et exécution
Exécution et débogage des tests
# Exécuter tous les tests
pytest
# Exécuter des tests dans un fichier spécifique
pytest tests/test_users.py
# Exécuter un test spécifique
pytest tests/test_users.py::test_create_user
# Exécuter des tests correspondant à un motif
pytest -k "user and not slow"
# Afficher les sorties des tests (même pour les tests réussis)
pytest -v -s
# Arrêter après le premier échec
pytest -x
# Afficher les 5 échecs les plus lents
pytest --durations=5
Configuration des tests (pytest.ini)
[pytest]
minversion = 7.0
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
markers =
slow: marque les tests comme lents
integration: marque les tests comme tests d'intégration
unit: marque les tests comme tests unitaires
asyncio_mode = auto
Configuration de test avec pyproject.toml
[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
markers = [
"slow: marque les tests comme lents",
"integration: marque les tests comme tests d'intégration",
"unit: marque les tests comme tests unitaires",
]
asyncio_mode = "auto"
Tests avancés
Tests basés sur le temps
from unittest.mock import patch
from datetime import datetime
@patch("mypackage.datetime")
def test_timeout_expiry(mock_datetime):
"""Test d'expiration de délai avec le temps simulé."""
mock_datetime.now.return_value = datetime(2024, 6, 10, 12, 0, 0)
token = create_token(expires_in=3600)
assert not token.is_expired()
# Avancer le temps
mock_datetime.now.return_value = datetime(2024, 6, 10, 13, 0, 0)
assert token.is_expired()
Test des flux d'événements
def test_countdown():
"""Test du flux de compte à rebours."""
results = []
for i in countdown(3):
results.append(i)
assert results == [3, 2, 1]
Fixtures imbriquées
@pytest.fixture
def base_data():
return {"name": "Alice"}
@pytest.fixture
def extended_data(base_data):
"""Fixture étendant une autre fixture."""
base_data["age"] = 30
return base_data
def test_extended_data(extended_data):
"""Test utilisant des fixtures imbriquées."""
assert extended_data["name"] == "Alice"
assert extended_data["age"] == 30