Aller au contenu principal
TestsPythonpytestFormation

pytest : le framework de test Python ultime

30 min de lecture Tests & Qualité — Chapitre 2

Maîtrise pytest de A à Z : fixtures, mocking, parametrize, markers, coverage. Tout ce qu'il faut pour tester du Python comme un pro.

Pourquoi pytest (et pas unittest)

Python a unittest dans la stdlib. Alors pourquoi tout le monde utilise pytest ?

# unittest — verbeux, orienté classe
import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        self.calc = Calculator()
    
    def test_add(self):
        self.assertEqual(self.calc.add(2, 3), 5)
    
    def test_add_negative(self):
        self.assertEqual(self.calc.add(-1, 1), 0)

if __name__ == '__main__':
    unittest.main()
# pytest — concis, pythonique
def test_add():
    calc = Calculator()
    assert calc.add(2, 3) == 5

def test_add_negative():
    calc = Calculator()
    assert calc.add(-1, 1) == 0

Avantages de pytest :

  • assert simple — Pas de self.assertEqual, self.assertIn, self.assertRaises
  • Messages d’erreur détaillés — pytest décompose l’assertion pour montrer les valeurs
  • Fixtures — Système de dépendance injection puissant
  • Plugins — Un écosystème riche (coverage, mock, asyncio, django, etc.)
  • Parametrize — Tester plusieurs cas en une ligne
  • Compatible unittest — Tu peux migrer progressivement

Installation et configuration

pip install pytest pytest-cov pytest-mock pytest-xdist

Configuration dans pyproject.toml

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
    "-v",                    # Verbose
    "--tb=short",            # Traceback court
    "--strict-markers",      # Erreur si marker non déclaré
    "-ra",                   # Résumé des tests non-passed
]
markers = [
    "slow: Tests lents (déactivés par défaut)",
    "integration: Tests d'intégration",
    "e2e: Tests end-to-end",
]
filterwarnings = [
    "error",                 # Les warnings deviennent des erreurs
    "ignore::DeprecationWarning",
]

Structure de projet

mon-projet/
├── src/
│   └── myapp/
│       ├── __init__.py
│       ├── models.py
│       ├── services.py
│       └── utils.py
├── tests/
│   ├── conftest.py          # Fixtures partagées
│   ├── unit/
│   │   ├── conftest.py      # Fixtures unit-specific
│   │   ├── test_models.py
│   │   ├── test_services.py
│   │   └── test_utils.py
│   ├── integration/
│   │   ├── conftest.py
│   │   └── test_database.py
│   └── e2e/
│       └── test_api.py
├── pyproject.toml
└── requirements.txt

Les bases

Écrire un test

# tests/unit/test_utils.py
from myapp.utils import slugify, truncate

def test_slugify_simple():
    assert slugify("Hello World") == "hello-world"

def test_slugify_special_chars():
    assert slugify("Café résumé") == "cafe-resume"

def test_slugify_multiple_spaces():
    assert slugify("hello   world") == "hello-world"

def test_truncate_short_string():
    assert truncate("hello", 10) == "hello"

def test_truncate_long_string():
    assert truncate("hello world", 5) == "hello..."

def test_truncate_exact_length():
    assert truncate("hello", 5) == "hello"

Lancer les tests

# Tous les tests
pytest

# Un fichier spécifique
pytest tests/unit/test_utils.py

# Un test spécifique
pytest tests/unit/test_utils.py::test_slugify_simple

# Tests matchant un pattern
pytest -k "slugify"

# Tests avec un marker
pytest -m "not slow"

# Avec verbose
pytest -v

# Stopper au premier échec
pytest -x

# Relancer seulement les tests échoués
pytest --lf

# Montrer les prints
pytest -s

Messages d’erreur intelligents

def test_list_comparison():
    expected = [1, 2, 3, 4, 5]
    actual = [1, 2, 4, 4, 5]
    assert actual == expected

pytest affiche :

E       AssertionError: assert [1, 2, 4, 4, 5] == [1, 2, 3, 4, 5]
E         At index 2 diff: 4 != 3
E         Full diff:
E         - [1, 2, 3, 4, 5]
E         + [1, 2, 4, 4, 5]
E         ?        ^

Pas besoin de assertEqual ou assertListEqual. Un simple assert suffit et pytest fait le travail d’introspection.


Fixtures : l’injection de dépendances

Les fixtures sont LE concept central de pytest. C’est un système d’injection de dépendances pour les tests.

Fixture basique

# tests/conftest.py
import pytest

@pytest.fixture
def calculator():
    """Fournit une instance de Calculator pour chaque test."""
    return Calculator()

@pytest.fixture
def sample_user():
    """Fournit un utilisateur de test."""
    return User(name="John", email="john@example.com", age=30)
# tests/unit/test_calculator.py
def test_add(calculator):  # ← pytest injecte automatiquement
    assert calculator.add(2, 3) == 5

def test_subtract(calculator):
    assert calculator.subtract(5, 3) == 2

pytest voit que test_add demande un paramètre calculator, cherche une fixture avec ce nom, et l’injecte. Pas de setUp, pas de classe, juste des fonctions.

Scopes de fixtures

@pytest.fixture(scope="function")  # Défaut — recréé pour chaque test
def fresh_db():
    db = create_database()
    yield db
    db.drop()

@pytest.fixture(scope="class")  # Partagé entre les tests d'une classe
def api_client():
    return APIClient()

@pytest.fixture(scope="module")  # Partagé dans tout le fichier
def config():
    return load_config("test")

@pytest.fixture(scope="session")  # Partagé pour toute la session pytest
def docker_services():
    compose = start_docker_compose()
    yield compose
    compose.stop()

Setup et teardown avec yield

@pytest.fixture
def db_session():
    """Crée une session DB, rollback après le test."""
    session = SessionLocal()
    session.begin_nested()  # Savepoint
    
    yield session  # ← Le test reçoit la session ici
    
    # Teardown — exécuté après le test
    session.rollback()
    session.close()

Tout ce qui est avant yield = setup. Tout ce qui est après = teardown. Le teardown s’exécute même si le test échoue.

Fixtures qui dépendent d’autres fixtures

@pytest.fixture
def db_engine():
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    yield engine
    engine.dispose()

@pytest.fixture
def db_session(db_engine):  # ← Dépend de db_engine
    Session = sessionmaker(bind=db_engine)
    session = Session()
    yield session
    session.close()

@pytest.fixture
def user_service(db_session):  # ← Dépend de db_session
    return UserService(db_session)

pytest résout l’arbre de dépendances automatiquement.

conftest.py : fixtures partagées

Les fichiers conftest.py sont automatiquement chargés par pytest. Les fixtures définies dedans sont disponibles pour tous les tests du même répertoire et ses sous-répertoires.

tests/
├── conftest.py           # Fixtures disponibles partout
├── unit/
│   ├── conftest.py       # Fixtures disponibles dans unit/ seulement
│   └── test_services.py
└── integration/
    ├── conftest.py       # Fixtures disponibles dans integration/ seulement
    └── test_database.py

autouse : fixtures automatiques

@pytest.fixture(autouse=True)
def reset_environment():
    """Reset les variables d'env avant chaque test."""
    original = os.environ.copy()
    yield
    os.environ.clear()
    os.environ.update(original)

autouse=True applique la fixture à tous les tests dans le scope, sans qu’ils la demandent explicitement. Utilise avec parcimonie.


Parametrize : tester plusieurs cas

Basique

@pytest.mark.parametrize("input,expected", [
    ("hello", "hello"),
    ("Hello World", "hello-world"),
    ("Café résumé", "cafe-resume"),
    ("  spaces  ", "spaces"),
    ("UPPERCASE", "uppercase"),
    ("", ""),
])
def test_slugify(input, expected):
    assert slugify(input) == expected

Un seul test, 6 cas. Chaque cas apparaît séparément dans les résultats :

test_utils.py::test_slugify[hello-hello] PASSED
test_utils.py::test_slugify[Hello World-hello-world] PASSED
test_utils.py::test_slugify[Café résumé-cafe-resume] PASSED
...

IDs personnalisés

@pytest.mark.parametrize("price,discount,expected", [
    pytest.param(100, 0, 100, id="no-discount"),
    pytest.param(100, 10, 90, id="10-percent"),
    pytest.param(100, 50, 50, id="half-price"),
    pytest.param(200, 100, 100, id="100-off-200"),
])
def test_apply_discount(price, discount, expected):
    assert apply_discount(price, discount) == expected

Combinaison de parametrize

@pytest.mark.parametrize("x", [1, 2, 3])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    assert multiply(x, y) == x * y
# Génère 6 tests : (1,10), (1,20), (2,10), (2,20), (3,10), (3,20)

Parametrize avec exceptions

@pytest.mark.parametrize("input,expected_error", [
    (-1, ValueError),
    ("abc", TypeError),
    (None, TypeError),
])
def test_square_root_errors(input, expected_error):
    with pytest.raises(expected_error):
        square_root(input)

Mocking avec pytest-mock

Le mocking remplace des dépendances par des faux objets pour isoler le code testé.

Le fixture mocker

# pip install pytest-mock

def test_send_email(mocker):
    # Mock le service d'email
    mock_smtp = mocker.patch("myapp.services.smtplib.SMTP")
    
    # Exécute le code
    send_welcome_email("user@example.com")
    
    # Vérifie que SMTP a été appelé
    mock_smtp.assert_called_once()
    mock_smtp.return_value.sendmail.assert_called_once()

Mocker une fonction

def test_get_weather(mocker):
    # Mock l'appel HTTP
    mock_get = mocker.patch("myapp.weather.requests.get")
    mock_get.return_value.json.return_value = {
        "temperature": 22,
        "condition": "sunny"
    }
    mock_get.return_value.status_code = 200
    
    result = get_weather("Paris")
    
    assert result["temperature"] == 22
    mock_get.assert_called_once_with(
        "https://api.weather.com/v1/current",
        params={"city": "Paris"}
    )

Mocker un attribut

def test_premium_user(mocker):
    user = User(name="John")
    mocker.patch.object(user, "is_premium", return_value=True)
    
    assert get_discount(user) == 20  # Les premium ont 20% de réduction

Side effects

def test_retry_on_failure(mocker):
    mock_api = mocker.patch("myapp.client.api_call")
    
    # Échoue 2 fois, réussit la 3ème
    mock_api.side_effect = [
        ConnectionError("timeout"),
        ConnectionError("timeout"),
        {"status": "ok"},
    ]
    
    result = resilient_api_call()
    
    assert result == {"status": "ok"}
    assert mock_api.call_count == 3

MagicMock en détail

def test_database_operations(mocker):
    mock_db = mocker.MagicMock()
    
    # Configure le mock
    mock_db.query.return_value.filter.return_value.first.return_value = User(
        id=1, name="John"
    )
    
    # Le code utilise le mock comme une vraie DB
    service = UserService(mock_db)
    user = service.get_by_id(1)
    
    assert user.name == "John"
    mock_db.query.assert_called_once_with(User)

Mocker datetime

Pattern classique pour tester du code qui dépend du temps :

from freezegun import freeze_time

@freeze_time("2026-03-21 14:00:00")
def test_greeting_afternoon():
    assert get_greeting() == "Bon après-midi"

@freeze_time("2026-03-21 08:00:00")
def test_greeting_morning():
    assert get_greeting() == "Bonjour"

Ou avec mocker :

def test_greeting_afternoon(mocker):
    mock_now = mocker.patch("myapp.utils.datetime")
    mock_now.now.return_value = datetime(2026, 3, 21, 14, 0, 0)
    
    assert get_greeting() == "Bon après-midi"

Markers : catégoriser les tests

Markers custom

# Déclaration dans pyproject.toml (voir plus haut)

# Utilisation
@pytest.mark.slow
def test_heavy_computation():
    result = compute_all_primes(1_000_000)
    assert len(result) > 0

@pytest.mark.integration
def test_database_connection(db_session):
    assert db_session.execute("SELECT 1").scalar() == 1
# Exclure les tests lents
pytest -m "not slow"

# Seulement les tests d'intégration
pytest -m "integration"

# Combinaisons
pytest -m "not slow and not e2e"

Markers built-in

# Skip — ignorer un test
@pytest.mark.skip(reason="Feature pas encore implémentée")
def test_future_feature():
    pass

# Skipif — ignorer conditionnellement
@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_specific():
    pass

# Xfail — test qui devrait échouer
@pytest.mark.xfail(reason="Bug #1234 pas encore fixé")
def test_known_bug():
    assert broken_function() == "expected"  # Échouera mais ne cassera pas le CI

Tester les exceptions

# Vérifier qu'une exception est levée
def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(1, 0)

# Vérifier le message
def test_invalid_email():
    with pytest.raises(ValueError, match="Email invalide"):
        validate_email("not-an-email")

# Vérifier les attributs de l'exception
def test_http_error():
    with pytest.raises(HTTPError) as exc_info:
        api_call("/not-found")
    
    assert exc_info.value.status_code == 404
    assert "Not Found" in str(exc_info.value)

Tester du code async

# pip install pytest-asyncio

import pytest

@pytest.mark.asyncio
async def test_async_fetch():
    result = await fetch_data("https://api.example.com/data")
    assert result["status"] == "ok"

@pytest.mark.asyncio
async def test_async_with_mock(mocker):
    mock_fetch = mocker.patch("myapp.client.aiohttp.ClientSession.get")
    mock_response = mocker.AsyncMock()
    mock_response.json.return_value = {"data": "test"}
    mock_response.status = 200
    mock_fetch.return_value.__aenter__.return_value = mock_response
    
    result = await fetch_data("https://api.example.com")
    assert result == {"data": "test"}

Coverage : mesurer la couverture

Configuration

# pyproject.toml
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/migrations/*"]
branch = true  # Couverture des branches (if/else)

[tool.coverage.report]
show_missing = true
skip_empty = true
fail_under = 80  # Échoue si < 80%
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]

Lancer avec coverage

# Terminal
pytest --cov=src --cov-report=term-missing

# HTML (pour visualiser dans le navigateur)
pytest --cov=src --cov-report=html
open htmlcov/index.html

# XML (pour le CI)
pytest --cov=src --cov-report=xml

# Combiné
pytest --cov=src --cov-report=term-missing --cov-report=html --cov-report=xml

Rapport terminal

---------- coverage: platform linux, python 3.12.0 ----------
Name                         Stmts   Miss Branch BrPart  Cover   Missing
------------------------------------------------------------------------
src/myapp/__init__.py            0      0      0      0   100%
src/myapp/models.py             45      3      8      2    91%   34, 67-68
src/myapp/services.py           82     12     20      5    83%   45-56, 89
src/myapp/utils.py              30      0     12      0   100%
------------------------------------------------------------------------
TOTAL                          157     15     40      7    89%

Ignorer du code

def debug_info():  # pragma: no cover
    """Fonction de debug, pas besoin de tester."""
    return {
        "version": __version__,
        "python": sys.version,
    }

if TYPE_CHECKING:  # Automatiquement exclu
    from myapp.types import Config

Plugins utiles

pytest-xdist : paralléliser les tests

pip install pytest-xdist

# Lancer sur 4 workers
pytest -n 4

# Auto-détecter le nombre de CPU
pytest -n auto

⚠️ Les tests doivent être indépendants pour la parallélisation.

pytest-randomly : ordre aléatoire

pip install pytest-randomly

# Les tests s'exécutent dans un ordre aléatoire
pytest  # L'ordre change à chaque run

# Reproduire un ordre spécifique
pytest -p randomly --randomly-seed=12345

Détecte les tests qui dépendent de l’ordre d’exécution.

pytest-timeout : limiter le temps

pip install pytest-timeout
@pytest.mark.timeout(5)  # Max 5 secondes
def test_api_call():
    result = slow_api_call()
    assert result is not None

pytest-sugar : sortie plus jolie

pip install pytest-sugar
# Automatiquement activé — barre de progression, couleurs

Patterns avancés

Factory fixtures

@pytest.fixture
def make_user():
    """Factory pour créer des users avec des valeurs par défaut."""
    created_users = []
    
    def _make_user(name="John", email=None, age=30, role="member"):
        if email is None:
            email = f"{name.lower()}@example.com"
        user = User(name=name, email=email, age=age, role=role)
        created_users.append(user)
        return user
    
    yield _make_user
    
    # Cleanup
    for user in created_users:
        user.delete()

def test_admin_permissions(make_user):
    admin = make_user(name="Admin", role="admin")
    regular = make_user(name="Regular")
    
    assert admin.can_delete_users() == True
    assert regular.can_delete_users() == False

Fixtures request-aware

@pytest.fixture
def db_session(request):
    """Fixture qui adapte son comportement selon le test."""
    session = SessionLocal()
    
    # Si le test a le marker 'clean_db', vide la DB avant
    if request.node.get_closest_marker("clean_db"):
        session.execute("DELETE FROM users")
        session.commit()
    
    yield session
    session.rollback()
    session.close()

@pytest.mark.clean_db
def test_count_users(db_session):
    assert db_session.query(User).count() == 0

Monkeypatch : modifier l’environnement

def test_with_env_variable(monkeypatch):
    monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
    monkeypatch.setenv("DEBUG", "true")
    
    config = load_config()
    
    assert config.database_url == "sqlite:///:memory:"
    assert config.debug == True

def test_without_home_dir(monkeypatch, tmp_path):
    monkeypatch.setenv("HOME", str(tmp_path))
    
    result = get_config_path()
    assert str(tmp_path) in result

Récapitulatif des commandes essentielles

# Base
pytest                          # Tous les tests
pytest -v                       # Verbose
pytest -x                       # Stop au premier échec
pytest -s                       # Affiche les prints
pytest -k "pattern"             # Filtre par nom
pytest -m "marker"              # Filtre par marker

# Debug
pytest --lf                     # Seulement les derniers échoués
pytest --ff                     # Derniers échoués en premier
pytest --pdb                    # Debugger sur échec
pytest --tb=long                # Traceback détaillé

# Performance
pytest -n auto                  # Parallèle (xdist)
pytest --durations=10           # Top 10 tests les plus lents

# Coverage
pytest --cov=src                # Avec coverage
pytest --cov=src --cov-fail-under=80  # Échec si < 80%

Le prochain cours couvre les tests d’intégration : testcontainers, API testing, et comment tester avec de vraies bases de données. 🔬

À toi de jouer

Trois exercices pour ancrer ce que tu viens de lire.

Exercice 1 — Tests d’un module utilitaire Crée un module utils.py avec 3 fonctions : parse_config(filepath) qui lit un YAML, validate_email(email) qui vérifie le format, et retry(func, max_attempts) qui réessaie une fonction. Écris les tests pytest avec : au moins un parametrize pour les cas valides/invalides, une fixture pour les fichiers temporaires, et un mock pour simuler des échecs.

Exercice 2 — Coverage à 90% Prends un petit projet existant (ou crée un mini-CLI avec click). Lance pytest --cov et identifie les lignes non couvertes. Écris les tests manquants pour atteindre 90% de coverage. Configure pyproject.toml pour que le CI échoue sous 85%.

Exercice 3 — Markers et organisation Organise une suite de tests avec des markers : @pytest.mark.unit, @pytest.mark.integration, @pytest.mark.slow. Configure pytest.ini pour enregistrer les markers. Écris un Makefile avec des targets test-unit, test-integration, test-all. Vérifie que pytest -m unit ne lance que les tests unitaires.

Articles liés