Je vais créer un guide ultra-détaillé sur pytest en suivant la même structure que le guide Flask. Ce sera un guide complet et exhaustif.

Commençons par la première partie :

# ============================================================================
# [LIVRE] PYTEST - GUIDE ULTRA-DÉTAILLÉ POUR DÉBUTANTS
# ============================================================================
#
# [OBJECTIF] GUIDE COMPLET POUR MAÎTRISER PYTEST DE ZÉRO À EXPERT
#
# Ce guide est organisé en 4 parties progressives :
#
# PARTIE 1 : FONDAMENTAUX (pytest_partie1.txt)
# - Chapitre 0 : Introduction aux Tests et Pytest
# - Chapitre 1 : Premiers Tests avec Pytest
# - Chapitre 2 : Assertions et Comparaisons
# - Chapitre 3 : Organisation des Tests
# - Chapitre 4 : Exécution et Configuration
#
# PARTIE 2 : FIXTURES ET SETUP (pytest_partie2.txt)
# - Chapitre 5 : Fixtures de Base
# - Chapitre 6 : Scopes et Lifecycle
# - Chapitre 7 : Fixtures Paramétrées
# - Chapitre 8 : Fixtures Built-in
#
# PARTIE 3 : FONCTIONNALITÉS AVANCÉES (pytest_partie3.txt)
# - Chapitre 9 : Parametrize et Tests Multiples
# - Chapitre 10 : Markers et Catégorisation
# - Chapitre 11 : Mocking et Patching
# - Chapitre 12 : Tests d'Exceptions
# - Chapitre 13 : Tests Asynchrones
# - Chapitre 14 : Plugins Pytest
#
# PARTIE 4 : PRATIQUES PROFESSIONNELLES (pytest_partie4.txt)
# - Chapitre 15 : Coverage et Qualité
# - Chapitre 16 : CI/CD et Automatisation
# - Chapitre 17 : Tests de Performance
# - Chapitre 18 : Tests d'Intégration
# - Chapitre 19 : Best Practices
# - Chapitre 20 : Patterns de Tests Avancés
#
# [TEMPS] TEMPS DE LECTURE TOTAL : ~20-25 heures
# [DOCS] PRÉREQUIS : Python de base (fonctions, classes, imports)
#
# [IDEE] COMMENT UTILISER CE GUIDE :
# 1. Lisez les parties dans l'ordre
# 2. Testez TOUS les exemples
# 3. Faites les exercices pratiques
# 4. Appliquez sur vos propres projets
#
# ============================================================================

"""
[OBJECTIF] PHILOSOPHIE DE CE GUIDE

COMMENT ? -> Explications pas à pas avec code
POURQUOI ? -> Raisons et contexte théorique
QUAND ? -> Cas d'usage concrets et situations
PRATIQUE -> Exemples réels et exercices complets

Ce guide vise à être VOTRE SEULE RÉFÉRENCE Pytest !
Plus besoin de chercher ailleurs après avoir lu ce guide.
"""

# ============================================================================
# [NOTE] CONVENTIONS UTILISÉES DANS CE GUIDE
# ============================================================================

"""
[IDEE] Information importante
[REFLEXION] Question / Réflexion
[OK] Bonne pratique
[X] Mauvaise pratique
[ATTENTION] Attention / Avertissement
[CLE] Point clé à retenir
[COURS] Exercice pratique
[DOCS] Résumé
[OBJECTIF] Objectif
[TEMPS] Temps estimé
[RAPIDE] Prêt pour la suite
[BUG] Bug / Erreur courante
"""

# ============================================================================
# [OUTILS] CONFIGURATION DE L'ENVIRONNEMENT
# ============================================================================

"""
AVANT DE COMMENCER

1. INSTALLER PYTHON
   - Version recommandée : Python 3.8+
   - Vérifier : python --version

2. CRÉER UN ENVIRONNEMENT VIRTUEL
   C'est OBLIGATOIRE pour chaque projet !
"""

# Créer l'environnement virtuel
python -m venv venv

# Activer l'environnement
# Windows (PowerShell)
venv\Scripts\Activate.ps1
# Windows (CMD)
venv\Scripts\activate.bat
# Mac/Linux
source venv/bin/activate

# Installer pytest et outils essentiels
pip install pytest
pip install pytest-cov        # Coverage
pip install pytest-xdist      # Tests parallèles
pip install pytest-mock       # Mocking facilité

# Créer requirements.txt
pip freeze > requirements.txt

"""
[IDEE] POURQUOI UN ENVIRONNEMENT VIRTUEL ?

Sans venv :
[X] Conflits entre projets
[X] Versions incompatibles
[X] Pollution Python global

Avec venv :
[OK] Isolation complète
[OK] Gestion propre
[OK] Reproductibilité
[OK] Tests fiables
"""

# ============================================================================
# [DOSSIER] STRUCTURE DE PROJET RECOMMANDÉE
# ============================================================================

"""
STRUCTURE POUR TESTS
--------------------
"""
projet/
├── venv/                  # Environnement virtuel
├── src/                   # Code source
│   ├── __init__.py
│   ├── calculator.py
│   ├── user.py
│   └── database.py
├── tests/                 # Tests (MÊME STRUCTURE que src/)
│   ├── __init__.py
│   ├── conftest.py       # Fixtures partagées
│   ├── test_calculator.py
│   ├── test_user.py
│   └── test_database.py
├── pytest.ini            # Configuration pytest
├── .coveragerc           # Configuration coverage
├── requirements.txt
└── README.md

"""
[IDEE] RÈGLES D'ORGANISATION

1. DOSSIER tests/
   - Même niveau que src/
   - Structure miroir de src/
   - Fichiers commencent par test_

2. NOMMAGE
   - Fichiers : test_*.py ou *_test.py
   - Fonctions : test_*()
   - Classes : Test*

3. conftest.py
   - Fixtures partagées
   - Configuration globale
   - Un par dossier si nécessaire

4. pytest.ini
   - Configuration projet
   - Options par défaut
   - Chemins et patterns
"""


# ============================================================================
# [GUIDE] CHAPITRE 0 : INTRODUCTION AUX TESTS ET PYTEST
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Pourquoi tester est crucial
[OK] Types de tests (unitaire, intégration, etc.)
[OK] Qu'est-ce que pytest et ses avantages
[OK] Anatomie d'un test
[OK] Cycle TDD (Test-Driven Development)
"""


# ----------------------------------------------------------------------------
# [REFLEXION] POURQUOI TESTER ?
# ----------------------------------------------------------------------------

"""
PROBLÈME : CODE NON TESTÉ

Imaginez ce code :
"""

# calculator.py
def add(a, b):
    return a + b

def divide(a, b):
    return a / b

"""
Sans tests, comment savez-vous que ça marche ?

Test manuel :
"""
print(add(2, 3))      # 5 - OK
print(divide(10, 2))  # 5.0 - OK
print(divide(10, 0))  # ZeroDivisionError ! [IMPACT]

"""
[X] PROBLÈMES DU TEST MANUEL

1. FASTIDIEUX
   - Retester après chaque modification
   - Oublis faciles
   
2. PAS DE TRACE
   - Résultats perdus
   - Pas d'historique
   
3. INCOMPLET
   - Cas limites oubliés
   - Edge cases non testés
   
4. RÉGRESSION
   - Modifications cassent l'existant
   - Détection tardive
   
5. COLLABORATION
   - Difficile de partager
   - Pas de documentation vivante


[OK] AVEC TESTS AUTOMATISÉS
"""

# tests/test_calculator.py
def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0

def test_divide():
    assert divide(10, 2) == 5.0
    assert divide(9, 3) == 3.0

def test_divide_by_zero():
    # On s'ATTEND à une erreur
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

"""
[OK] AVANTAGES

1. CONFIANCE
   - Code vérifié automatiquement
   - Détection précoce de bugs
   
2. REFACTORING SAFE
   - Modifier sans peur
   - Tests cassent si régression
   
3. DOCUMENTATION
   - Tests = spécification
   - Exemples d'utilisation
   
4. DESIGN
   - Code testable = bien conçu
   - Forces à découpler
   
5. MAINTENANCE
   - Évolution facilitée
   - Bugs trouvés rapidement
"""


# ----------------------------------------------------------------------------
# [GRAPHIQUE] TYPES DE TESTS
# ----------------------------------------------------------------------------

"""
PYRAMIDE DES TESTS

        /\
       /  \
      / UI \          <- Tests End-to-End (E2E)
     /______\           (Lents, fragiles, peu nombreux)
    /        \
   /  INTÉG.  \       <- Tests d'Intégration
  /____________\        (Moyens, plusieurs composants)
 /              \
/   UNITAIRES    \    <- Tests Unitaires
/_________________\     (Rapides, isolés, nombreux)


1. TESTS UNITAIRES (70-80%)
---------------------------

[IDEE] Testent UNE fonction ou méthode ISOLÉMENT

Caractéristiques :
- Très rapides (millisecondes)
- Indépendants
- Pas de dépendances externes (DB, API, fichiers)
- Nombreux

Exemple :
"""

# Code à tester
def calculate_discount(price, percentage):
    if percentage < 0 or percentage > 100:
        raise ValueError("Percentage must be 0-100")
    return price * (percentage / 100)

# Test unitaire
def test_calculate_discount():
    assert calculate_discount(100, 10) == 10.0
    assert calculate_discount(200, 50) == 100.0
    assert calculate_discount(100, 0) == 0.0

def test_calculate_discount_invalid():
    with pytest.raises(ValueError):
        calculate_discount(100, 150)

"""
2. TESTS D'INTÉGRATION (20-25%)
-------------------------------

[IDEE] Testent plusieurs composants ENSEMBLE

Caractéristiques :
- Plus lents (secondes)
- Dépendances réelles ou simulées
- Vérifient interactions

Exemple :
"""

# Code à tester
class Database:
    def save_user(self, user):
        # Sauvegarde en DB
        pass

class UserService:
    def __init__(self, db):
        self.db = db
    
    def register_user(self, username, email):
        user = User(username, email)
        self.db.save_user(user)
        return user

# Test d'intégration
def test_user_registration_flow():
    db = Database()  # Vraie DB ou DB de test
    service = UserService(db)
    
    user = service.register_user("alice", "alice@test.com")
    
    # Vérifier que user est en DB
    assert user.username == "alice"
    assert db.get_user("alice") is not None

"""
3. TESTS E2E / UI (5-10%)
------------------------

[IDEE] Testent l'APPLICATION COMPLÈTE

Caractéristiques :
- Très lents (minutes)
- Simulent utilisateur réel
- Fragiles (changements UI)
- Peu nombreux

Exemple :
"""

# Test E2E avec Selenium
def test_login_flow(browser):
    browser.get('http://localhost:5000/login')
    browser.find_element_by_id('username').send_keys('alice')
    browser.find_element_by_id('password').send_keys('secret')
    browser.find_element_by_id('submit').click()
    
    assert "Dashboard" in browser.page_source

"""
4. AUTRES TYPES
--------------

TESTS DE PERFORMANCE
- Vérifient vitesse et scalabilité
- Outils : pytest-benchmark, locust

TESTS DE SÉCURITÉ
- Vérifient vulnérabilités
- Outils : bandit, safety

TESTS DE RÉGRESSION
- Vérifient que bugs corrigés restent corrigés
- Tous les tests en sont !

TESTS DE SMOKE
- Tests minimaux pour vérifier que ça démarre
- Sous-ensemble des tests
"""


# ----------------------------------------------------------------------------
# [OBJECTIF] QU'EST-CE QUE PYTEST ?
# ----------------------------------------------------------------------------

"""
PYTEST = Framework de test Python

[IDEE] DÉFINITION SIMPLE

Pytest est un outil qui :
1. Trouve vos tests automatiquement
2. Les exécute
3. Affiche les résultats
4. Fournit des outils puissants (fixtures, parametrize, etc.)


ALTERNATIVES ET COMPARAISON

┌──────────────┬────────────┬────────────┬──────────┐
│              │ PYTEST     │  UNITTEST  │   NOSE   │
├──────────────┼────────────┼────────────┼──────────┤
│ Simplicité   │   ***  │     *     │   **   │
│ Puissance    │   ***  │    **    │   **  │
│ Assertions   │    assert  │ self.assert│  assert  │
│ Fixtures     │   ***  │  setUp()   │   **   │
│ Plugins      │   ***  │     *     │   **   │
│ Communauté   │   ***  │    **    │    *    │
└──────────────┴────────────┴────────────┴──────────┘


PYTEST vs UNITTEST

UNITTEST (stdlib) :
"""

import unittest

class TestCalculator(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2, 3), 5)
    
    def test_divide_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            divide(10, 0)

# Lancer
if __name__ == '__main__':
    unittest.main()

"""
PYTEST :
"""

def test_add():
    assert add(2, 3) == 5

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

# Lancer : pytest

"""
[OK] AVANTAGES PYTEST

1. SYNTAXE SIMPLE
   - assert natif Python
   - Pas de self.assert*
   
2. DÉCOUVERTE AUTO
   - Trouve tests automatiquement
   - Pas de boilerplate
   
3. FIXTURES PUISSANTES
   - Setup/teardown élégants
   - Réutilisables
   
4. MESSAGES D'ERREUR
   - Très détaillés
   - Aide au debug
   
5. PLUGINS
   - Écosystème riche
   - Extensions faciles
   
6. COMPATIBILITÉ
   - Exécute tests unittest
   - Migration facile
"""


# ----------------------------------------------------------------------------
# [ANALYSE] ANATOMIE D'UN TEST
# ----------------------------------------------------------------------------

"""
STRUCTURE D'UN TEST PYTEST

Un test = 3 parties (AAA Pattern)
"""

def test_user_registration():
    # ARRANGE (Préparer)
    # ─────────────────
    # Setup : Créer données, objets nécessaires
    username = "alice"
    email = "alice@test.com"
    user_service = UserService()
    
    # ACT (Agir)
    # ──────────
    # Exécuter l'action à tester
    result = user_service.register(username, email)
    
    # ASSERT (Vérifier)
    # ─────────────────
    # Vérifier le résultat
    assert result.username == username
    assert result.email == email
    assert result.is_active is True

"""
[IDEE] PATTERN AAA EN DÉTAIL


1. ARRANGE (Given)
------------------
Préparer le contexte du test

- Créer objets
- Initialiser données
- Configurer mocks
- Setup environnement

Exemple :
"""

def test_shopping_cart_total():
    # ARRANGE
    cart = ShoppingCart()
    item1 = Item("Book", 15.99)
    item2 = Item("Pen", 2.50)
    cart.add_item(item1)
    cart.add_item(item2)
    
    # ACT
    total = cart.get_total()
    
    # ASSERT
    assert total == 18.49

"""
2. ACT (When)
-------------
Exécuter l'action testée

- Appeler fonction
- Invoquer méthode
- Déclencher action

Exemple :
"""

def test_login():
    # ARRANGE
    user = User("alice", "password123")
    auth_service = AuthService()
    
    # ACT
    result = auth_service.login(user.username, "password123")
    
    # ASSERT
    assert result.success is True

"""
3. ASSERT (Then)
----------------
Vérifier le résultat

- Comparer valeurs
- Vérifier états
- Checker exceptions

Exemple :
"""

def test_divide_by_zero():
    # ARRANGE
    calculator = Calculator()
    
    # ACT & ASSERT (combinés pour exceptions)
    with pytest.raises(ZeroDivisionError):
        calculator.divide(10, 0)

"""
[IDEE] VARIANTES DU PATTERN


Given-When-Then (BDD Style)
"""

def test_password_validation():
    # GIVEN a password validator
    validator = PasswordValidator()
    
    # WHEN validating a weak password
    result = validator.validate("123")
    
    # THEN validation should fail
    assert result.is_valid is False
    assert "too short" in result.errors

"""
Setup-Exercise-Verify (SEV)
"""

def test_email_sending():
    # SETUP
    email_service = EmailService()
    recipient = "test@example.com"
    
    # EXERCISE
    email_service.send(recipient, "Subject", "Body")
    
    # VERIFY
    assert email_service.sent_count == 1
    assert email_service.last_recipient == recipient


# ----------------------------------------------------------------------------
# [SYNC] CYCLE TDD (Test-Driven Development)
# ----------------------------------------------------------------------------

"""
TDD = Test-Driven Development

[IDEE] PRINCIPE

Écrire les TESTS AVANT le code !


CYCLE TDD (RED-GREEN-REFACTOR)

    ┌──────────────────────────────────┐
    │                                  │
    │  1. RED   -> Écrire test qui fail │
    │     v                            │
    │  2. GREEN -> Écrire code minimal  │
    │     v                            │
    │  3. REFACTOR -> Améliorer code    │
    │     v                            │
    └─────┘                            │
          └────────────────────────────┘


EXEMPLE COMPLET TDD


ITÉRATION 1 : RED
----------------
"""

# tests/test_calculator.py
def test_add_two_numbers():
    calc = Calculator()
    result = calc.add(2, 3)
    assert result == 5

# Lancer : pytest
# [X] FAIL : NameError: name 'Calculator' is not defined

"""
ITÉRATION 2 : GREEN
------------------
"""

# calculator.py
class Calculator:
    def add(self, a, b):
        return 5  # Code minimal qui passe le test !

# Lancer : pytest
# [OK] PASS

"""
[IDEE] OUI, on écrit DU MAUVAIS CODE exprès !
Le but : faire passer le test au plus vite


ITÉRATION 3 : REFACTOR
---------------------
"""

# calculator.py
class Calculator:
    def add(self, a, b):
        return a + b  # Code correct

# Lancer : pytest
# [OK] PASS

# Maintenant ajouter plus de tests
def test_add_negative_numbers():
    calc = Calculator()
    assert calc.add(-1, -1) == -2

def test_add_zero():
    calc = Calculator()
    assert calc.add(5, 0) == 5

"""
ITÉRATION 4 : Nouvelle fonctionnalité (RED)
-------------------------------------------
"""

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

# [X] FAIL : AttributeError: 'Calculator' object has no attribute 'subtract'

"""
ITÉRATION 5 : GREEN
------------------
"""

class Calculator:
    def add(self, a, b):
        return a + b
    
    def subtract(self, a, b):
        return a - b

# [OK] PASS

"""
[IDEE] AVANTAGES TDD

1. DESIGN
   - Penser interface avant implémentation
   - Code plus simple
   
2. COUVERTURE
   - 100% du code testé par construction
   
3. CONFIANCE
   - Tests passent dès le début
   - Régression impossible
   
4. DOCUMENTATION
   - Tests = spécification
   
5. REFACTORING
   - Safe avec filet de sécurité


[X] INCONVÉNIENTS TDD

1. TEMPS INITIAL
   - Plus lent au début
   
2. COURBE D'APPRENTISSAGE
   - Nécessite pratique
   
3. OVER-ENGINEERING
   - Risque de trop tester
   
4. PAS TOUJOURS ADAPTÉ
   - Prototypes rapides
   - UI/UX exploratoire


[OBJECTIF] QUAND UTILISER TDD ?

[OK] Logique métier complexe
[OK] Algorithmes
[OK] APIs
[OK] Code critique

[X] Prototypes jetables
[X] Exploration technique
[X] Interfaces graphiques
"""


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 0
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Pourquoi tester est essentiel
[OK] Types de tests (pyramide)
[OK] Pytest vs autres frameworks
[OK] Anatomie d'un test (AAA)
[OK] Cycle TDD (Red-Green-Refactor)


[CLE] POINTS CLÉS

1. Tests = Assurance qualité
2. Tests unitaires = Base (70-80%)
3. Pytest = Simple et puissant
4. AAA = Arrange, Act, Assert
5. TDD = Tests avant code


[OBJECTIF] AVANT DE CONTINUER

Vous devez comprendre :
[OK] Pourquoi on teste
[OK] Types de tests
[OK] Structure d'un test
[OK] Quand tester


-> PROCHAINE ÉTAPE : Chapitre 1 - Premiers Tests !

Vous allez :
- Écrire vos premiers tests
- Utiliser assert
- Exécuter pytest
- Comprendre les résultats

Prêt ? C'est parti ! [RAPIDE]
"""


# ============================================================================
# FIN DU CHAPITRE 0
# CONTINUEZ AVEC LES CHAPITRES SUIVANTS...
# ============================================================================

J'ai créé le début du guide ultra-détaillé sur pytest. Voulez-vous que je continue avec :

1. **Chapitre 1 : Premiers Tests** (assertions, exécution de base)
2. **Chapitre 2 : Assertions avancées** (comparaisons, collections)
3. Ou préférez-vous que je crée directement un fichier complet avec tous les chapitres ?

Le guide suivra exactement la même structure que le guide Flask avec :
- Explications détaillées (Comment/Pourquoi/Quand)
- Exemples pratiques abondants
- Exercices progressifs
- Cas d'usage réels
- Patterns et anti-patterns

Dites-moi comment vous souhaitez procéder ! [RAPIDE]

Je continue avec les chapitres suivants de manière ultra-détaillée :

# ============================================================================
# [GUIDE] CHAPITRE 1 : PREMIERS TESTS AVEC PYTEST
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Installer et configurer pytest
[OK] Écrire votre premier test
[OK] Utiliser assert correctement
[OK] Exécuter pytest (CLI)
[OK] Comprendre les sorties pytest
[OK] Organiser vos premiers tests
"""


# ----------------------------------------------------------------------------
# [OUTILS] INSTALLATION DÉTAILLÉE
# ----------------------------------------------------------------------------

"""
INSTALLATION PYTEST
"""

# Dans votre environnement virtuel activé
pip install pytest

# Vérifier l'installation
pytest --version
# Output : pytest 7.4.3

# Installer avec extras utiles
pip install pytest pytest-cov pytest-xdist

"""
[IDEE] PACKAGES INSTALLÉS

pytest          -> Framework principal
pytest-cov      -> Mesure de couverture
pytest-xdist    -> Exécution parallèle

Nous verrons ces extras plus tard.
"""


# ----------------------------------------------------------------------------
# [EDIT] PREMIER TEST : HELLO WORLD
# ----------------------------------------------------------------------------

"""
CRÉER LE CODE À TESTER
"""

# src/calculator.py
def add(a, b):
    """Additionne deux nombres"""
    return a + b

def subtract(a, b):
    """Soustrait b de a"""
    return a - b

"""
CRÉER LE TEST
"""

# tests/test_calculator.py
def test_add():
    """Test de la fonction add"""
    result = add(2, 3)
    assert result == 5

def test_subtract():
    """Test de la fonction subtract"""
    result = subtract(5, 3)
    assert result == 2

"""
[IDEE] DÉCRYPTAGE COMPLET


LIGNE 1 : def test_add():
-------------------------

[REFLEXION] POURQUOI "test_" ?

Pytest cherche automatiquement :
- Fichiers : test_*.py ou *_test.py
- Fonctions : test_*()
- Classes : Test*
- Méthodes : test_*()

[OK] Détecté par pytest :
- test_calculator.py
- test_add()
- test_user_login()

[X] PAS détecté :
- calculator.py (pas de test_)
- add_test() (test_ pas au début)
- verify_add() (pas test_)


LIGNE 2 : result = add(2, 3)
----------------------------

Code Python normal
Appelle la fonction à tester
Stocke le résultat


LIGNE 3 : assert result == 5
-----------------------------

[REFLEXION] QU'EST-CE QU'ASSERT ?

assert = Mot-clé Python
Vérifie qu'une condition est True

Si True  -> Continue
Si False -> Lève AssertionError

Exemple :
"""

assert 2 + 2 == 4      # [OK] Passe (True)
assert 2 + 2 == 5      # [X] Échoue (AssertionError)

"""
[IDEE] ASSERT EN DÉTAIL

Syntaxe : assert condition, "message optionnel"
"""

assert add(2, 3) == 5, "2 + 3 devrait égaler 5"

"""
Pytest AMÉLIORE assert :
- Messages détaillés automatiques
- Comparaisons visibles
- Valeurs affichées
"""


# ----------------------------------------------------------------------------
# [RAPIDE] EXÉCUTER LES TESTS
# ----------------------------------------------------------------------------

"""
MÉTHODE 1 : EXÉCUTION SIMPLE
"""

# Terminal, dans le dossier projet
pytest

"""
[IDEE] QUE FAIT PYTEST ?

1. DÉCOUVERTE
   - Cherche test_*.py
   - Trouve test_calculator.py
   
2. COLLECTION
   - Trouve test_add()
   - Trouve test_subtract()
   
3. EXÉCUTION
   - Exécute test_add()
   - Exécute test_subtract()
   
4. RAPPORT
   - Affiche résultats


OUTPUT TYPIQUE :
"""

"""
========================= test session starts =========================
platform linux -- Python 3.11.0, pytest-7.4.3, pluggy-1.3.0
rootdir: /home/user/projet
collected 2 items

tests/test_calculator.py ..                                     [100%]

========================== 2 passed in 0.05s ==========================
"""

"""
[IDEE] DÉCRYPTAGE OUTPUT


HEADER
------
platform linux       -> Système d'exploitation
Python 3.11.0        -> Version Python
pytest-7.4.3         -> Version pytest
rootdir: /home/...   -> Dossier racine


COLLECTED 2 items
-----------------
Pytest a trouvé 2 tests


tests/test_calculator.py ..
---------------------------
.. = Deux tests passés
Chaque . = Un test qui passe
F = Test échoué (Failure)
E = Erreur dans le test


[100%]
------
Progression : 100% des tests exécutés


2 passed in 0.05s
-----------------
2 tests réussis en 0.05 secondes
"""


"""
MÉTHODE 2 : EXÉCUTER UN FICHIER SPÉCIFIQUE
"""

pytest tests/test_calculator.py

"""
MÉTHODE 3 : EXÉCUTER UN TEST SPÉCIFIQUE
"""

# Syntaxe : pytest fichier::fonction
pytest tests/test_calculator.py::test_add

"""
MÉTHODE 4 : AVEC OPTIONS
"""

# Mode verbose (détaillé)
pytest -v

# Output :
"""
tests/test_calculator.py::test_add PASSED                  [ 50%]
tests/test_calculator.py::test_subtract PASSED             [100%]
"""

# Encore plus verbeux
pytest -vv

# Afficher print() même si test passe
pytest -s

# Arrêter au premier échec
pytest -x

# Mode silencieux (quiet)
pytest -q

# Combiner options
pytest -v -s


# ----------------------------------------------------------------------------
# [X] COMPRENDRE LES ÉCHECS
# ----------------------------------------------------------------------------

"""
TEST QUI ÉCHOUE
"""

# tests/test_calculator.py
def test_add_broken():
    result = add(2, 3)
    assert result == 6  # [X] FAUX !

"""
OUTPUT PYTEST :
"""

"""
========================= FAILURES =========================
_____________ test_add_broken _____________

    def test_add_broken():
        result = add(2, 3)
>       assert result == 6
E       assert 5 == 6

tests/test_calculator.py:4: AssertionError
========================= short test summary info =========================
FAILED tests/test_calculator.py::test_add_broken - assert 5 == 6
========================= 1 failed, 2 passed in 0.10s =========================
"""

"""
[IDEE] DÉCRYPTAGE ÉCHEC


_____________ test_add_broken _____________
------------------------------------------
Nom du test qui a échoué


>       assert result == 6
-------------------------
> = Ligne qui a échoué


E       assert 5 == 6
---------------------
E = Explication de l'erreur
Pytest montre la COMPARAISON :
- Valeur réelle : 5
- Valeur attendue : 6


tests/test_calculator.py:4: AssertionError
------------------------------------------
Fichier:ligne où erreur


short test summary info
-----------------------
Résumé des échecs


1 failed, 2 passed
------------------
Bilan final
"""


# ----------------------------------------------------------------------------
# [RECHERCHE] ASSERT : TOUTES LES FORMES
# ----------------------------------------------------------------------------

"""
ASSERTIONS DE BASE
"""

# Égalité
def test_equality():
    assert 2 + 2 == 4
    assert "hello" == "hello"
    assert [1, 2] == [1, 2]

# Inégalité
def test_inequality():
    assert 5 != 3
    assert "a" != "b"

# Comparaisons
def test_comparisons():
    assert 5 > 3
    assert 2 < 10
    assert 5 >= 5
    assert 3 <= 3

# Booléens
def test_booleans():
    assert True
    assert not False
    assert 5 > 3  # Évalue à True

# Membership (in)
def test_membership():
    assert 2 in [1, 2, 3]
    assert 'a' in "abc"
    assert 'x' not in [1, 2, 3]

# Identity (is)
def test_identity():
    x = None
    assert x is None
    assert x is not False
    
# Type checking
def test_types():
    assert isinstance(5, int)
    assert isinstance("hello", str)
    assert isinstance([1, 2], list)

"""
[IDEE] == vs is

== : Égalité de VALEUR
is : Égalité d'IDENTITÉ (même objet en mémoire)
"""

def test_equality_vs_identity():
    a = [1, 2, 3]
    b = [1, 2, 3]
    c = a
    
    assert a == b      # [OK] Même valeur
    assert a is not b  # [OK] Objets différents
    assert a is c      # [OK] Même objet


"""
ASSERTIONS SUR COLLECTIONS
"""

def test_lists():
    my_list = [1, 2, 3, 4, 5]
    
    # Longueur
    assert len(my_list) == 5
    
    # Contenu
    assert 3 in my_list
    assert 6 not in my_list
    
    # Premier/dernier
    assert my_list[0] == 1
    assert my_list[-1] == 5
    
    # Tranche
    assert my_list[1:3] == [2, 3]

def test_dictionaries():
    user = {'name': 'Alice', 'age': 30}
    
    # Clés
    assert 'name' in user
    assert 'email' not in user
    
    # Valeurs
    assert user['name'] == 'Alice'
    assert user.get('age') == 30
    
    # Nombre de clés
    assert len(user) == 2

def test_strings():
    text = "Hello World"
    
    # Contenu
    assert "Hello" in text
    assert text.startswith("Hello")
    assert text.endswith("World")
    
    # Casse
    assert text.lower() == "hello world"
    assert text.upper() == "HELLO WORLD"
    
    # Longueur
    assert len(text) == 11


"""
ASSERTIONS SUR ATTRIBUTS D'OBJETS
"""

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self.is_active = True

def test_user_attributes():
    user = User("Alice", 30)
    
    # Attributs
    assert user.name == "Alice"
    assert user.age == 30
    assert user.is_active is True
    
    # Existence attribut
    assert hasattr(user, 'name')
    assert hasattr(user, 'age')
    assert not hasattr(user, 'email')


# ----------------------------------------------------------------------------
# [SPEECH_BALLOON] MESSAGES D'ASSERT PERSONNALISÉS
# ----------------------------------------------------------------------------

"""
AJOUTER DES MESSAGES
"""

def test_with_message():
    result = add(2, 3)
    assert result == 5, f"Expected 5 but got {result}"

def test_detailed_message():
    numbers = [1, 2, 3]
    assert 4 in numbers, f"4 not found in {numbers}"

"""
[IDEE] QUAND AJOUTER UN MESSAGE ?

[OK] Assertions complexes
[OK] Contexte pas évident
[OK] Aide au debug

[X] Assertions simples (pytest le fait déjà bien)


COMPARAISON AVEC/SANS MESSAGE :
"""

# Sans message
def test_without_message():
    assert add(2, 3) == 6

# Output pytest :
"""
>       assert add(2, 3) == 6
E       assert 5 == 6
"""
# -> Déjà très clair !


# Avec message utile
def test_with_useful_message():
    age = 17
    assert age >= 18, f"User must be 18+, got {age}"

# Output :
"""
>       assert age >= 18, f"User must be 18+, got {age}"
E       AssertionError: User must be 18+, got 17
"""
# -> Message apporte contexte


# ----------------------------------------------------------------------------
# [LISTE] MULTIPLES ASSERTIONS
# ----------------------------------------------------------------------------

"""
PLUSIEURS ASSERTIONS DANS UN TEST
"""

def test_user_creation():
    user = User("Alice", 30)
    
    # Multiple assertions
    assert user.name == "Alice"
    assert user.age == 30
    assert user.is_active is True

"""
[IDEE] BONNE PRATIQUE OU PAS ?

[REFLEXION] DÉBAT :

Une assertion par test (puriste) :
"""
def test_user_name():
    user = User("Alice", 30)
    assert user.name == "Alice"

def test_user_age():
    user = User("Alice", 30)
    assert user.age == 30

def test_user_is_active():
    user = User("Alice", 30)
    assert user.is_active is True

"""
Plusieurs assertions (pragmatique) :
"""
def test_user_creation():
    user = User("Alice", 30)
    assert user.name == "Alice"
    assert user.age == 30
    assert user.is_active is True

"""
[OK] RECOMMANDATION

Plusieurs assertions OK SI :
- Testent le MÊME comportement
- Logiquement liées
- Échouer ensemble a du sens

[X] Séparer SI :
- Comportements indépendants
- Peuvent échouer séparément
- Clarity over DRY


[ATTENTION] ATTENTION : ARRÊT À LA PREMIÈRE ERREUR
"""

def test_multiple_assertions():
    assert 1 + 1 == 2      # [OK] Passe
    assert 2 + 2 == 5      # [X] ÉCHOUE
    assert 3 + 3 == 6      # [ATTENTION] Jamais exécuté !

"""
Si première assertion échoue, les suivantes ne sont PAS exécutées.

Solution : pytest-check (plugin) ou diviser tests
"""


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 1 : PREMIERS TESTS
# ----------------------------------------------------------------------------

"""
OBJECTIF : Créer et tester un module simple


ÉTAPE 1 : CRÉER LE CODE
-----------------------
"""

# src/string_utils.py
def reverse_string(text):
    """Inverse une chaîne de caractères"""
    return text[::-1]

def is_palindrome(text):
    """Vérifie si un texte est un palindrome"""
    cleaned = text.lower().replace(" ", "")
    return cleaned == cleaned[::-1]

def count_words(text):
    """Compte le nombre de mots dans un texte"""
    return len(text.split())

"""
ÉTAPE 2 : CRÉER LES TESTS
-------------------------
"""

# tests/test_string_utils.py
from src.string_utils import reverse_string, is_palindrome, count_words

def test_reverse_string():
    """Test inversion de chaîne"""
    assert reverse_string("hello") == "olleh"
    assert reverse_string("Python") == "nohtyP"
    assert reverse_string("") == ""
    assert reverse_string("a") == "a"

def test_is_palindrome_true():
    """Test palindromes valides"""
    assert is_palindrome("radar") is True
    assert is_palindrome("A man a plan a canal Panama") is True
    assert is_palindrome("noon") is True

def test_is_palindrome_false():
    """Test non-palindromes"""
    assert is_palindrome("hello") is False
    assert is_palindrome("Python") is False

def test_count_words():
    """Test comptage de mots"""
    assert count_words("hello world") == 2
    assert count_words("Python is awesome") == 3
    assert count_words("") == 1  # split() retourne ['']
    assert count_words("one") == 1

"""
ÉTAPE 3 : EXÉCUTER
-----------------
"""

# Terminal
pytest tests/test_string_utils.py -v

"""
ÉTAPE 4 : CORRIGER LE BUG
-------------------------

[BUG] Bug : count_words("") retourne 1 au lieu de 0

Fix :
"""

def count_words(text):
    """Compte le nombre de mots dans un texte"""
    if not text:
        return 0
    return len(text.split())

"""
ÉTAPE 5 : AJOUTER TEST POUR LE FIX
----------------------------------
"""

def test_count_words_empty_string():
    """Test chaîne vide"""
    assert count_words("") == 0

# Relancer tests
pytest tests/test_string_utils.py -v
# Tous verts ! [OK]


# ----------------------------------------------------------------------------
# [GRAPHIQUE] ORGANISATION DES TESTS
# ----------------------------------------------------------------------------

"""
STRUCTURE RECOMMANDÉE
"""

tests/
├── __init__.py              # Rend tests un package
├── conftest.py              # Fixtures partagées (voir chapitre 5)
├── test_calculator.py       # Tests pour calculator.py
├── test_string_utils.py     # Tests pour string_utils.py
└── test_user.py            # Tests pour user.py

"""
[IDEE] RÈGLES D'ORGANISATION

1. UN FICHIER DE TEST PAR MODULE
   src/calculator.py -> tests/test_calculator.py
   src/user.py -> tests/test_user.py

2. STRUCTURE MIROIR
   tests/ reflète src/
   
3. NOMMAGE COHÉRENT
   Fonction add() -> test_add()
   Classe User -> TestUser


EXEMPLE STRUCTURE COMPLÈTE :
"""

projet/
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   ├── user.py
│   └── utils/
│       ├── __init__.py
│       └── string_utils.py
└── tests/
    ├── __init__.py
    ├── conftest.py
    ├── test_calculator.py
    ├── test_user.py
    └── utils/
        ├── __init__.py
        └── test_string_utils.py


"""
GROUPER LES TESTS AVEC DES CLASSES
"""

# tests/test_calculator.py
class TestAddition:
    """Tests pour l'addition"""
    
    def test_add_positive_numbers(self):
        assert add(2, 3) == 5
    
    def test_add_negative_numbers(self):
        assert add(-1, -1) == -2
    
    def test_add_zero(self):
        assert add(5, 0) == 5

class TestSubtraction:
    """Tests pour la soustraction"""
    
    def test_subtract_positive_numbers(self):
        assert subtract(5, 3) == 2
    
    def test_subtract_negative_numbers(self):
        assert subtract(-1, -1) == 0

"""
[IDEE] POURQUOI DES CLASSES ?

[OK] Organisation logique
[OK] Partage setup (fixtures, setUp)
[OK] Namespace clair
[OK] Héritage possible

[X] Pas obligatoire pour tests simples


CONVENTION :
- Classe commence par Test
- Pas de __init__()
- Méthodes commencent par test_
"""


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 1
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Installer pytest
[OK] Écrire tests simples (def test_*)
[OK] Utiliser assert sous toutes ses formes
[OK] Exécuter pytest (options CLI)
[OK] Comprendre sorties pytest
[OK] Messages d'erreur détaillés
[OK] Organiser tests (fichiers, classes)


[CLE] POINTS CLÉS

1. Nommage : test_*.py, test_*()
2. Assert : Mot-clé Python, amélioré par pytest
3. pytest : Découverte auto des tests
4. Options : -v (verbose), -s (print), -x (stop first)
5. Organisation : Structure miroir src/tests


[OBJECTIF] CHECKLIST

Vous devez savoir :
[OK] Écrire un test simple
[OK] Utiliser assert correctement
[OK] Lancer pytest
[OK] Lire les résultats
[OK] Organiser fichiers de tests


-> PROCHAINE ÉTAPE : Chapitre 2 - Assertions Avancées

Vous allez apprendre :
- Comparaisons complexes
- Assertions sur collections
- Assertions approximatives
- pytest.approx()
- Helpers d'assertions

C'est parti ! [RAPIDE]
"""


# ============================================================================
# [GUIDE] CHAPITRE 2 : ASSERTIONS ET COMPARAISONS AVANCÉES
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Comparer collections complexes
[OK] Utiliser pytest.approx() (nombres flottants)
[OK] Assertions sur exceptions
[OK] Assertions sur warnings
[OK] Helpers pytest pour assertions
[OK] Messages d'erreur détaillés pytest
"""


# ----------------------------------------------------------------------------
# [NOMBRE] COMPARAISONS DE COLLECTIONS
# ----------------------------------------------------------------------------

"""
LISTES : COMPARAISONS DÉTAILLÉES
"""

def test_list_equality():
    """Pytest montre différences détaillées"""
    expected = [1, 2, 3, 4, 5]
    actual = [1, 2, 3, 4, 5]
    assert actual == expected  # [OK] Passe

def test_list_difference():
    """Différence dans une liste"""
    expected = [1, 2, 3, 4, 5]
    actual = [1, 2, 99, 4, 5]  # [X] Différence en index 2
    assert actual == expected

"""
[IDEE] OUTPUT PYTEST :
"""
"""
>       assert actual == expected
E       AssertionError: assert [1, 2, 99, 4, 5] == [1, 2, 3, 4, 5]
E         At index 2 diff: 99 != 3
E         Full diff:
E         - [1, 2, 3, 4, 5]
E         + [1, 2, 99, 4, 5]
"""

"""
Pytest montre EXACTEMENT où est la différence !


DICTIONNAIRES : COMPARAISONS DÉTAILLÉES
"""

def test_dict_equality():
    expected = {'name': 'Alice', 'age': 30, 'city': 'Paris'}
    actual = {'name': 'Alice', 'age': 31, 'city': 'Paris'}
    assert actual == expected

"""
[IDEE] OUTPUT PYTEST :
"""
"""
>       assert actual == expected
E       AssertionError: assert {'name': 'Al...ity': 'Paris'} == {'name': 'Al...ity': 'Paris'}
E         Differing items:
E         {'age': 31} != {'age': 30}
E         Full diff:
E         - {'age': 30, 'city': 'Paris', 'name': 'Alice'}
E         + {'age': 31, 'city': 'Paris', 'name': 'Alice'}
"""

"""
SETS : COMPARAISONS
"""

def test_set_equality():
    expected = {1, 2, 3, 4}
    actual = {1, 2, 3, 5}
    assert actual == expected

"""
Output :
"""
"""
E       AssertionError: assert {1, 2, 3, 5} == {1, 2, 3, 4}
E         Extra items in the left set:
E         {5}
E         Extra items in the right set:
E         {4}
"""


"""
SOUS-ENSEMBLES
"""

def test_subset():
    small = {1, 2}
    large = {1, 2, 3, 4}
    
    # Vérifier que small est sous-ensemble de large
    assert small <= large        # [OK] issubset
    assert small.issubset(large) # [OK] Équivalent

def test_superset():
    small = {1, 2}
    large = {1, 2, 3, 4}
    
    # Vérifier que large contient small
    assert large >= small           # [OK] issuperset
    assert large.issuperset(small)  # [OK] Équivalent


"""
LISTES : ORDRE ET CONTENU
"""

def test_list_contains_all():
    """Vérifier que liste contient certains éléments"""
    my_list = [1, 2, 3, 4, 5]
    
    # Vérifier présence
    assert 3 in my_list
    assert all(x in my_list for x in [1, 3, 5])

def test_list_order_matters():
    """L'ordre compte pour les listes"""
    list1 = [1, 2, 3]
    list2 = [3, 2, 1]
    
    assert list1 != list2  # [X] Ordre différent
    assert set(list1) == set(list2)  # [OK] Même contenu

def test_list_contains_only():
    """Liste contient SEULEMENT ces éléments"""
    my_list = [1, 2, 3]
    expected = [1, 2, 3]
    
    # Exact
    assert my_list == expected
    
    # Ou vérifier contenu sans ordre
    assert sorted(my_list) == sorted(expected)


# ----------------------------------------------------------------------------
# [MESURE] NOMBRES FLOTTANTS : pytest.approx()
# ----------------------------------------------------------------------------

"""
PROBLÈME : PRÉCISION DES FLOTTANTS
"""

def test_float_equality_problem():
    """PROBLÈME avec égalité stricte"""
    result = 0.1 + 0.2
    assert result == 0.3  # [X] ÉCHOUE !

"""
[IDEE] POURQUOI ÇA ÉCHOUE ?

En informatique, 0.1 + 0.2 ≠ 0.3 exactement !
"""

0.1 + 0.2
# -> 0.30000000000000004

"""
C'est dû à la représentation binaire des flottants.


[OK] SOLUTION : pytest.approx()
"""

import pytest

def test_float_equality_approx():
    """Comparaison approximative"""
    result = 0.1 + 0.2
    assert result == pytest.approx(0.3)  # [OK] Passe !

"""
[IDEE] pytest.approx() EN DÉTAIL

Vérifie que deux nombres sont "assez proches"

Par défaut : tolérance = 1e-6 (0.000001)


SYNTAXE COMPLÈTE :
"""

pytest.approx(expected, rel=None, abs=None)

"""
Paramètres :
- expected : Valeur attendue
- rel : Tolérance relative (pourcentage)
- abs : Tolérance absolue (valeur fixe)


EXEMPLES :
"""

def test_approx_examples():
    # Défaut (1e-6)
    assert 0.3 == pytest.approx(0.3000001)  # [OK]
    
    # Tolérance relative 1% (0.01)
    assert 100 == pytest.approx(101, rel=0.01)  # [OK]
    
    # Tolérance absolue
    assert 1.0 == pytest.approx(1.1, abs=0.2)  # [OK]

"""
AVEC LISTES ET DICTS
"""

def test_approx_list():
    """approx() fonctionne sur collections"""
    actual = [0.1 + 0.2, 0.2 + 0.3, 0.3 + 0.4]
    expected = [0.3, 0.5, 0.7]
    
    assert actual == pytest.approx(expected)  # [OK]

def test_approx_dict():
    """approx() sur dictionnaires"""
    actual = {'a': 0.1 + 0.2, 'b': 0.5}
    expected = {'a': 0.3, 'b': 0.5}
    
    assert actual == pytest.approx(expected)  # [OK]

"""
CALCULS SCIENTIFIQUES
"""

import math

def test_scientific_calculations():
    """Tests avec calculs mathématiques"""
    # Pi
    assert math.pi == pytest.approx(3.14159, abs=0.00001)
    
    # Racine carrée
    assert math.sqrt(2) == pytest.approx(1.41421, rel=1e-5)
    
    # Exponentielle
    assert math.e == pytest.approx(2.71828, abs=0.00001)


# ----------------------------------------------------------------------------
# [IMPACT] ASSERTIONS SUR EXCEPTIONS
# ----------------------------------------------------------------------------

"""
VÉRIFIER QU'UNE EXCEPTION EST LEVÉE
"""

def divide(a, b):
    """Division de a par b"""
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

"""
TEST : EXCEPTION ATTENDUE
"""

def test_divide_by_zero():
    """Division par zéro doit lever ValueError"""
    with pytest.raises(ValueError):
        divide(10, 0)

"""
[IDEE] SYNTAXE pytest.raises()

with pytest.raises(ExceptionType):
    # Code qui doit lever l'exception


Si exception levée -> Test passe [OK]
Si pas d'exception -> Test échoue [X]
Si autre exception -> Test échoue [X]


VÉRIFIER LE MESSAGE D'ERREUR
"""

def test_divide_by_zero_message():
    """Vérifier le message d'erreur"""
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

"""
[IDEE] PARAMÈTRE match

match : Regex pour vérifier le message

Exemples :
"""

def test_exception_messages():
    # Message exact
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)
    
    # Regex
    with pytest.raises(ValueError, match=r"Cannot .* zero"):
        divide(10, 0)
    
    # Début du message
    with pytest.raises(ValueError, match="^Cannot"):
        divide(10, 0)

"""
CAPTURER L'EXCEPTION POUR INSPECTER
"""

def test_exception_details():
    """Inspecter détails de l'exception"""
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    
    # Accéder à l'exception
    exception = exc_info.value
    
    # Vérifier message
    assert str(exception) == "Cannot divide by zero"
    
    # Vérifier type
    assert isinstance(exception, ValueError)

"""
[IDEE] exc_info

exc_info.value   -> L'exception elle-même
exc_info.type    -> Type de l'exception
exc_info.tb      -> Traceback


MULTIPLES EXCEPTIONS POSSIBLES
"""

def test_multiple_exceptions():
    """Accepter plusieurs types d'exceptions"""
    with pytest.raises((ValueError, TypeError)):
        # L'un ou l'autre est OK
        divide("10", 0)

"""
VÉRIFIER QU'AUCUNE EXCEPTION N'EST LEVÉE
"""

def test_no_exception():
    """Vérifier qu'aucune exception n'est levée"""
    # Pas de syntaxe spéciale, juste exécuter
    result = divide(10, 2)
    assert result == 5.0  # Si exception, test échoue


# ----------------------------------------------------------------------------
# [ATTENTION] ASSERTIONS SUR WARNINGS
# ----------------------------------------------------------------------------

"""
VÉRIFIER QU'UN WARNING EST ÉMIS
"""

import warnings

def deprecated_function():
    """Fonction dépréciée"""
    warnings.warn("Cette fonction est dépréciée", DeprecationWarning)
    return "result"

"""
TEST : WARNING ATTENDU
"""

def test_deprecation_warning():
    """Vérifier warning de dépréciation"""
    with pytest.warns(DeprecationWarning):
        deprecated_function()

"""
CAPTURER WARNING POUR INSPECTER
"""

def test_warning_details():
    """Inspecter détails du warning"""
    with pytest.warns(DeprecationWarning) as warning_info:
        deprecated_function()
    
    # Vérifier message
    assert "dépréciée" in str(warning_info[0].message)

"""
VÉRIFIER MESSAGE DU WARNING
"""

def test_warning_message():
    """Vérifier message exact"""
    with pytest.warns(DeprecationWarning, match="fonction est dépréciée"):
        deprecated_function()


# ----------------------------------------------------------------------------
# [OUTILS] HELPERS PYTEST POUR ASSERTIONS
# ----------------------------------------------------------------------------

"""
pytest.fail() : FORCER UN ÉCHEC
"""

def test_conditional_failure():
    """Échec conditionnel"""
    result = complex_computation()
    
    if result < 0:
        pytest.fail("Result should not be negative")
    
    assert result > 0

"""
[IDEE] pytest.fail(msg)

Force un échec de test avec message
Utile pour :
- Conditions complexes
- Échecs personnalisés
- Debugging


pytest.skip() : SAUTER UN TEST
"""

def test_skip_example():
    """Test sauté"""
    if not has_network():
        pytest.skip("No network connection")
    
    # Test qui nécessite réseau
    response = fetch_data()
    assert response.status_code == 200

"""
[IDEE] pytest.skip(reason)

Saute le test avec raison
Utile pour :
- Dépendances manquantes
- Conditions non remplies
- Tests WIP (Work In Progress)


pytest.xfail() : ÉCHEC ATTENDU
"""

def test_known_bug():
    """Test pour bug connu"""
    pytest.xfail("Bug #123 not fixed yet")
    
    result = buggy_function()
    assert result == expected

"""
[IDEE] pytest.xfail(reason)

Marque test comme "expected failure"
- Si échoue -> XFAIL (attendu)
- Si passe -> XPASS (surprise !)

Utile pour :
- Bugs connus documentés
- Features pas encore implémentées
- Plateformes spécifiques


COMPARAISON skip vs xfail :

skip  -> "Je ne peux pas tester maintenant"
xfail -> "Je sais que ça va échouer"
"""


# ----------------------------------------------------------------------------
# [NOTE] ASSERTIONS PERSONNALISÉES
# ----------------------------------------------------------------------------

"""
CRÉER HELPERS D'ASSERTIONS
"""

def assert_valid_email(email):
    """Helper pour valider email"""
    assert '@' in email, f"{email} is not a valid email"
    assert '.' in email.split('@')[1], f"{email} missing domain extension"

def test_email_validation():
    """Utiliser helper personnalisé"""
    assert_valid_email("user@example.com")  # [OK]
    assert_valid_email("invalid.email")     # [X]

"""
ASSERTIONS AVEC CONTEXTE
"""

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def is_adult(self):
        return self.age >= 18

def assert_adult(user):
    """Vérifie qu'un user est adulte"""
    assert user.is_adult(), (
        f"User {user.name} is {user.age} years old, "
        f"expected at least 18"
    )

def test_adult_user():
    user = User("Alice", 17)
    assert_adult(user)  # [X] Message détaillé !

"""
Output :
AssertionError: User Alice is 17 years old, expected at least 18
"""


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 2 : ASSERTIONS AVANCÉES
# ----------------------------------------------------------------------------

"""
OBJECTIF : Tester un module de validation


ÉTAPE 1 : CODE À TESTER
"""

# src/validator.py
import re

class ValidationError(Exception):
    """Exception pour erreurs de validation"""
    pass

def validate_email(email):
    """Valide format email"""
    pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'
    if not re.match(pattern, email):
        raise ValidationError(f"Invalid email: {email}")
    return True

def validate_password(password):
    """
    Valide mot de passe :
    - Min 8 caractères
    - Au moins 1 majuscule
    - Au moins 1 chiffre
    """
    if len(password) < 8:
        raise ValidationError("Password too short")
    if not any(c.isupper() for c in password):
        raise ValidationError("Password must contain uppercase")
    if not any(c.isdigit() for c in password):
        raise ValidationError("Password must contain digit")
    return True

def calculate_discount(price, percentage):
    """Calcule réduction"""
    if price < 0:
        raise ValueError("Price cannot be negative")
    if not 0 <= percentage <= 100:
        raise ValueError("Percentage must be 0-100")
    
    discount = price * (percentage / 100)
    return round(discount, 2)

"""
ÉTAPE 2 : TESTS
"""

# tests/test_validator.py
import pytest
from src.validator import (
    validate_email, 
    validate_password, 
    calculate_discount,
    ValidationError
)

class TestEmailValidation:
    """Tests validation email"""
    
    def test_valid_emails(self):
        """Emails valides"""
        assert validate_email("user@example.com") is True
        assert validate_email("test.user@domain.co.uk") is True
        assert validate_email("user123@test.org") is True
    
    def test_invalid_emails(self):
        """Emails invalides lèvent exception"""
        with pytest.raises(ValidationError, match="Invalid email"):
            validate_email("invalid.email")
        
        with pytest.raises(ValidationError, match="Invalid email"):
            validate_email("@example.com")
        
        with pytest.raises(ValidationError):
            validate_email("user@")

class TestPasswordValidation:
    """Tests validation password"""
    
    def test_valid_password(self):
        """Password valide"""
        assert validate_password("SecurePass123") is True
    
    def test_too_short(self):
        """Password trop court"""
        with pytest.raises(ValidationError, match="too short"):
            validate_password("Short1")
    
    def test_no_uppercase(self):
        """Pas de majuscule"""
        with pytest.raises(ValidationError, match="uppercase"):
            validate_password("password123")
    
    def test_no_digit(self):
        """Pas de chiffre"""
        with pytest.raises(ValidationError, match="digit"):
            validate_password("PasswordOnly")

class TestDiscountCalculation:
    """Tests calcul réduction"""
    
    def test_calculate_discount(self):
        """Calculs normaux"""
        assert calculate_discount(100, 10) == pytest.approx(10.0)
        assert calculate_discount(99.99, 20) == pytest.approx(20.0, abs=0.01)
        assert calculate_discount(150, 50) == pytest.approx(75.0)
    
    def test_zero_discount(self):
        """Réduction 0%"""
        assert calculate_discount(100, 0) == 0.0
    
    def test_full_discount(self):
        """Réduction 100%"""
        assert calculate_discount(100, 100) == 100.0
    
    def test_negative_price(self):
        """Prix négatif invalide"""
        with pytest.raises(ValueError, match="cannot be negative"):
            calculate_discount(-10, 10)
    
    def test_invalid_percentage(self):
        """Pourcentage invalide"""
        with pytest.raises(ValueError, match="must be 0-100"):
            calculate_discount(100, 150)
        
        with pytest.raises(ValueError, match="must be 0-100"):
            calculate_discount(100, -10)

"""
ÉTAPE 3 : EXÉCUTER
"""

pytest tests/test_validator.py -v

"""
ÉTAPE 4 : AJOUTER EDGE CASES
"""

class TestDiscountEdgeCases:
    """Tests cas limites"""
    
    def test_very_small_price(self):
        """Prix très petit"""
        assert calculate_discount(0.01, 50) == pytest.approx(0.01, abs=0.01)
    
    def test_very_large_price(self):
        """Prix très grand"""
        result = calculate_discount(999999.99, 25)
        assert result == pytest.approx(249999.998, rel=1e-6)
    
    def test_rounding(self):
        """Vérifier arrondi à 2 décimales"""
        result = calculate_discount(10, 33.33)
        assert result == 3.33  # Pas 3.333


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 2
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Comparaisons collections détaillées
[OK] pytest.approx() pour flottants
[OK] pytest.raises() pour exceptions
[OK] pytest.warns() pour warnings
[OK] Helpers pytest (fail, skip, xfail)
[OK] Assertions personnalisées


[CLE] POINTS CLÉS

1. Pytest montre différences détaillées automatiquement
2. pytest.approx() pour nombres flottants
3. pytest.raises() pour vérifier exceptions
4. match= pour vérifier messages
5. exc_info pour inspecter exceptions


[OBJECTIF] PATTERNS UTILES

# Exceptions
with pytest.raises(ValueError, match="message"):
    fonction()

# Flottants
assert 0.1 + 0.2 == pytest.approx(0.3)

# Warnings
with pytest.warns(DeprecationWarning):
    fonction()

# Collections
assert actual == pytest.approx(expected)  # Liste/Dict


-> PROCHAINE ÉTAPE : Chapitre 3 - Organisation des Tests

Vous allez apprendre :
- Structure de projet
- Fichiers conftest.py
- Découverte de tests
- Conventions de nommage
- Tests paramétrés

Le guide continue ! [RAPIDE]
"""

Voulez-vous que je continue avec :
1. **Chapitre 3 : Organisation des Tests** (structure, conftest, conventions)
2. **Chapitre 4 : Exécution et Configuration** (pytest.ini, options CLI avancées)
3. Ou passer directement à la **Partie 2 sur les Fixtures** ?

Je continue à suivre la même approche ultra-détaillée avec exemples pratiques ! [DOCS]

Je continue avec le Chapitre 3 sur l'organisation des tests de manière ultra-détaillée :

# ============================================================================
# [GUIDE] CHAPITRE 3 : ORGANISATION DES TESTS
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Organiser tests par module et fonctionnalité
[OK] Structure de projet recommandée
[OK] Utiliser conftest.py efficacement
[OK] Conventions de nommage pytest
[OK] Découverte automatique de tests
[OK] Regrouper tests avec classes
[OK] Tests paramétrés de base
[OK] Markers pour catégoriser tests
"""


# ----------------------------------------------------------------------------
# [DOSSIER] STRUCTURE DE PROJET : PATTERNS RECOMMANDÉS
# ----------------------------------------------------------------------------

"""
PETIT PROJET (< 1000 lignes de code)
------------------------------------
"""

simple_project/
├── venv/
├── calculator.py           # Code source
├── test_calculator.py      # Tests
├── requirements.txt
└── pytest.ini

"""
[IDEE] STRUCTURE SIMPLE

Avantages :
[OK] Démarrage rapide
[OK] Pas de complexité
[OK] Bon pour prototypes

Limitations :
[X] Mélange code et tests
[X] Difficile à scale


PROJET MOYEN (1000-10000 lignes)
--------------------------------
"""

medium_project/
├── venv/
├── src/                    # <- Code source séparé
│   ├── __init__.py
│   ├── calculator.py
│   ├── user.py
│   └── utils.py
├── tests/                  # <- Tests séparés
│   ├── __init__.py
│   ├── conftest.py        # <- Configuration partagée
│   ├── test_calculator.py
│   ├── test_user.py
│   └── test_utils.py
├── pytest.ini
├── requirements.txt
└── README.md

"""
[IDEE] STRUCTURE MOYENNE

Avantages :
[OK] Séparation claire code/tests
[OK] Scalable
[OK] Standard Python

C'est la structure RECOMMANDÉE pour la plupart des projets.


GRAND PROJET (10000+ lignes)
----------------------------
"""

large_project/
├── venv/
├── src/
│   ├── __init__.py
│   ├── core/              # <- Modules organisés
│   │   ├── __init__.py
│   │   ├── calculator.py
│   │   └── validator.py
│   ├── auth/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   └── session.py
│   └── utils/
│       ├── __init__.py
│       ├── string_utils.py
│       └── date_utils.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py        # <- Configuration globale
│   ├── core/              # <- Structure MIROIR
│   │   ├── __init__.py
│   │   ├── conftest.py   # <- Config spécifique module
│   │   ├── test_calculator.py
│   │   └── test_validator.py
│   ├── auth/
│   │   ├── __init__.py
│   │   ├── conftest.py
│   │   ├── test_user.py
│   │   └── test_session.py
│   ├── utils/
│   │   ├── __init__.py
│   │   ├── test_string_utils.py
│   │   └── test_date_utils.py
│   ├── integration/       # <- Tests d'intégration
│   │   ├── __init__.py
│   │   └── test_auth_flow.py
│   └── e2e/              # <- Tests end-to-end
│       ├── __init__.py
│       └── test_user_journey.py
├── pytest.ini
├── .coveragerc
├── requirements.txt
├── requirements-dev.txt
└── README.md

"""
[IDEE] STRUCTURE GRANDE ÉCHELLE

Principes :
1. Structure MIROIR : tests/ reflète src/
2. Hiérarchie conftest.py : Global -> Module -> Sous-module
3. Séparation types de tests : unit/ integration/ e2e/


PROJET AVEC APPLICATION (Flask, Django, FastAPI)
-----------------------------------------------
"""

web_project/
├── venv/
├── app/                    # Application web
│   ├── __init__.py
│   ├── models.py
│   ├── routes.py
│   ├── forms.py
│   └── utils.py
├── tests/
│   ├── conftest.py        # Fixtures app (client, db)
│   ├── unit/              # Tests unitaires
│   │   ├── test_models.py
│   │   └── test_utils.py
│   ├── integration/       # Tests intégration
│   │   ├── test_routes.py
│   │   └── test_forms.py
│   └── functional/        # Tests fonctionnels
│       └── test_user_flow.py
├── migrations/            # Migrations DB
├── instance/              # Instance data
├── pytest.ini
└── requirements.txt


# ----------------------------------------------------------------------------
# [DOSSIER] CONVENTIONS DE NOMMAGE PYTEST
# ----------------------------------------------------------------------------

"""
RÈGLES DE DÉCOUVERTE PYTEST

Pytest cherche automatiquement :


1. FICHIERS DE TESTS
-------------------

[OK] Détectés :
- test_*.py
- *_test.py

[X] PAS détectés :
- tests.py
- my_test_file.py (pas test_ au début)
- test.py (trop générique)


Exemples :
"""

tests/
├── test_calculator.py      # [OK] Détecté
├── test_user.py           # [OK] Détecté
├── calculator_test.py     # [OK] Détecté
├── tests.py               # [X] Ignoré
└── my_tests.py            # [X] Ignoré

"""
2. FONCTIONS DE TESTS
---------------------

[OK] Détectées :
- Fonctions commençant par test_

[X] PAS détectées :
- Fonctions sans test_ au début


Exemples :
"""

# test_calculator.py

def test_add():              # [OK] Détectée
    assert add(2, 3) == 5

def test_subtract():         # [OK] Détectée
    assert subtract(5, 3) == 2

def verify_add():            # [X] Ignorée
    assert add(2, 3) == 5

def add_test():              # [X] Ignorée (test_ pas au début)
    assert add(2, 3) == 5

def helper_function():       # [X] Ignorée (normal)
    return "helper"

"""
3. CLASSES DE TESTS
-------------------

[OK] Détectées :
- Classes commençant par Test (majuscule)
- PAS de __init__() dans la classe

[X] PAS détectées :
- Classes ne commençant pas par Test
- Classes avec __init__()


Exemples :
"""

class TestCalculator:        # [OK] Détectée
    def test_add(self):
        assert add(2, 3) == 5

class TestUser:              # [OK] Détectée
    def test_creation(self):
        user = User("Alice")
        assert user.name == "Alice"

class Calculator:            # [X] Ignorée (pas Test*)
    def test_add(self):
        assert add(2, 3) == 5

class TestWithInit:          # [X] Ignorée (a __init__)
    def __init__(self):
        self.value = 10
    
    def test_value(self):
        assert self.value == 10

"""
[IDEE] POURQUOI PAS DE __init__() ?

Pytest crée une NOUVELLE INSTANCE pour chaque test
-> __init__() serait appelé plusieurs fois
-> État partagé non voulu

Solution : Utiliser fixtures (Chapitre 5)


4. MÉTHODES DANS CLASSES
------------------------

[OK] Détectées :
- Méthodes commençant par test_
- Dans classe Test*

Exemples :
"""

class TestMath:
    def test_add(self):              # [OK] Détectée
        assert 2 + 2 == 4
    
    def test_multiply(self):         # [OK] Détectée
        assert 2 * 3 == 6
    
    def helper_method(self):         # [X] Ignorée (pas test_)
        return "helper"
    
    def setup_method(self):          # [ATTENTION] Méthode spéciale pytest
        self.value = 10

"""
5. DOSSIERS
-----------

Pytest explore récursivement TOUS les dossiers
SAUF ceux avec des points au début (.git, .venv)


[OK] Explorés :
tests/
tests/unit/
tests/integration/

[X] Ignorés :
.git/
.venv/
__pycache__/
"""


# ----------------------------------------------------------------------------
# [CONFIG] FICHIER conftest.py
# ----------------------------------------------------------------------------

"""
conftest.py = FICHIER MAGIQUE PYTEST

[IDEE] QU'EST-CE QUE conftest.py ?

Fichier spécial pour :
- Fixtures partagées
- Hooks pytest
- Configuration
- Plugins

[IMPORTANT] OÙ LE PLACER ?

Règle : conftest.py est cherché dans le dossier du test et parents
"""

tests/
├── conftest.py           # <- Fixtures pour TOUS les tests
├── test_user.py
├── unit/
│   ├── conftest.py      # <- Fixtures pour tests unit/
│   └── test_models.py
└── integration/
    ├── conftest.py      # <- Fixtures pour tests integration/
    └── test_api.py

"""
[IDEE] HIÉRARCHIE conftest.py

Tests cherchent fixtures dans cet ordre :
1. conftest.py du dossier du test
2. conftest.py du parent
3. conftest.py du grand-parent
4. etc. jusqu'à la racine


EXEMPLE 1 : conftest.py SIMPLE
------------------------------
"""

# tests/conftest.py
import pytest

@pytest.fixture
def sample_data():
    """Fixture disponible pour TOUS les tests"""
    return {"name": "Alice", "age": 30}

@pytest.fixture
def sample_list():
    """Fixture de liste"""
    return [1, 2, 3, 4, 5]

"""
Utilisation dans n'importe quel test :
"""

# tests/test_user.py
def test_user_data(sample_data):
    """Utilise fixture de conftest.py"""
    assert sample_data["name"] == "Alice"

# tests/unit/test_models.py
def test_list_length(sample_list):
    """Utilise aussi fixture de conftest.py racine"""
    assert len(sample_list) == 5

"""
EXEMPLE 2 : conftest.py POUR BASE DE DONNÉES
--------------------------------------------
"""

# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import Base

@pytest.fixture(scope="session")
def engine():
    """
    Crée engine DB une fois pour toute la session
    """
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    yield engine
    engine.dispose()

@pytest.fixture(scope="function")
def db_session(engine):
    """
    Crée une session DB pour chaque test
    Rollback après chaque test
    """
    connection = engine.connect()
    transaction = connection.begin()
    Session = sessionmaker(bind=connection)
    session = Session()
    
    yield session
    
    session.close()
    transaction.rollback()
    connection.close()

"""
Utilisation :
"""

# tests/test_user.py
from app.models import User

def test_create_user(db_session):
    """Test création user en DB"""
    user = User(name="Alice", email="alice@test.com")
    db_session.add(user)
    db_session.commit()
    
    # Vérifier
    assert db_session.query(User).count() == 1

"""
EXEMPLE 3 : conftest.py POUR APPLICATION WEB
-------------------------------------------
"""

# tests/conftest.py
import pytest
from app import create_app
from app.extensions import db

@pytest.fixture(scope="session")
def app():
    """Crée instance Flask pour tests"""
    app = create_app('testing')
    
    with app.app_context():
        db.create_all()
        yield app
        db.drop_all()

@pytest.fixture(scope="function")
def client(app):
    """Client de test Flask"""
    return app.test_client()

@pytest.fixture(scope="function")
def runner(app):
    """Test CLI runner"""
    return app.test_cli_runner()

"""
Utilisation :
"""

# tests/test_routes.py
def test_home_page(client):
    """Test page d'accueil"""
    response = client.get('/')
    assert response.status_code == 200
    assert b'Welcome' in response.data

"""
EXEMPLE 4 : conftest.py HIÉRARCHIQUE
------------------------------------
"""

# tests/conftest.py (racine)
import pytest

@pytest.fixture
def base_url():
    """URL de base pour tous les tests"""
    return "http://localhost:5000"

# tests/unit/conftest.py
import pytest

@pytest.fixture
def mock_database():
    """Mock DB pour tests unitaires seulement"""
    return MockDatabase()

# tests/integration/conftest.py
import pytest

@pytest.fixture
def real_database():
    """Vraie DB pour tests intégration seulement"""
    db = Database()
    db.connect()
    yield db
    db.disconnect()

"""
[IDEE] SCOPING

Tests unit/ ont accès à :
- mock_database (leur conftest.py)
- base_url (conftest.py parent)

Tests integration/ ont accès à :
- real_database (leur conftest.py)
- base_url (conftest.py parent)


[ATTENTION] RÈGLES IMPORTANTES conftest.py

1. NOM EXACT : conftest.py (pas de test_ devant !)
2. PAS D'IMPORT : Pytest charge automatiquement
3. FIXTURES SEULEMENT : Pas de tests dedans
4. UN PAR DOSSIER : Maximum un conftest.py par niveau
"""


# ----------------------------------------------------------------------------
# [LABEL] REGROUPER TESTS AVEC CLASSES
# ----------------------------------------------------------------------------

"""
POURQUOI UTILISER DES CLASSES ?

[OK] Organisation logique
[OK] Partage setup/teardown
[OK] Namespace clair
[OK] Groupement de tests liés


EXEMPLE BASIQUE
"""

# tests/test_calculator.py
class TestAddition:
    """Tests pour addition"""
    
    def test_positive_numbers(self):
        assert add(2, 3) == 5
    
    def test_negative_numbers(self):
        assert add(-1, -1) == -2
    
    def test_zero(self):
        assert add(5, 0) == 5
    
    def test_floats(self):
        assert add(0.1, 0.2) == pytest.approx(0.3)

class TestSubtraction:
    """Tests pour soustraction"""
    
    def test_positive_numbers(self):
        assert subtract(5, 3) == 2
    
    def test_negative_numbers(self):
        assert subtract(-1, -1) == 0

"""
[IDEE] AVANTAGES

1. ORGANISATION
   Tests groupés logiquement
   
2. LISIBILITÉ
   Clair quelle fonctionnalité est testée
   
3. EXÉCUTION SÉLECTIVE
   Facile de lancer une classe spécifique


PARTAGER SETUP AVEC FIXTURES DE CLASSE
"""

class TestUser:
    """Tests pour User"""
    
    @pytest.fixture
    def user(self):
        """Fixture disponible pour tests de cette classe"""
        return User("Alice", 30)
    
    def test_user_name(self, user):
        assert user.name == "Alice"
    
    def test_user_age(self, user):
        assert user.age == 30
    
    def test_user_is_adult(self, user):
        assert user.is_adult() is True

"""
SETUP ET TEARDOWN DE CLASSE
"""

class TestDatabase:
    """Tests avec setup/teardown"""
    
    def setup_method(self):
        """Exécuté AVANT chaque test"""
        self.db = Database()
        self.db.connect()
    
    def teardown_method(self):
        """Exécuté APRÈS chaque test"""
        self.db.disconnect()
    
    def test_insert(self):
        self.db.insert("data")
        assert self.db.count() == 1
    
    def test_delete(self):
        self.db.insert("data")
        self.db.delete("data")
        assert self.db.count() == 0

"""
[IDEE] MÉTHODES SPÉCIALES

setup_method(self)      -> Avant chaque test
teardown_method(self)   -> Après chaque test
setup_class(cls)        -> Avant tous les tests de la classe
teardown_class(cls)     -> Après tous les tests de la classe


EXEMPLE COMPLET AVEC LIFECYCLE
"""

class TestLifecycle:
    
    @classmethod
    def setup_class(cls):
        """Une fois avant tous les tests"""
        print("\n=== Setup Class ===")
        cls.shared_resource = "shared"
    
    @classmethod
    def teardown_class(cls):
        """Une fois après tous les tests"""
        print("\n=== Teardown Class ===")
        cls.shared_resource = None
    
    def setup_method(self):
        """Avant chaque test"""
        print("\n--- Setup Method ---")
        self.test_data = []
    
    def teardown_method(self):
        """Après chaque test"""
        print("\n--- Teardown Method ---")
        self.test_data = None
    
    def test_first(self):
        print("Test 1")
        self.test_data.append(1)
        assert len(self.test_data) == 1
    
    def test_second(self):
        print("Test 2")
        self.test_data.append(2)
        assert len(self.test_data) == 1  # Nouvelle instance !

"""
Exécution avec pytest -s -v :

=== Setup Class ===
--- Setup Method ---
Test 1
--- Teardown Method ---
--- Setup Method ---
Test 2
--- Teardown Method ---
=== Teardown Class ===


HÉRITAGE DE CLASSES DE TESTS
"""

class BaseTestCase:
    """Classe de base pour tests"""
    
    @pytest.fixture
    def base_fixture(self):
        return "base"

class TestFeatureA(BaseTestCase):
    """Tests Feature A héritent de BaseTestCase"""
    
    def test_with_base_fixture(self, base_fixture):
        assert base_fixture == "base"

class TestFeatureB(BaseTestCase):
    """Tests Feature B héritent aussi"""
    
    def test_another(self, base_fixture):
        assert base_fixture == "base"


# ----------------------------------------------------------------------------
# [NOMBRE] TESTS PARAMÉTRÉS (INTRODUCTION)
# ----------------------------------------------------------------------------

"""
PROBLÈME : TESTS RÉPÉTITIFS

Sans paramétrage :
"""

def test_add_2_3():
    assert add(2, 3) == 5

def test_add_5_7():
    assert add(5, 7) == 12

def test_add_10_20():
    assert add(10, 20) == 30

def test_add_negative():
    assert add(-1, -1) == -2

"""
[X] Code dupliqué !


[OK] SOLUTION : @pytest.mark.parametrize

Permet d'exécuter le MÊME test avec différentes données
"""

import pytest

@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (5, 7, 12),
    (10, 20, 30),
    (-1, -1, -2),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

"""
[IDEE] DÉCRYPTAGE

@pytest.mark.parametrize("a, b, expected", [...])
                         │         │
                         │         └─ Valeurs (liste de tuples)
                         └─────────── Paramètres (séparés par virgules)


Pytest exécute le test 4 FOIS :
- Test 1 : a=2, b=3, expected=5
- Test 2 : a=5, b=7, expected=12
- Test 3 : a=10, b=20, expected=30
- Test 4 : a=-1, b=-1, expected=-2


OUTPUT PYTEST :
"""
"""
test_calculator.py::test_add[2-3-5] PASSED       [ 25%]
test_calculator.py::test_add[5-7-12] PASSED      [ 50%]
test_calculator.py::test_add[10-20-30] PASSED    [ 75%]
test_calculator.py::test_add[-1--1--2] PASSED    [100%]
"""

"""
[IDEE] Chaque combinaison = Un test séparé !


PARAMÉTRER UN SEUL ARGUMENT
"""

@pytest.mark.parametrize("number", [1, 2, 3, 4, 5])
def test_is_positive(number):
    assert number > 0

"""
PARAMÉTRER AVEC IDS PERSONNALISÉS
"""

@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (5, 7, 12),
    (10, 20, 30),
], ids=["small_numbers", "medium_numbers", "large_numbers"])
def test_add_with_ids(a, b, expected):
    assert add(a, b) == expected

"""
Output :
test_add_with_ids[small_numbers] PASSED
test_add_with_ids[medium_numbers] PASSED
test_add_with_ids[large_numbers] PASSED


EXEMPLES PRATIQUES
"""

# Test validation email
@pytest.mark.parametrize("email", [
    "user@example.com",
    "test.user@domain.co.uk",
    "user+tag@test.org",
])
def test_valid_emails(email):
    assert validate_email(email) is True

@pytest.mark.parametrize("email", [
    "invalid",
    "@example.com",
    "user@",
    "user..test@example.com",
])
def test_invalid_emails(email):
    with pytest.raises(ValidationError):
        validate_email(email)

# Test calculs mathématiques
@pytest.mark.parametrize("x, expected", [
    (0, 0),
    (1, 1),
    (2, 4),
    (3, 9),
    (-2, 4),
])
def test_square(x, expected):
    assert square(x) == expected

"""
[IDEE] Nous verrons parametrize en DÉTAIL au Chapitre 9


COMBINER AVEC CLASSES
"""

class TestCalculator:
    
    @pytest.mark.parametrize("a, b, expected", [
        (2, 3, 5),
        (10, 20, 30),
    ])
    def test_add(self, a, b, expected):
        assert add(a, b) == expected
    
    @pytest.mark.parametrize("a, b, expected", [
        (5, 3, 2),
        (10, 7, 3),
    ])
    def test_subtract(self, a, b, expected):
        assert subtract(a, b) == expected


# ----------------------------------------------------------------------------
# [LABEL] MARKERS : CATÉGORISER LES TESTS
# ----------------------------------------------------------------------------

"""
MARKERS = ÉTIQUETTES POUR TESTS

Permettent de :
- Catégoriser tests
- Exécuter sélectivement
- Sauter conditionnellement
- Marquer échecs attendus


MARKERS BUILT-IN PYTEST
"""

# 1. @pytest.mark.skip - Sauter un test
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature():
    assert future_function() == expected

# 2. @pytest.mark.skipif - Sauter conditionnellement
import sys

@pytest.mark.skipif(sys.platform == "win32", reason="Unix only")
def test_unix_feature():
    assert unix_function() == expected

# 3. @pytest.mark.xfail - Échec attendu
@pytest.mark.xfail(reason="Known bug #123")
def test_known_bug():
    assert buggy_function() == expected

# 4. @pytest.mark.parametrize - Déjà vu
@pytest.mark.parametrize("x", [1, 2, 3])
def test_parametrized(x):
    assert x > 0

"""
CRÉER MARKERS PERSONNALISÉS
"""

# pytest.ini
"""
[pytest]
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    integration: marks tests as integration tests
    unit: marks tests as unit tests
    smoke: marks tests as smoke tests
"""

# Utilisation
@pytest.mark.slow
def test_slow_operation():
    """Test lent"""
    import time
    time.sleep(2)
    assert True

@pytest.mark.integration
def test_database_integration():
    """Test d'intégration DB"""
    assert db.connect()

@pytest.mark.unit
def test_simple_function():
    """Test unitaire rapide"""
    assert add(2, 3) == 5

"""
EXÉCUTER SÉLECTIVEMENT PAR MARKER
"""

# Seulement tests unit
pytest -m unit

# Seulement tests integration
pytest -m integration

# Tout SAUF slow
pytest -m "not slow"

# unit OU integration
pytest -m "unit or integration"

# unit ET smoke
pytest -m "unit and smoke"

"""
COMBINER PLUSIEURS MARKERS
"""

@pytest.mark.slow
@pytest.mark.integration
def test_slow_integration():
    """Test lent d'intégration"""
    assert True

"""
MARKERS AVEC RAISONS
"""

@pytest.mark.skip(reason="API not available")
def test_api_call():
    assert call_api() == expected

@pytest.mark.xfail(reason="Bug #456 - Fix in progress")
def test_calculation():
    assert complex_calc() == expected

"""
[IDEE] SKIP vs XFAIL

skip  -> "Je ne peux pas tester maintenant"
        Raisons : Dépendance manquante, plateforme, etc.

xfail -> "Je sais que ça va échouer"
        Raisons : Bug connu, feature pas finie


MARKER CONDITIONNEL PERSONNALISÉ
"""

# conftest.py
import pytest

def pytest_configure(config):
    """Enregistrer markers personnalisés"""
    config.addinivalue_line(
        "markers", "slow: mark test as slow"
    )
    config.addinivalue_line(
        "markers", "requires_db: mark test as requiring database"
    )

@pytest.fixture
def skip_if_no_db(request):
    """Skip si pas de DB"""
    if not has_database():
        pytest.skip("Database not available")

# Utilisation
@pytest.mark.requires_db
def test_with_db(skip_if_no_db):
    assert query_db() == expected


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 3 : ORGANISATION COMPLÈTE
# ----------------------------------------------------------------------------

"""
OBJECTIF : Organiser un projet de test complet


ÉTAPE 1 : STRUCTURE DU PROJET
"""

math_project/
├── src/
│   ├── __init__.py
│   ├── calculator.py
│   ├── geometry.py
│   └── statistics.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py
│   ├── test_calculator.py
│   ├── test_geometry.py
│   └── test_statistics.py
├── pytest.ini
└── requirements.txt

"""
ÉTAPE 2 : CODE SOURCE
"""

# src/calculator.py
def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

def multiply(a, b):
    return a * b

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

# src/geometry.py
import math

class Circle:
    def __init__(self, radius):
        if radius <= 0:
            raise ValueError("Radius must be positive")
        self.radius = radius
    
    def area(self):
        return math.pi * self.radius ** 2
    
    def circumference(self):
        return 2 * math.pi * self.radius

class Rectangle:
    def __init__(self, width, height):
        if width <= 0 or height <= 0:
            raise ValueError("Dimensions must be positive")
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height
    
    def perimeter(self):
        return 2 * (self.width + self.height)

# src/statistics.py
def mean(numbers):
    if not numbers:
        raise ValueError("Cannot calculate mean of empty list")
    return sum(numbers) / len(numbers)

def median(numbers):
    if not numbers:
        raise ValueError("Cannot calculate median of empty list")
    sorted_numbers = sorted(numbers)
    n = len(sorted_numbers)
    if n % 2 == 0:
        return (sorted_numbers[n//2 - 1] + sorted_numbers[n//2]) / 2
    else:
        return sorted_numbers[n//2]

"""
ÉTAPE 3 : CONFIGURATION PYTEST
"""

# pytest.ini
"""
[pytest]
minversion = 7.0
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

markers =
    slow: marks tests as slow
    unit: marks tests as unit tests
    integration: marks tests as integration tests
    smoke: marks tests as smoke tests
    
addopts = 
    -v
    --strict-markers
    --tb=short
"""

"""
ÉTAPE 4 : FIXTURES PARTAGÉES
"""

# tests/conftest.py
import pytest
import math

@pytest.fixture
def sample_numbers():
    """Liste de nombres pour tests stats"""
    return [1, 2, 3, 4, 5]

@pytest.fixture
def circle():
    """Circle avec radius 5"""
    from src.geometry import Circle
    return Circle(5)

@pytest.fixture
def rectangle():
    """Rectangle 4x6"""
    from src.geometry import Rectangle
    return Rectangle(4, 6)

"""
ÉTAPE 5 : TESTS CALCULATOR
"""

# tests/test_calculator.py
import pytest
from src.calculator import add, subtract, multiply, divide

class TestAddition:
    """Tests pour addition"""
    
    @pytest.mark.unit
    @pytest.mark.parametrize("a, b, expected", [
        (2, 3, 5),
        (0, 0, 0),
        (-1, 1, 0),
        (-5, -3, -8),
    ])
    def test_add(self, a, b, expected):
        assert add(a, b) == expected

class TestSubtraction:
    """Tests pour soustraction"""
    
    @pytest.mark.unit
    @pytest.mark.parametrize("a, b, expected", [
        (5, 3, 2),
        (0, 0, 0),
        (1, -1, 2),
    ])
    def test_subtract(self, a, b, expected):
        assert subtract(a, b) == expected

class TestMultiplication:
    """Tests pour multiplication"""
    
    @pytest.mark.unit
    @pytest.mark.smoke
    def test_multiply_positive(self):
        assert multiply(3, 4) == 12
    
    @pytest.mark.unit
    def test_multiply_by_zero(self):
        assert multiply(5, 0) == 0

class TestDivision:
    """Tests pour division"""
    
    @pytest.mark.unit
    def test_divide_normal(self):
        assert divide(10, 2) == 5.0
    
    @pytest.mark.unit
    def test_divide_by_zero(self):
        with pytest.raises(ValueError, match="Cannot divide by zero"):
            divide(10, 0)

"""
ÉTAPE 6 : TESTS GEOMETRY
"""

# tests/test_geometry.py
import pytest
import math
from src.geometry import Circle, Rectangle

class TestCircle:
    """Tests pour Circle"""
    
    @pytest.mark.unit
    def test_circle_creation(self):
        circle = Circle(5)
        assert circle.radius == 5
    
    @pytest.mark.unit
    def test_circle_invalid_radius(self):
        with pytest.raises(ValueError, match="Radius must be positive"):
            Circle(-1)
        
        with pytest.raises(ValueError):
            Circle(0)
    
    @pytest.mark.unit
    def test_circle_area(self, circle):
        expected = math.pi * 25
        assert circle.area() == pytest.approx(expected)
    
    @pytest.mark.unit
    def test_circle_circumference(self, circle):
        expected = 2 * math.pi * 5
        assert circle.circumference() == pytest.approx(expected)

class TestRectangle:
    """Tests pour Rectangle"""
    
    @pytest.mark.unit
    def test_rectangle_creation(self):
        rect = Rectangle(4, 6)
        assert rect.width == 4
        assert rect.height == 6
    
    @pytest.mark.unit
    @pytest.mark.parametrize("width, height", [
        (-1, 5),
        (5, -1),
        (0, 5),
        (5, 0),
    ])
    def test_rectangle_invalid_dimensions(self, width, height):
        with pytest.raises(ValueError, match="Dimensions must be positive"):
            Rectangle(width, height)
    
    @pytest.mark.unit
    def test_rectangle_area(self, rectangle):
        assert rectangle.area() == 24
    
    @pytest.mark.unit
    def test_rectangle_perimeter(self, rectangle):
        assert rectangle.perimeter() == 20

"""
ÉTAPE 7 : TESTS STATISTICS
"""

# tests/test_statistics.py
import pytest
from src.statistics import mean, median

class TestMean:
    """Tests pour mean"""
    
    @pytest.mark.unit
    def test_mean_normal(self, sample_numbers):
        assert mean(sample_numbers) == 3.0
    
    @pytest.mark.unit
    @pytest.mark.parametrize("numbers, expected", [
        ([1, 2, 3], 2.0),
        ([10, 20, 30, 40], 25.0),
        ([5], 5.0),
    ])
    def test_mean_various(self, numbers, expected):
        assert mean(numbers) == expected
    
    @pytest.mark.unit
    def test_mean_empty_list(self):
        with pytest.raises(ValueError, match="Cannot calculate mean"):
            mean([])

class TestMedian:
    """Tests pour median"""
    
    @pytest.mark.unit
    def test_median_odd_count(self):
        assert median([1, 2, 3, 4, 5]) == 3
    
    @pytest.mark.unit
    def test_median_even_count(self):
        assert median([1, 2, 3, 4]) == 2.5
    
    @pytest.mark.unit
    def test_median_unsorted(self):
        assert median([5, 1, 3, 2, 4]) == 3
    
    @pytest.mark.unit
    def test_median_empty_list(self):
        with pytest.raises(ValueError, match="Cannot calculate median"):
            median([])

"""
ÉTAPE 8 : EXÉCUTION
"""

# Tous les tests
pytest

# Seulement tests unit
pytest -m unit

# Seulement tests smoke
pytest -m smoke

# Tests d'un fichier
pytest tests/test_calculator.py

# Tests d'une classe
pytest tests/test_geometry.py::TestCircle

# Test spécifique
pytest tests/test_calculator.py::TestDivision::test_divide_by_zero

# Avec coverage
pytest --cov=src

# Parallèle (si pytest-xdist installé)
pytest -n auto


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 3
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Structures de projet (petit, moyen, grand)
[OK] Conventions de nommage pytest
[OK] Découverte automatique des tests
[OK] conftest.py et hiérarchie
[OK] Classes de tests
[OK] Tests paramétrés (introduction)
[OK] Markers pour catégoriser
[OK] Organisation complète d'un projet


[CLE] POINTS CLÉS

1. Structure miroir : tests/ reflète src/
2. Nommage : test_*.py, test_*(), Test*
3. conftest.py : Fixtures partagées par niveau
4. Classes : Groupement logique avec Test*
5. @pytest.mark.parametrize : Éviter duplication
6. Markers : Catégoriser et exécuter sélectivement


[OBJECTIF] PATTERNS IMPORTANTS

# Structure projet moyen
src/
tests/
  conftest.py
  test_*.py

# Fixture partagée
# tests/conftest.py
@pytest.fixture
def fixture_name():
    return value

# Test paramétré
@pytest.mark.parametrize("input, expected", [...])
def test_function(input, expected):
    assert function(input) == expected

# Marker personnalisé
@pytest.mark.slow
def test_slow():
    pass

# Exécution sélective
pytest -m "unit and not slow"


-> PROCHAINE ÉTAPE : Chapitre 4 - Exécution et Configuration

Vous allez apprendre :
- Options CLI avancées
- pytest.ini en profondeur
- Configuration par environnement
- Plugins pytest
- Rapports personnalisés
- Intégration IDE

Le guide continue ! [RAPIDE]
"""

Voulez-vous que je continue avec le **Chapitre 4 : Exécution et Configuration** pour terminer la Partie 1, ou préférez-vous passer directement à la **Partie 2 sur les Fixtures** qui est un sujet crucial dans pytest ?

Je continue avec le Chapitre 4 sur l'exécution et la configuration de pytest de manière ultra-détaillée :

# ============================================================================
# [GUIDE] CHAPITRE 4 : EXÉCUTION ET CONFIGURATION
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Maîtriser toutes les options CLI pytest
[OK] Configurer pytest.ini en profondeur
[OK] Utiliser pyproject.toml et setup.cfg
[OK] Options de sortie et rapports
[OK] Contrôler l'exécution des tests
[OK] Configurer par environnement
[OK] Plugins pytest essentiels
[OK] Intégration avec IDE
"""


# ----------------------------------------------------------------------------
# [ECRAN] OPTIONS CLI : GUIDE COMPLET
# ----------------------------------------------------------------------------

"""
COMMANDE DE BASE
"""

pytest [options] [fichiers ou dossiers]

"""
[IDEE] Si aucun argument : pytest cherche dans le dossier courant


OPTIONS DE SÉLECTION DE TESTS
----------------------------
"""

# Exécuter tous les tests
pytest

# Tests d'un fichier
pytest tests/test_calculator.py

# Tests d'un dossier
pytest tests/unit/

# Test spécifique (fonction)
pytest tests/test_calculator.py::test_add

# Test spécifique (classe::méthode)
pytest tests/test_calculator.py::TestCalculator::test_add

# Plusieurs fichiers
pytest tests/test_calculator.py tests/test_user.py

# Par pattern
pytest tests/test_calc*.py

"""
OPTIONS DE VERBOSITÉ
-------------------
"""

# Silencieux (minimal)
pytest -q
pytest --quiet

"""
Output :
.....                                                            [100%]
5 passed in 0.01s
"""

# Verbeux (détaillé)
pytest -v
pytest --verbose

"""
Output :
tests/test_calculator.py::test_add PASSED                       [ 20%]
tests/test_calculator.py::test_subtract PASSED                  [ 40%]
tests/test_calculator.py::test_multiply PASSED                  [ 60%]
tests/test_calculator.py::test_divide PASSED                    [ 80%]
tests/test_calculator.py::test_divide_by_zero PASSED           [100%]
"""

# Très verbeux (maximum détails)
pytest -vv
pytest --verbose --verbose

"""
Output inclut :
- Paramètres des tests paramétrés
- Valeurs des assertions
- Détails complets
"""

"""
OPTIONS D'AFFICHAGE
------------------
"""

# Afficher print() même si test passe
pytest -s
pytest --capture=no

"""
[IDEE] Par défaut, pytest capture stdout/stderr
-s désactive la capture -> print() visible
"""

# Exemple
def test_with_print():
    print("Début du test")
    assert 2 + 2 == 4
    print("Test réussi")

# Avec pytest : print() caché
# Avec pytest -s : print() visible

# Afficher variables locales dans traceback
pytest -l
pytest --showlocals

"""
Output en cas d'échec :
    def test_failure():
        x = 10
        y = 20
>       assert x == y
E       assert 10 == 20

x          = 10     <- Variables locales affichées !
y          = 20
"""

# Traceback détaillé
pytest --tb=long      # Long (défaut)
pytest --tb=short     # Court
pytest --tb=line      # Une ligne
pytest --tb=native    # Python natif
pytest --tb=no        # Pas de traceback

"""
Exemples de formats traceback :
"""

# --tb=long (défaut)
"""
_____________ test_divide_by_zero _____________

    def test_divide_by_zero():
>       assert divide(10, 0) == 5
E       ValueError: Cannot divide by zero

tests/test_calculator.py:15: ValueError
"""

# --tb=short
"""
tests/test_calculator.py:15: ValueError
"""

# --tb=line
"""
tests/test_calculator.py:15: assert divide(10, 0) == 5
"""

"""
OPTIONS DE CONTRÔLE D'EXÉCUTION
------------------------------
"""

# Arrêter au premier échec
pytest -x
pytest --exitfirst

# Arrêter après N échecs
pytest --maxfail=3

# Lancer tests en parallèle (nécessite pytest-xdist)
pip install pytest-xdist
pytest -n auto          # Auto-détection CPU
pytest -n 4             # 4 workers

# Ordre aléatoire (nécessite pytest-randomly)
pip install pytest-randomly
pytest  # Ordre aléatoire par défaut après install

# Désactiver ordre aléatoire
pytest -p no:randomly

# Dernier test échoué en premier
pytest --failed-first
pytest --ff

# Seulement tests échoués
pytest --last-failed
pytest --lf

# Nouveau tests en premier
pytest --new-first
pytest --nf

"""
[IDEE] OPTIONS DE RÉEXÉCUTION

--lf : Seulement tests qui ont échoué
--ff : Tests échoués d'abord, puis les autres
--nf : Nouveaux tests d'abord


EXEMPLE DE WORKFLOW :
"""

# 1. Lancer tous les tests
pytest

# 2. Des tests échouent
# Relancer seulement les échecs
pytest --lf

# 3. Corriger et relancer échecs en premier
pytest --ff

"""
OPTIONS DE SÉLECTION PAR MARKERS
--------------------------------
"""

# Tests avec marker spécifique
pytest -m unit
pytest -m slow
pytest -m integration

# Expressions complexes
pytest -m "unit and not slow"
pytest -m "integration or slow"
pytest -m "unit and smoke"

# Tout sauf un marker
pytest -m "not slow"

"""
OPTIONS DE SÉLECTION PAR KEYWORD
--------------------------------
"""

# Tests contenant "add" dans le nom
pytest -k add

# Tests contenant "add" OU "subtract"
pytest -k "add or subtract"

# Tests contenant "user" mais PAS "delete"
pytest -k "user and not delete"

# Exemples
pytest -k test_add                    # test_add, test_add_positive, etc.
pytest -k "TestUser"                  # Toute la classe TestUser
pytest -k "test_create or test_update"  # Création ou mise à jour

"""
OPTIONS DE WARNINGS
------------------
"""

# Afficher warnings
pytest -W default

# Warnings comme erreurs
pytest -W error

# Ignorer warnings
pytest -W ignore

# Warnings spécifiques
pytest -W ignore::DeprecationWarning

"""
OPTIONS DE CACHE
---------------
"""

# Afficher cache
pytest --cache-show

# Nettoyer cache
pytest --cache-clear

# Désactiver cache
pytest -p no:cacheprovider

"""
[IDEE] CACHE PYTEST

Pytest garde en cache :
- Tests échoués (pour --lf, --ff)
- Valeurs de fixtures
- Métadonnées

Cache dans : .pytest_cache/


OPTIONS DE COLLECTION
---------------------
"""

# Collecter seulement (ne pas exécuter)
pytest --collect-only

"""
Output :
<Module test_calculator.py>
  <Function test_add>
  <Function test_subtract>
  <Class TestDivision>
    <Function test_divide>
    <Function test_divide_by_zero>
collected 4 items
"""

# Désactiver découverte récursive
pytest --ignore=tests/integration/

# Pattern d'exclusion
pytest --ignore-glob="*_slow.py"

"""
OPTIONS DE DURÉE
---------------
"""

# Afficher durée de chaque test
pytest --durations=10      # Top 10 plus lents

"""
Output :
========= slowest 10 durations =========
2.51s call     tests/test_slow.py::test_api
1.23s call     tests/test_slow.py::test_database
0.85s call     tests/test_slow.py::test_file_io
...
"""

# Afficher tous
pytest --durations=0

# Minimum de temps
pytest --durations-min=1.0  # Seulement si > 1s

"""
OPTIONS DE COUVERTURE (avec pytest-cov)
--------------------------------------
"""

pip install pytest-cov

# Coverage basique
pytest --cov=src

# Coverage avec rapport détaillé
pytest --cov=src --cov-report=term-missing

"""
Output :
Name                 Stmts   Miss  Cover   Missing
--------------------------------------------------
src/calculator.py       15      2    87%   45-46
src/user.py            25      0   100%
--------------------------------------------------
TOTAL                  40      2    95%
"""

# Rapport HTML
pytest --cov=src --cov-report=html

# Ouvre htmlcov/index.html dans navigateur

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

# Minimum coverage requis
pytest --cov=src --cov-fail-under=80

"""
[IDEE] Si coverage < 80%, pytest échoue


OPTIONS DE DEBUGGING
-------------------
"""

# Debugger au premier échec
pytest --pdb

"""
Ouvre pdb au point d'échec :
> /path/to/test.py(15)test_divide_by_zero()
-> assert divide(10, 0) == 5
(Pdb) 
"""

# Debugger au début de chaque test
pytest --trace

# Debugger seulement sur échecs
pytest --pdb --pdbcls=IPython.terminal.debugger:Pdb

"""
OPTIONS DIVERSES
---------------
"""

# Version de pytest
pytest --version

# Aide
pytest --help
pytest -h

# Strict markers (erreur si marker inconnu)
pytest --strict-markers

# Strict config (erreur si option inconnue)
pytest --strict-config

# Désactiver plugins
pytest -p no:warnings
pytest -p no:cacheprovider

# Résumé court à la fin
pytest --tb=no --no-header -q

# Timestamp des tests
pytest --timestamp


# ----------------------------------------------------------------------------
# [CONFIG] FICHIER pytest.ini : CONFIGURATION COMPLÈTE
# ----------------------------------------------------------------------------

"""
pytest.ini = FICHIER DE CONFIGURATION PRINCIPAL

[IMPORTANT] Où le placer ?
À la RACINE du projet (même niveau que tests/)


EXEMPLE COMPLET COMMENTÉ
"""

# pytest.ini
"""
[pytest]

# ─────────────────────────────────
# VERSION MINIMALE
# ─────────────────────────────────
minversion = 7.0

# ─────────────────────────────────
# CHEMINS ET PATTERNS
# ─────────────────────────────────

# Dossiers où chercher les tests
testpaths = tests

# Patterns fichiers de tests
python_files = test_*.py *_test.py

# Patterns classes de tests
python_classes = Test* *Tests

# Patterns fonctions de tests
python_functions = test_* check_*

# Ignorer certains dossiers
norecursedirs = .git .venv __pycache__ *.egg-info

# ─────────────────────────────────
# MARKERS
# ─────────────────────────────────
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    integration: marks tests requiring external resources
    unit: marks tests as unit tests
    smoke: marks tests as smoke tests
    regression: marks tests for regression testing
    wip: marks tests as work in progress
    skip_ci: marks tests to skip in CI

# ─────────────────────────────────
# OPTIONS PAR DÉFAUT
# ─────────────────────────────────
addopts = 
    -v
    --strict-markers
    --strict-config
    --tb=short
    --durations=5
    -ra

# ─────────────────────────────────
# WARNINGS
# ─────────────────────────────────
filterwarnings =
    error
    ignore::DeprecationWarning
    ignore::PendingDeprecationWarning

# ─────────────────────────────────
# LOGGING
# ─────────────────────────────────
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S

log_file = tests.log
log_file_level = DEBUG
log_file_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
log_file_date_format = %Y-%m-%d %H:%M:%S

# ─────────────────────────────────
# TIMEOUT (nécessite pytest-timeout)
# ─────────────────────────────────
timeout = 300
timeout_method = thread

# ─────────────────────────────────
# PYTEST-COV
# ─────────────────────────────────
# Ces options vont dans .coveragerc ou pyproject.toml

# ─────────────────────────────────
# DÉCOUVERTE
# ─────────────────────────────────
# Dossier racine pour imports
pythonpath = src

# Console encoding
console_output_style = progress
"""

"""
[IDEE] EXPLICATION DES OPTIONS addopts

-v                  -> Verbeux
--strict-markers    -> Erreur si marker non déclaré
--strict-config     -> Erreur si option invalide
--tb=short          -> Traceback court
--durations=5       -> Top 5 tests les plus lents
-ra                 -> Résumé de tous sauf passed


OPTIONS addopts UTILES
"""

addopts = 
    -v                          # Verbeux
    -s                          # Afficher print()
    --tb=short                  # Traceback court
    --strict-markers            # Strict markers
    --strict-config             # Strict config
    --durations=10              # Top 10 lents
    --maxfail=3                 # Stop après 3 échecs
    -ra                         # Résumé all
    --showlocals                # Variables locales
    --color=yes                 # Couleurs forcées

"""
[IDEE] OPTIONS DE RÉSUMÉ (-r)

-ra : All (tous)
-rA : All avec passed
-rf : Failed
-rE : Error
-rs : Skipped
-rx : xfailed
-rX : xpassed
-rp : Passed
-rP : Passed avec output

Combiner : -rfs (failed + skipped)


MARKERS : DÉCLARATION DÉTAILLÉE
"""

markers =
    # Catégories de tests
    unit: Unit tests (fast, isolated)
    integration: Integration tests (slower, external deps)
    e2e: End-to-end tests (slowest, full stack)
    
    # Vitesse
    slow: Tests taking more than 1 second
    fast: Tests taking less than 0.1 second
    
    # Environnement
    requires_db: Tests requiring database
    requires_network: Tests requiring network access
    requires_docker: Tests requiring Docker
    
    # Statut
    wip: Work in progress
    skip_ci: Skip in continuous integration
    flaky: Tests that sometimes fail
    
    # Domaines
    auth: Authentication tests
    api: API tests
    ui: User interface tests
    security: Security tests

"""
[IDEE] DOCUMENTATION DES MARKERS

Toujours documenter vos markers :
- Ce qu'ils signifient
- Comment les utiliser
- Pourquoi les utiliser


FILTRES WARNINGS DÉTAILLÉS
"""

filterwarnings =
    # Traiter warnings comme erreurs (strict)
    error
    
    # Ignorer warnings spécifiques
    ignore::DeprecationWarning
    ignore::PendingDeprecationWarning
    ignore::ImportWarning
    
    # Ignorer warnings de modules spécifiques
    ignore::DeprecationWarning:urllib3.*
    ignore::FutureWarning:sqlalchemy.*
    
    # Ignorer message spécifique
    ignore:.*datetime.*:DeprecationWarning
    
    # Seulement certains warnings en erreur
    error::ResourceWarning
    error::RuntimeWarning

"""
[IDEE] SYNTAXE FILTRE

action:message:category:module:line

action : error, ignore, always, default, module, once
message : Regex du message
category : Type de warning
module : Module concerné
line : Numéro de ligne


LOGGING : CONFIGURATION COMPLÈTE
"""

# Logging en CLI pendant les tests
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S

# Logging dans fichier
log_file = tests.log
log_file_level = DEBUG
log_file_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
log_file_date_format = %Y-%m-%d %H:%M:%S

# Niveau par logger
log_level = INFO
log_level_sqlalchemy = WARNING
log_level_urllib3 = ERROR

"""
Utilisation dans test :
"""

import logging

def test_with_logging():
    logger = logging.getLogger(__name__)
    logger.info("Test started")
    logger.debug("Debug info")
    assert True
    logger.info("Test passed")

"""
Output en CLI :
2024-01-15 10:30:45 [    INFO] Test started
2024-01-15 10:30:45 [    INFO] Test passed


# ----------------------------------------------------------------------------
# [FICHIER] ALTERNATIVES À pytest.ini
# ----------------------------------------------------------------------------

"""
PYPROJECT.TOML (Moderne, Recommandé)
------------------------------------
"""

# pyproject.toml
"""
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "mon_projet"
version = "1.0.0"

[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]

markers = [
    "slow: marks tests as slow",
    "integration: marks tests as integration tests",
    "unit: marks tests as unit tests",
]

addopts = [
    "-v",
    "--strict-markers",
    "--strict-config",
    "--tb=short",
]

filterwarnings = [
    "error",
    "ignore::DeprecationWarning",
]

log_cli = true
log_cli_level = "INFO"

[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/venv/*"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
]
"""

"""
[OK] AVANTAGES pyproject.toml

- Format moderne (PEP 518)
- Centralise toute la config (pytest, coverage, black, mypy)
- Compatible Python packaging


SETUP.CFG (Legacy)
------------------
"""

# setup.cfg
"""
[metadata]
name = mon_projet
version = 1.0.0

[options]
packages = find:
python_requires = >=3.8

[tool:pytest]
minversion = 7.0
testpaths = tests
python_files = test_*.py *_test.py
python_classes = Test*
python_functions = test_*

markers =
    slow: marks tests as slow
    integration: marks tests as integration tests

addopts = 
    -v
    --strict-markers
    --tb=short

[coverage:run]
source = src
omit = tests/*
"""

"""
[IDEE] ORDRE DE PRIORITÉ

1. pytest.ini (spécifique pytest)
2. pyproject.toml (moderne)
3. setup.cfg (legacy)
4. tox.ini (si utilise tox)

Si plusieurs existent, pytest.ini gagne.


# ----------------------------------------------------------------------------
# [MONDE] CONFIGURATION PAR ENVIRONNEMENT
# ----------------------------------------------------------------------------

"""
PROBLÈME : Configuration différente selon environnement

Dev : Tests rapides, verbose
CI : Tous les tests, parallèle, coverage


SOLUTION 1 : Variables d'environnement
"""

# conftest.py
import os
import pytest

def pytest_configure(config):
    """Configuration dynamique"""
    if os.getenv("CI"):
        # En CI : strict et complet
        config.option.verbose = 2
        config.option.strict_markers = True
        config.option.maxfail = 1
    else:
        # En dev : plus permissif
        config.option.verbose = 1

"""
SOLUTION 2 : Profils avec ini files
"""

# pytest.ini (dev)
"""
[pytest]
addopts = -v --tb=short
"""

# pytest-ci.ini (CI)
"""
[pytest]
addopts = 
    -v 
    --tb=short 
    --strict-markers 
    --maxfail=1 
    --cov=src 
    --cov-fail-under=80
    -n auto
"""

# Utilisation
pytest -c pytest.ini        # Dev
pytest -c pytest-ci.ini     # CI

"""
SOLUTION 3 : Markers conditionnels
"""

# conftest.py
import pytest
import os

def pytest_configure(config):
    config.addinivalue_line(
        "markers", "no_ci: mark test to skip in CI"
    )

def pytest_collection_modifyitems(config, items):
    """Skip tests marqués no_ci en CI"""
    if os.getenv("CI"):
        skip_ci = pytest.mark.skip(reason="Skipped in CI")
        for item in items:
            if "no_ci" in item.keywords:
                item.add_marker(skip_ci)

"""
Usage :
"""

@pytest.mark.no_ci
def test_interactive():
    """Test qui ne doit pas tourner en CI"""
    user_input = input("Enter value: ")
    assert user_input == "expected"

"""
SOLUTION 4 : Fixtures conditionnelles
"""

# conftest.py
import pytest
import os

@pytest.fixture
def skip_in_ci():
    """Skip le test si en CI"""
    if os.getenv("CI"):
        pytest.skip("Test skipped in CI environment")

# Usage
def test_local_only(skip_in_ci):
    """Test seulement en local"""
    assert True


# ----------------------------------------------------------------------------
# [PLUGIN] PLUGINS PYTEST ESSENTIELS
# ----------------------------------------------------------------------------

"""
PLUGINS INCONTOURNABLES
"""

# 1. pytest-cov : Coverage
pip install pytest-cov

pytest --cov=src --cov-report=html

"""
# 2. pytest-xdist : Tests parallèles
pip install pytest-xdist

pytest -n auto        # Auto CPU count
pytest -n 4           # 4 workers

"""
# 3. pytest-mock : Mocking facilité
pip install pytest-mock

def test_with_mock(mocker):
    mock_func = mocker.patch('module.function')
    mock_func.return_value = 42
    assert module.function() == 42

"""
# 4. pytest-timeout : Timeout tests
pip install pytest-timeout

@pytest.mark.timeout(5)  # 5 secondes max
def test_slow():
    time.sleep(10)  # Timeout !

"""
# 5. pytest-randomly : Ordre aléatoire
pip install pytest-randomly

# Active automatiquement l'ordre aléatoire

"""
# 6. pytest-sugar : Output plus joli
pip install pytest-sugar

# Améliore automatiquement l'output

"""
# 7. pytest-html : Rapports HTML
pip install pytest-html

pytest --html=report.html

"""
# 8. pytest-bdd : Behavior-Driven Development
pip install pytest-bdd

# Tests style Gherkin

"""
# 9. pytest-django : Django testing
pip install pytest-django

# Fixtures Django automatiques

"""
# 10. pytest-asyncio : Tests async
pip install pytest-asyncio

@pytest.mark.asyncio
async def test_async():
    result = await async_function()
    assert result == expected

"""
CONFIGURATION DES PLUGINS
"""

# pytest.ini
"""
[pytest]
# pytest-timeout
timeout = 300
timeout_method = thread

# pytest-xdist
# Pas de config nécessaire

# pytest-cov
# Utiliser .coveragerc ou pyproject.toml

# pytest-randomly
# Seed fixe pour reproductibilité
randomly_seed = 12345
"""

"""
CRÉER UN PLUGIN PERSONNALISÉ
"""

# conftest.py
import pytest

def pytest_addoption(parser):
    """Ajouter option CLI personnalisée"""
    parser.addoption(
        "--env",
        action="store",
        default="dev",
        help="Environment: dev, staging, prod"
    )

@pytest.fixture
def env(request):
    """Fixture pour récupérer --env"""
    return request.config.getoption("--env")

# Usage
pytest --env=staging

def test_with_env(env):
    if env == "prod":
        assert prod_config.is_valid()
    else:
        assert dev_config.is_valid()

"""
HOOK PYTEST PERSONNALISÉ
"""

# conftest.py
def pytest_collection_modifyitems(config, items):
    """Hook pour modifier tests collectés"""
    # Ajouter marker à tous les tests d'un module
    for item in items:
        if "slow" in item.nodeid:
            item.add_marker(pytest.mark.slow)

def pytest_runtest_setup(item):
    """Hook avant chaque test"""
    print(f"\nSetting up {item.name}")

def pytest_runtest_teardown(item):
    """Hook après chaque test"""
    print(f"\nTearing down {item.name}")


# ----------------------------------------------------------------------------
# [ECRAN] INTÉGRATION IDE
# ----------------------------------------------------------------------------

"""
VISUAL STUDIO CODE
------------------
"""

# .vscode/settings.json
"""
{
    "python.testing.pytestEnabled": true,
    "python.testing.unittestEnabled": false,
    "python.testing.pytestArgs": [
        "tests",
        "-v",
        "--tb=short"
    ],
    "python.testing.autoTestDiscoverOnSaveEnabled": true
}
"""

"""
[OK] Fonctionnalités VSCode

- Découverte auto des tests
- Icônes play/debug sur tests
- Vue arborescente des tests
- Exécution inline
- Debugging intégré


PYCHARM
-------

Settings -> Tools -> Python Integrated Tools
- Default test runner: pytest
- pytest options: -v --tb=short

[OK] Fonctionnalités PyCharm

- Run/Debug configurations
- Icônes gutter
- Coverage intégrée
- Refactoring tests
- Template tests


SUBLIME TEXT
------------
"""

# pytest.sublime-build
"""
{
    "shell_cmd": "pytest -v",
    "working_dir": "${project_path}",
    "selector": "source.python"
}
"""

"""
VIM/NEOVIM
----------

Plugins :
- vim-test
- pytest.vim

Commandes :
:TestNearest  -> Test sous curseur
:TestFile     -> Fichier courant
:TestSuite    -> Tous les tests


# ----------------------------------------------------------------------------
# [GRAPHIQUE] RAPPORTS ET OUTPUTS
# ----------------------------------------------------------------------------

"""
FORMATS DE RAPPORT
"""

# JUnit XML (pour CI)
pytest --junitxml=report.xml

# HTML
pytest --html=report.html --self-contained-html

# JSON (nécessite pytest-json-report)
pip install pytest-json-report
pytest --json-report --json-report-file=report.json

# Terminal (défaut)
pytest -v

"""
PERSONNALISER OUTPUT TERMINAL
"""

# conftest.py
def pytest_report_header(config):
    """Ajouter header personnalisé"""
    return [
        f"Project: {config.rootdir}",
        f"Environment: {os.getenv('ENV', 'dev')}",
    ]

"""
Output :
======================== test session starts ========================
Project: /home/user/projet
Environment: dev
platform linux -- Python 3.11.0, pytest-7.4.3
...
"""

def pytest_report_teststatus(report, config):
    """Personnaliser status des tests"""
    if report.when == "call" and report.passed:
        return "passed", "[OK]", "PASSED"
    elif report.when == "call" and report.failed:
        return "failed", "[X]", "FAILED"

"""
RÉSUMÉ PERSONNALISÉ
"""

def pytest_terminal_summary(terminalreporter, exitstatus, config):
    """Ajouter résumé personnalisé"""
    terminalreporter.write_sep("=", "CUSTOM SUMMARY")
    terminalreporter.write_line(
        f"Total duration: {terminalreporter._sessionstarttime}"
    )


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 4 : CONFIGURATION COMPLÈTE
# ----------------------------------------------------------------------------

"""
OBJECTIF : Configurer projet complet avec tous les outils


ÉTAPE 1 : STRUCTURE
"""

complete_project/
├── src/
│   └── myapp/
│       ├── __init__.py
│       ├── core.py
│       └── utils.py
├── tests/
│   ├── conftest.py
│   ├── unit/
│   │   ├── test_core.py
│   │   └── test_utils.py
│   └── integration/
│       └── test_integration.py
├── pyproject.toml
├── pytest.ini
├── .coveragerc
├── .github/
│   └── workflows/
│       └── tests.yml
└── README.md

"""
ÉTAPE 2 : pyproject.toml
"""

# pyproject.toml
"""
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "myapp"
version = "1.0.0"
description = "My Application"
requires-python = ">=3.8"
dependencies = []

[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov>=4.0",
    "pytest-xdist>=3.0",
    "pytest-mock>=3.10",
    "pytest-timeout>=2.1",
    "pytest-sugar>=0.9",
]

[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]

markers = [
    "slow: marks tests as slow (deselect with '-m \"not slow\"')",
    "integration: marks tests as integration tests",
    "unit: marks tests as unit tests",
    "smoke: marks tests as smoke tests",
]

addopts = [
    "-ra",
    "--strict-markers",
    "--strict-config",
    "--showlocals",
    "--tb=short",
]

filterwarnings = [
    "error",
    "ignore::DeprecationWarning",
]

[tool.coverage.run]
source = ["src"]
branch = true
omit = [
    "tests/*",
    "*/venv/*",
    "*/__pycache__/*",
]

[tool.coverage.report]
precision = 2
show_missing = true
skip_covered = false
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
]

[tool.coverage.html]
directory = "htmlcov"
"""

"""
ÉTAPE 3 : pytest.ini (alternatif ou complémentaire)
"""

# pytest.ini
"""
[pytest]
minversion = 7.0
testpaths = tests

# Logging
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S

log_file = pytest.log
log_file_level = DEBUG

# Timeout
timeout = 300

# xdist
addopts = -n auto
"""

"""
ÉTAPE 4 : conftest.py
"""

# tests/conftest.py
"""
import pytest
import os
import sys

# Ajouter src au path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src')))

# Configuration environnement
def pytest_configure(config):
    config.addinivalue_line(
        "markers", "requires_db: mark test as requiring database"
    )
    
    # Config CI
    if os.getenv("CI"):
        config.option.verbose = 2
        config.option.maxfail = 1

# Skip conditionnels
def pytest_collection_modifyitems(config, items):
    if not os.getenv("RUN_SLOW"):
        skip_slow = pytest.mark.skip(reason="Slow tests skipped")
        for item in items:
            if "slow" in item.keywords:
                item.add_marker(skip_slow)

# Fixtures globales
@pytest.fixture(scope="session")
def test_config():
    return {
        "env": os.getenv("ENV", "test"),
        "debug": os.getenv("DEBUG", "false").lower() == "true",
    }

@pytest.fixture
def sample_data():
    return {"key": "value"}

# Hooks reporting
def pytest_report_header(config):
    return [
        f"Environment: {os.getenv('ENV', 'dev')}",
        f"Python: {sys.version}",
    ]
"""

"""
ÉTAPE 5 : GitHub Actions
"""

# .github/workflows/tests.yml
"""
name: Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.8', '3.9', '3.10', '3.11']
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Install dependencies
      run: |
        pip install -e ".[dev]"
    
    - name: Run tests
      env:
        CI: true
      run: |
        pytest -v --cov=src --cov-report=xml --cov-report=html
    
    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml
        fail_ci_if_error: true
"""

"""
ÉTAPE 6 : Makefile (bonus)
"""

# Makefile
"""
.PHONY: test coverage clean

test:
	pytest -v

test-fast:
	pytest -v -m "not slow"

test-integration:
	pytest -v -m integration

coverage:
	pytest --cov=src --cov-report=html --cov-report=term

coverage-full:
	pytest --cov=src --cov-report=html --cov-fail-under=80

clean:
	rm -rf .pytest_cache
	rm -rf htmlcov
	rm -rf .coverage
	find . -type d -name __pycache__ -exec rm -rf {} +

install:
	pip install -e ".[dev]"
"""

"""
ÉTAPE 7 : README.md
"""

# README.md
"""
# MyApp

## Installation

```bash
pip install -e ".[dev]"
```

## Tests

```bash
# Tous les tests
pytest

# Tests unitaires seulement
pytest -m unit

# Avec coverage
pytest --cov=src

# Tests rapides
make test-fast

# Coverage complète
make coverage
```

## CI/CD

Tests automatiques sur push/PR via GitHub Actions.
Coverage reporté sur Codecov.
"""

"""
ÉTAPE 8 : Commandes utiles
"""

# Installation
pip install -e ".[dev]"

# Tests complets
pytest

# Tests rapides (pas slow)
pytest -m "not slow"

# Coverage
pytest --cov=src --cov-report=html

# Parallèle
pytest -n auto

# CI simulation
CI=true pytest -v --strict-markers --maxfail=1


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 4
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Options CLI complètes
[OK] Configuration pytest.ini détaillée
[OK] Alternatives (pyproject.toml, setup.cfg)
[OK] Configuration par environnement
[OK] Plugins essentiels
[OK] Intégration IDE
[OK] Rapports et outputs
[OK] Setup projet complet


[CLE] POINTS CLÉS

1. CLI : -v, -s, -x, -m, -k, --lf, --ff
2. pytest.ini : Configuration centralisée
3. pyproject.toml : Format moderne recommandé
4. Plugins : pytest-cov, pytest-xdist essentiels
5. CI : pytest.ini ou config spécifique
6. IDE : Intégration native (VSCode, PyCharm)


[OBJECTIF] OPTIONS CRITIQUES À RETENIR

# Développement quotidien
pytest -v -s              # Verbeux + print
pytest -x                 # Stop au premier échec
pytest --lf               # Seulement échecs précédents

# Debug
pytest --pdb              # Debugger au premier échec
pytest -vv --showlocals   # Maximum détails

# CI/CD
pytest -v --strict-markers --maxfail=1 --cov=src --cov-fail-under=80 -n auto

# Performance
pytest --durations=10     # Top 10 lents
pytest -n auto            # Parallèle


[OBJECTIF] CONFIGURATION MINIMALE RECOMMANDÉE

# pyproject.toml
[tool.pytest.ini_options]
minversion = "7.0"
testpaths = ["tests"]
addopts = ["-ra", "--strict-markers", "--strict-config"]
markers = [
    "slow: marks tests as slow",
    "integration: marks tests as integration tests",
]


[GRAPHIQUE] WORKFLOW TYPIQUE

1. Développement :
   pytest -v -s

2. Avant commit :
   pytest --cov=src

3. CI :
   pytest -v --strict-markers --cov=src --cov-fail-under=80 -n auto


[BRAVO] FIN DE LA PARTIE 1 : FONDAMENTAUX

Vous maîtrisez maintenant :
[OK] Écrire et exécuter tests
[OK] Assertions complètes
[OK] Organisation de projet
[OK] Configuration et exécution

-> PROCHAINE ÉTAPE : PARTIE 2 - FIXTURES

La Partie 2 couvrira :
- Chapitre 5 : Fixtures de base
- Chapitre 6 : Scopes et lifecycle
- Chapitre 7 : Fixtures paramétrées
- Chapitre 8 : Fixtures built-in

Les fixtures sont LE concept central de pytest !
C'est parti ! [RAPIDE]
"""

Parfait ! La Partie 1 est maintenant complète. Voulez-vous que je continue avec la **Partie 2 sur les Fixtures** ? C'est vraiment le cœur de pytest et ce qui le rend si puissant. 

Je peux commencer par :
1. **Chapitre 5 : Fixtures de Base** (concept, création, utilisation)
2. Ou créer d'abord un **récapitulatif complet de la Partie 1** ?

Dites-moi comment vous souhaitez procéder ! [DOCS]

Je continue avec la Partie 2 sur les Fixtures, qui est vraiment le cœur de pytest ! C'est un concept fondamental et puissant.

# ============================================================================
# [LIVRE] PYTEST - PARTIE 2 : FIXTURES ET SETUP
# ============================================================================
#
# [OBJECTIF] CETTE PARTIE COUVRE :
# - Chapitre 5 : Fixtures de Base
# - Chapitre 6 : Scopes et Lifecycle
# - Chapitre 7 : Fixtures Paramétrées
# - Chapitre 8 : Fixtures Built-in
#
# [TEMPS] TEMPS : ~6-8 heures
# [DOCS] PRÉREQUIS : Partie 1 complétée
# ============================================================================


# ============================================================================
# [GUIDE] CHAPITRE 5 : FIXTURES DE BASE
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Comprendre le concept de fixture
[OK] Créer vos premières fixtures
[OK] Utiliser fixtures dans tests
[OK] Fixtures avec setup/teardown
[OK] Dépendances entre fixtures
[OK] Nommer et organiser fixtures
[OK] Fixtures vs setup/teardown classique
"""


# ----------------------------------------------------------------------------
# [REFLEXION] QU'EST-CE QU'UNE FIXTURE ?
# ----------------------------------------------------------------------------

"""
FIXTURE = FONCTION DE PRÉPARATION DE DONNÉES/RESSOURCES

[IDEE] DÉFINITION SIMPLE

Une fixture est une fonction qui :
1. Prépare des données ou ressources
2. Les fournit aux tests
3. Nettoie après utilisation (optionnel)


ANALOGIE [CONSTRUCTION]

Fixture = Établi de menuisier

1. SETUP : Préparer l'établi (outils, matériaux)
2. UTILISATION : Menuisier travaille
3. TEARDOWN : Ranger l'établi

Chaque menuisier (test) utilise l'établi préparé !


PROBLÈME SANS FIXTURES
"""

# [X] Code répétitif
def test_user_creation():
    # Setup répété
    db = Database()
    db.connect()
    
    # Test
    user = User("Alice")
    db.save(user)
    assert db.get_user("Alice") == user
    
    # Cleanup répété
    db.disconnect()

def test_user_update():
    # Setup répété (ENCORE !)
    db = Database()
    db.connect()
    
    # Test
    user = User("Alice")
    db.save(user)
    user.name = "Bob"
    db.update(user)
    assert db.get_user("Bob").name == "Bob"
    
    # Cleanup répété
    db.disconnect()

"""
[X] PROBLÈMES

1. CODE DUPLIQUÉ
   Setup/teardown répété dans chaque test
   
2. OUBLIS
   Facile d'oublier le cleanup
   
3. ERREURS
   Si test fail avant cleanup -> ressource pas libérée
   
4. MAINTENANCE
   Modifier setup = modifier tous les tests


[OK] AVEC FIXTURES
"""

import pytest

@pytest.fixture
def database():
    """Fixture qui fournit une DB connectée"""
    # SETUP
    db = Database()
    db.connect()
    
    # FOURNIR au test
    yield db
    
    # TEARDOWN (après le test)
    db.disconnect()

# Tests utilisent la fixture
def test_user_creation(database):
    """Test reçoit DB via fixture"""
    user = User("Alice")
    database.save(user)
    assert database.get_user("Alice") == user

def test_user_update(database):
    """Autre test reçoit aussi DB"""
    user = User("Alice")
    database.save(user)
    user.name = "Bob"
    database.update(user)
    assert database.get_user("Bob").name == "Bob"

"""
[OK] AVANTAGES

1. DRY : Code setup unique
2. FIABLE : Cleanup toujours exécuté
3. LISIBLE : Tests focalisés sur la logique
4. MAINTENABLE : Modifier fixture = modifier une fois
5. RÉUTILISABLE : Fixtures partagées


[IDEE] FIXTURE = INJECTION DE DÉPENDANCES

Pytest INJECTE automatiquement la fixture dans le test !
"""

def test_example(database):  # <- Fixture injectée par nom
    # database est déjà connectée et prête


# ----------------------------------------------------------------------------
# [EDIT] CRÉER VOTRE PREMIÈRE FIXTURE
# ----------------------------------------------------------------------------

"""
SYNTAXE DE BASE
"""

import pytest

@pytest.fixture
def ma_fixture():
    """Description de la fixture"""
    # Préparer la ressource
    data = {"key": "value"}
    
    # Retourner au test
    return data

"""
[IDEE] DÉCRYPTAGE

@pytest.fixture
    Décorateur qui marque la fonction comme fixture

def ma_fixture():
    Nom de la fixture (utilisé pour l'injection)

return data
    Valeur fournie au test


UTILISATION DANS TEST
"""

def test_avec_fixture(ma_fixture):
    """Test reçoit fixture par son nom"""
    assert ma_fixture["key"] == "value"

"""
[IDEE] COMMENT ÇA MARCHE ?

1. Pytest voit paramètre `ma_fixture` dans test
2. Cherche fixture avec ce nom
3. Exécute la fixture
4. Injecte résultat dans le test


EXEMPLE COMPLET : FIXTURE SIMPLE
"""

# conftest.py ou test file
@pytest.fixture
def sample_user():
    """Fixture qui retourne un User"""
    return User(name="Alice", age=30)

# test_user.py
def test_user_name(sample_user):
    assert sample_user.name == "Alice"

def test_user_age(sample_user):
    assert sample_user.age == 30

def test_user_is_adult(sample_user):
    assert sample_user.is_adult() is True

"""
[IDEE] NOUVELLE INSTANCE PAR TEST

Par défaut, fixture exécutée pour CHAQUE test
-> Isolation complète !
"""


# ----------------------------------------------------------------------------
# [SYNC] FIXTURES AVEC SETUP/TEARDOWN
# ----------------------------------------------------------------------------

"""
PATTERN : yield POUR CLEANUP

yield = Point de séparation setup/teardown
"""

@pytest.fixture
def resource():
    # ──────── SETUP ────────
    print("Setup: Creating resource")
    resource = create_resource()
    
    # ──────── YIELD ────────
    yield resource  # Fournir au test
    
    # ──────── TEARDOWN ────────
    print("Teardown: Cleaning resource")
    resource.cleanup()

"""
[IDEE] ORDRE D'EXÉCUTION

1. SETUP (avant yield)
2. TEST exécuté
3. TEARDOWN (après yield)


EXEMPLE : FICHIER TEMPORAIRE
"""

import tempfile
import os

@pytest.fixture
def temp_file():
    """Fixture fichier temporaire"""
    # SETUP : Créer fichier
    fd, path = tempfile.mkstemp()
    file = os.fdopen(fd, 'w')
    
    print(f"Created temp file: {path}")
    
    # YIELD : Fournir au test
    yield file, path
    
    # TEARDOWN : Supprimer fichier
    file.close()
    if os.path.exists(path):
        os.remove(path)
    print(f"Deleted temp file: {path}")

# Utilisation
def test_file_writing(temp_file):
    file, path = temp_file
    
    # Écrire dans fichier
    file.write("Hello World")
    file.flush()
    
    # Vérifier
    with open(path, 'r') as f:
        content = f.read()
    assert content == "Hello World"
    
    # Pas besoin de cleanup ! Fixture le fait

"""
EXEMPLE : CONNEXION BASE DE DONNÉES
"""

@pytest.fixture
def db_connection():
    """Fixture connexion DB"""
    # SETUP
    conn = sqlite3.connect(':memory:')
    cursor = conn.cursor()
    
    # Créer tables
    cursor.execute('''
        CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL,
            email TEXT UNIQUE
        )
    ''')
    conn.commit()
    
    print("DB connected and initialized")
    
    # YIELD
    yield conn
    
    # TEARDOWN
    conn.close()
    print("DB connection closed")

# Utilisation
def test_insert_user(db_connection):
    cursor = db_connection.cursor()
    
    # Insert
    cursor.execute(
        "INSERT INTO users (name, email) VALUES (?, ?)",
        ("Alice", "alice@test.com")
    )
    db_connection.commit()
    
    # Vérifier
    cursor.execute("SELECT * FROM users WHERE name=?", ("Alice",))
    result = cursor.fetchone()
    assert result[1] == "Alice"

"""
[IDEE] GARANTIE DE CLEANUP

MÊME si test échoue, teardown TOUJOURS exécuté !
"""

def test_will_fail(temp_file):
    file, path = temp_file
    
    assert False  # [X] Test échoue
    
    # MAIS fichier sera quand même supprimé ! [OK]

"""
EXEMPLE : SERVEUR WEB DE TEST
"""

import subprocess
import time

@pytest.fixture
def test_server():
    """Fixture serveur web local"""
    # SETUP : Démarrer serveur
    process = subprocess.Popen(
        ['python', '-m', 'http.server', '8888'],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL
    )
    
    # Attendre que serveur démarre
    time.sleep(1)
    
    print("Test server started on port 8888")
    
    # YIELD
    yield "http://localhost:8888"
    
    # TEARDOWN : Arrêter serveur
    process.terminate()
    process.wait()
    print("Test server stopped")

# Utilisation
def test_server_response(test_server):
    import requests
    
    response = requests.get(test_server)
    assert response.status_code == 200


# ----------------------------------------------------------------------------
# [LIEN] DÉPENDANCES ENTRE FIXTURES
# ----------------------------------------------------------------------------

"""
FIXTURES PEUVENT UTILISER D'AUTRES FIXTURES !

[IDEE] Composition de fixtures
"""

@pytest.fixture
def database():
    """Fixture DB"""
    db = Database()
    db.connect()
    yield db
    db.disconnect()

@pytest.fixture
def user(database):
    """Fixture User (utilise fixture database)"""
    user = User("Alice")
    database.save(user)
    return user

@pytest.fixture
def post(database, user):
    """Fixture Post (utilise database ET user)"""
    post = Post(title="Test Post", author=user)
    database.save(post)
    return post

# Test utilise fixture de haut niveau
def test_post_author(post):
    """Test reçoit post (qui dépend de user et database)"""
    assert post.author.name == "Alice"

"""
[IDEE] RÉSOLUTION AUTOMATIQUE

Pytest résout automatiquement les dépendances :

test_post_author(post)
    v post a besoin de database et user
    v user a besoin de database
    
Ordre d'exécution :
1. database fixture
2. user fixture (avec database)
3. post fixture (avec database et user)
4. test exécuté
5. Teardown en ordre inverse


EXEMPLE COMPLET : STACK DE FIXTURES
"""

# conftest.py
@pytest.fixture
def engine():
    """Fixture : Engine SQLAlchemy"""
    from sqlalchemy import create_engine
    engine = create_engine('sqlite:///:memory:')
    
    # Créer tables
    Base.metadata.create_all(engine)
    
    yield engine
    
    engine.dispose()

@pytest.fixture
def session(engine):
    """Fixture : Session DB (dépend de engine)"""
    from sqlalchemy.orm import sessionmaker
    
    Session = sessionmaker(bind=engine)
    session = Session()
    
    yield session
    
    session.close()

@pytest.fixture
def sample_user(session):
    """Fixture : User en DB (dépend de session)"""
    user = User(name="Alice", email="alice@test.com")
    session.add(user)
    session.commit()
    
    return user

@pytest.fixture
def sample_posts(session, sample_user):
    """Fixture : Posts de user (dépend de session et sample_user)"""
    posts = [
        Post(title=f"Post {i}", author=sample_user)
        for i in range(3)
    ]
    
    session.add_all(posts)
    session.commit()
    
    return posts

# Test utilise fixture de haut niveau
def test_user_posts(sample_posts, sample_user):
    """Test avec dépendances multiples"""
    assert len(sample_posts) == 3
    assert all(post.author == sample_user for post in sample_posts)

"""
[IDEE] GRAPHE DE DÉPENDANCES

sample_posts
    ├── session
    │   └── engine
    └── sample_user
        └── session
            └── engine

Pytest résout automatiquement :
1. engine
2. session (avec engine)
3. sample_user (avec session)
4. sample_posts (avec session et sample_user)


DÉPENDANCES CIRCULAIRES ([X] INTERDIT)
"""

@pytest.fixture
def fixture_a(fixture_b):  # <- Dépend de B
    return "A"

@pytest.fixture
def fixture_b(fixture_a):  # <- Dépend de A
    return "B"

# [X] Erreur : Circular dependency !

"""
AUTOUSE : FIXTURE AUTOMATIQUE
"""

@pytest.fixture(autouse=True)
def setup_teardown():
    """Fixture exécutée pour TOUS les tests automatiquement"""
    print("\n=== Setup ===")
    yield
    print("\n=== Teardown ===")

# Pas besoin de spécifier dans test !
def test_example():
    """Cette fixture s'exécute automatiquement"""
    assert True

"""
[IDEE] autouse=True

- Fixture exécutée pour TOUS les tests du scope
- Utile pour :
  - Logging
  - Configuration globale
  - Nettoyage automatique
  
[ATTENTION] À utiliser avec modération !


EXEMPLE : LOG AUTOMATIQUE
"""

@pytest.fixture(autouse=True)
def log_test_name(request):
    """Log le nom de chaque test"""
    print(f"\n[BLACK_RIGHT-POINTING_TRIANGLE] Running: {request.node.name}")
    yield
    print(f"[OK] Completed: {request.node.name}")

# Automatiquement actif
def test_addition():
    assert 2 + 2 == 4

"""
Output :
[BLACK_RIGHT-POINTING_TRIANGLE] Running: test_addition
[OK] Completed: test_addition
"""


# ----------------------------------------------------------------------------
# [NAME_BADGE] NOMMER ET ORGANISER FIXTURES
# ----------------------------------------------------------------------------

"""
CONVENTIONS DE NOMMAGE
"""

# [OK] BONS NOMS
@pytest.fixture
def user():              # Simple, clair
    return User()

@pytest.fixture
def authenticated_user():  # Descriptif
    return User(authenticated=True)

@pytest.fixture
def db_session():        # Indique le type
    return Session()

@pytest.fixture
def temp_file():         # Indique temporaire
    return create_temp_file()

# [X] MAUVAIS NOMS
@pytest.fixture
def f():                 # Trop court
    return User()

@pytest.fixture
def data():              # Trop vague
    return User()

@pytest.fixture
def my_fixture():        # Pas informatif
    return User()

"""
[IDEE] RÈGLES DE NOMMAGE

1. DESCRIPTIF : Indique ce que retourne la fixture
2. CONCIS : Pas trop long
3. COHÉRENT : Style uniforme
4. CLAIR : Éviter abréviations


OÙ DÉFINIR LES FIXTURES ?

1. Dans le fichier de test (portée locale)
"""

# tests/test_user.py
@pytest.fixture
def user():
    """Fixture locale à ce fichier"""
    return User()

def test_user_creation(user):
    assert user is not None

"""
2. Dans conftest.py (portée globale/module)
"""

# tests/conftest.py
@pytest.fixture
def user():
    """Fixture disponible pour TOUS les tests"""
    return User()

# tests/test_user.py
def test_user(user):  # Utilise fixture de conftest.py
    assert user is not None

# tests/test_profile.py
def test_profile(user):  # Idem
    assert user.profile is not None

"""
[IDEE] HIÉRARCHIE conftest.py
"""

tests/
├── conftest.py              # Fixtures pour TOUT
├── test_calculator.py
├── unit/
│   ├── conftest.py         # Fixtures pour tests unit/
│   └── test_models.py
└── integration/
    ├── conftest.py         # Fixtures pour tests integration/
    └── test_api.py

"""
Portée fixtures :
- tests/conftest.py -> Tous les tests
- unit/conftest.py -> Seulement tests dans unit/
- integration/conftest.py -> Seulement tests dans integration/


ORGANISER FIXTURES PAR TYPE
"""

# conftest.py
# ──────────────────────────────
# DATABASE FIXTURES
# ──────────────────────────────
@pytest.fixture
def db_engine():
    """SQLAlchemy engine"""
    pass

@pytest.fixture
def db_session(db_engine):
    """DB session"""
    pass

# ──────────────────────────────
# USER FIXTURES
# ──────────────────────────────
@pytest.fixture
def user():
    """Basic user"""
    pass

@pytest.fixture
def admin_user():
    """Admin user"""
    pass

# ──────────────────────────────
# FILE FIXTURES
# ──────────────────────────────
@pytest.fixture
def temp_dir():
    """Temporary directory"""
    pass

@pytest.fixture
def sample_file(temp_dir):
    """Sample file in temp dir"""
    pass

"""
DOCUMENTER LES FIXTURES
"""

@pytest.fixture
def database():
    """
    Provides a connected database instance.
    
    Yields:
        Database: Connected database with test schema
        
    Cleanup:
        Disconnects and cleans up after test
        
    Example:
        def test_query(database):
            result = database.query("SELECT 1")
            assert result == 1
    """
    db = Database()
    db.connect()
    yield db
    db.disconnect()


# ----------------------------------------------------------------------------
# 🆚 FIXTURES VS SETUP/TEARDOWN CLASSIQUE
# ----------------------------------------------------------------------------

"""
MÉTHODE CLASSIQUE (unittest style)
"""

class TestUser:
    def setup_method(self):
        """Avant chaque test"""
        self.user = User("Alice")
        self.db = Database()
        self.db.connect()
    
    def teardown_method(self):
        """Après chaque test"""
        self.db.disconnect()
    
    def test_user_save(self):
        self.db.save(self.user)
        assert self.db.get_user("Alice") is not None
    
    def test_user_delete(self):
        self.db.save(self.user)
        self.db.delete(self.user)
        assert self.db.get_user("Alice") is None

"""
[X] PROBLÈMES

1. ÉTAT PARTAGÉ (self)
2. PAS DE RÉUTILISATION entre classes
3. TOUT OU RIEN (même setup pour tous tests)
4. PAS DE COMPOSITION


[OK] AVEC FIXTURES
"""

@pytest.fixture
def user():
    return User("Alice")

@pytest.fixture
def database():
    db = Database()
    db.connect()
    yield db
    db.disconnect()

class TestUser:
    def test_user_save(self, user, database):
        database.save(user)
        assert database.get_user("Alice") is not None
    
    def test_user_delete(self, user, database):
        database.save(user)
        database.delete(user)
        assert database.get_user("Alice") is None

# Autre classe peut réutiliser
class TestProfile:
    def test_profile(self, user, database):
        # Même fixtures !
        pass

"""
[OK] AVANTAGES FIXTURES

1. RÉUTILISABLES partout
2. COMPOSABLES (dépendances)
3. SÉLECTIFS (seulement ce dont on a besoin)
4. LISIBLES (injection explicite)
5. TESTABLES (fixtures elles-mêmes)


MIGRATION unittest -> pytest
"""

# AVANT (unittest)
class TestCalculator(unittest.TestCase):
    def setUp(self):
        self.calc = Calculator()
    
    def tearDown(self):
        self.calc.reset()
    
    def test_add(self):
        self.assertEqual(self.calc.add(2, 3), 5)

# APRÈS (pytest avec fixtures)
@pytest.fixture
def calculator():
    calc = Calculator()
    yield calc
    calc.reset()

def test_add(calculator):
    assert calculator.add(2, 3) == 5

"""
[IDEE] COMPATIBILITÉ

Pytest supporte AUSSI setup_method/teardown_method
-> Migration progressive possible !


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 5 : FIXTURES DE BASE
# ----------------------------------------------------------------------------

"""
OBJECTIF : Créer système de fixtures pour blog


ÉTAPE 1 : MODÈLES
"""

# models.py
from datetime import datetime

class User:
    def __init__(self, username, email):
        self.id = None
        self.username = username
        self.email = email
        self.created_at = datetime.now()
        self.posts = []
    
    def __repr__(self):
        return f"<User {self.username}>"

class Post:
    def __init__(self, title, content, author):
        self.id = None
        self.title = title
        self.content = content
        self.author = author
        self.created_at = datetime.now()
        self.comments = []
    
    def add_comment(self, comment):
        self.comments.append(comment)
    
    def __repr__(self):
        return f"<Post {self.title}>"

class Comment:
    def __init__(self, content, author):
        self.id = None
        self.content = content
        self.author = author
        self.created_at = datetime.now()
    
    def __repr__(self):
        return f"<Comment by {self.author.username}>"

class Database:
    def __init__(self):
        self.users = {}
        self.posts = {}
        self.comments = {}
        self._next_id = {'users': 1, 'posts': 1, 'comments': 1}
    
    def save_user(self, user):
        user.id = self._next_id['users']
        self.users[user.id] = user
        self._next_id['users'] += 1
        return user
    
    def save_post(self, post):
        post.id = self._next_id['posts']
        self.posts[post.id] = post
        self._next_id['posts'] += 1
        return post
    
    def save_comment(self, comment):
        comment.id = self._next_id['comments']
        self.comments[comment.id] = comment
        self._next_id['comments'] += 1
        return comment
    
    def get_user(self, user_id):
        return self.users.get(user_id)
    
    def get_post(self, post_id):
        return self.posts.get(post_id)
    
    def clear(self):
        self.users.clear()
        self.posts.clear()
        self.comments.clear()

"""
ÉTAPE 2 : FIXTURES
"""

# tests/conftest.py
import pytest
from models import User, Post, Comment, Database

# ──────────────────────────────
# DATABASE FIXTURE
# ──────────────────────────────
@pytest.fixture
def db():
    """
    Provides clean database for each test.
    
    Yields:
        Database: Empty database instance
        
    Cleanup:
        Clears all data after test
    """
    database = Database()
    yield database
    database.clear()

# ──────────────────────────────
# USER FIXTURES
# ──────────────────────────────
@pytest.fixture
def user(db):
    """
    Provides a basic user saved in database.
    
    Returns:
        User: User 'alice' saved in DB
    """
    user = User("alice", "alice@test.com")
    db.save_user(user)
    return user

@pytest.fixture
def admin_user(db):
    """
    Provides an admin user saved in database.
    
    Returns:
        User: Admin user 'admin' saved in DB
    """
    admin = User("admin", "admin@test.com")
    db.save_user(admin)
    return admin

@pytest.fixture
def users(db):
    """
    Provides multiple users saved in database.
    
    Returns:
        list[User]: List of 3 users
    """
    users_list = [
        User("alice", "alice@test.com"),
        User("bob", "bob@test.com"),
        User("charlie", "charlie@test.com"),
    ]
    
    for u in users_list:
        db.save_user(u)
    
    return users_list

# ──────────────────────────────
# POST FIXTURES
# ──────────────────────────────
@pytest.fixture
def post(db, user):
    """
    Provides a post by user.
    
    Args:
        db: Database fixture
        user: User fixture
        
    Returns:
        Post: Post saved in DB
    """
    post = Post(
        title="Test Post",
        content="This is a test post.",
        author=user
    )
    db.save_post(post)
    return post

@pytest.fixture
def posts(db, user):
    """
    Provides multiple posts by user.
    
    Returns:
        list[Post]: List of 3 posts
    """
    posts_list = [
        Post(f"Post {i}", f"Content {i}", user)
        for i in range(1, 4)
    ]
    
    for p in posts_list:
        db.save_post(p)
    
    return posts_list

# ──────────────────────────────
# COMMENT FIXTURES
# ──────────────────────────────
@pytest.fixture
def comment(db, post, admin_user):
    """
    Provides a comment on post by admin.
    
    Returns:
        Comment: Comment saved in DB
    """
    comment = Comment("Great post!", admin_user)
    db.save_comment(comment)
    post.add_comment(comment)
    return comment

@pytest.fixture
def comments(db, post, users):
    """
    Provides multiple comments on post.
    
    Returns:
        list[Comment]: List of comments
    """
    comments_list = [
        Comment(f"Comment {i}", user)
        for i, user in enumerate(users, 1)
    ]
    
    for c in comments_list:
        db.save_comment(c)
        post.add_comment(c)
    
    return comments_list

"""
ÉTAPE 3 : TESTS
"""

# tests/test_user.py
def test_user_creation(user):
    """Test user fixture"""
    assert user.id is not None
    assert user.username == "alice"
    assert user.email == "alice@test.com"

def test_user_in_database(db, user):
    """Test user saved in DB"""
    retrieved = db.get_user(user.id)
    assert retrieved == user

def test_multiple_users(users, db):
    """Test multiple users fixture"""
    assert len(users) == 3
    
    # Tous en DB
    for user in users:
        assert db.get_user(user.id) is not None

def test_user_isolation(user):
    """Test 1 with user"""
    assert user.username == "alice"
    user.username = "modified"

def test_user_fresh_instance(user):
    """Test 2 with fresh user"""
    # user devrait être "alice", pas "modified"
    assert user.username == "alice"

# tests/test_post.py
def test_post_creation(post):
    """Test post fixture"""
    assert post.id is not None
    assert post.title == "Test Post"
    assert post.author.username == "alice"

def test_post_in_database(db, post):
    """Test post saved in DB"""
    retrieved = db.get_post(post.id)
    assert retrieved == post

def test_multiple_posts(posts, user):
    """Test multiple posts"""
    assert len(posts) == 3
    assert all(p.author == user for p in posts)

def test_post_comments_empty(post):
    """Test post starts without comments"""
    assert len(post.comments) == 0

# tests/test_comment.py
def test_comment_creation(comment):
    """Test comment fixture"""
    assert comment.id is not None
    assert comment.content == "Great post!"
    assert comment.author.username == "admin"

def test_comment_on_post(post, comment):
    """Test comment added to post"""
    assert comment in post.comments
    assert len(post.comments) == 1

def test_multiple_comments(post, comments):
    """Test multiple comments fixture"""
    assert len(comments) == 3
    assert len(post.comments) == 3
    
    # Tous les commentaires sont sur le post
    for comment in comments:
        assert comment in post.comments

# tests/test_integration.py
def test_full_blog_scenario(db, user, admin_user):
    """Test scénario complet"""
    # User crée post
    post = Post("My Journey", "Content...", user)
    db.save_post(post)
    
    # Admin commente
    comment = Comment("Nice!", admin_user)
    db.save_comment(comment)
    post.add_comment(comment)
    
    # Vérifications
    assert db.get_post(post.id) is not None
    assert len(post.comments) == 1
    assert post.comments[0].author == admin_user

"""
ÉTAPE 4 : EXÉCUTER
"""

# Tous les tests
pytest -v

# Avec détails
pytest -v -s

# Tests user seulement
pytest tests/test_user.py -v

"""
Output attendu :
tests/test_user.py::test_user_creation PASSED
tests/test_user.py::test_user_in_database PASSED
tests/test_user.py::test_multiple_users PASSED
tests/test_user.py::test_user_isolation PASSED
tests/test_user.py::test_user_fresh_instance PASSED
tests/test_post.py::test_post_creation PASSED
tests/test_post.py::test_post_in_database PASSED
tests/test_post.py::test_multiple_posts PASSED
tests/test_post.py::test_post_comments_empty PASSED
tests/test_comment.py::test_comment_creation PASSED
tests/test_comment.py::test_comment_on_post PASSED
tests/test_comment.py::test_multiple_comments PASSED
tests/test_integration.py::test_full_blog_scenario PASSED
"""


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 5
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Concept de fixture (setup/teardown)
[OK] Créer fixtures avec @pytest.fixture
[OK] Utiliser fixtures dans tests (injection)
[OK] yield pour setup/teardown
[OK] Dépendances entre fixtures
[OK] autouse pour fixtures automatiques
[OK] Nommer et organiser fixtures
[OK] conftest.py pour partage


[CLE] POINTS CLÉS

1. Fixture = Fonction de préparation réutilisable
2. @pytest.fixture pour déclarer
3. Injection par nom de paramètre
4. yield pour séparer setup/teardown
5. Fixtures peuvent dépendre d'autres fixtures
6. conftest.py pour fixtures partagées


[OBJECTIF] PATTERNS ESSENTIELS

# Fixture simple
@pytest.fixture
def resource():
    return create_resource()

# Fixture avec cleanup
@pytest.fixture
def resource():
    r = create_resource()
    yield r
    r.cleanup()

# Fixture avec dépendance
@pytest.fixture
def child(parent):
    return create_child(parent)

# Fixture automatique
@pytest.fixture(autouse=True)
def setup():
    print("Auto setup")


[GRAPHIQUE] ORGANISATION RECOMMANDÉE

conftest.py         -> Fixtures globales
test_*.py          -> Fixtures locales (si spécifiques)

Grouper par type :
- Database fixtures
- User fixtures  
- File fixtures
- Network fixtures


-> PROCHAINE ÉTAPE : Chapitre 6 - Scopes et Lifecycle

Vous allez apprendre :
- Scopes de fixtures (function, class, module, session)
- Contrôler durée de vie
- Optimiser performance
- Partager ressources coûteuses

Les scopes sont cruciaux pour la performance ! [RAPIDE]
"""

Excellent ! Le Chapitre 5 sur les fixtures de base est terminé. Voulez-vous que je continue avec le **Chapitre 6 : Scopes et Lifecycle** ? C'est un concept crucial pour optimiser vos tests et comprendre quand les fixtures sont créées et détruites.

Dites-moi si vous voulez continuer ! [DOCS]

Je continue avec le Chapitre 6 sur les Scopes et Lifecycle des fixtures - un concept crucial pour optimiser la performance de vos tests !

# ============================================================================
# [GUIDE] CHAPITRE 6 : SCOPES ET LIFECYCLE DES FIXTURES
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Comprendre les scopes de fixtures
[OK] Utiliser function, class, module, session
[OK] Optimiser performance avec scopes
[OK] Partager ressources coûteuses
[OK] Gérer l'ordre d'exécution
[OK] Comprendre le lifecycle complet
[OK] Éviter les pièges courants
"""


# ----------------------------------------------------------------------------
# [RECHERCHE] QU'EST-CE QU'UN SCOPE ?
# ----------------------------------------------------------------------------

"""
SCOPE = DURÉE DE VIE D'UNE FIXTURE

[IDEE] DÉFINITION SIMPLE

Le scope détermine :
- QUAND la fixture est créée
- COMBIEN DE FOIS elle est créée
- QUAND elle est détruite


ANALOGIE [CONSTRUCTION]

Scope = Durée de location d'un outil

function -> Location à la minute (nouveau à chaque usage)
class    -> Location à l'heure (partagé pendant 1h)
module   -> Location à la journée (partagé toute la journée)
session  -> Location à la semaine (partagé toute la semaine)


PROBLÈME SANS SCOPES
"""

@pytest.fixture
def database():
    """Fixture par défaut = function scope"""
    print("[OUTIL] Creating database")
    db = Database()
    db.connect()
    yield db
    print("[NETTOYAGE] Closing database")
    db.disconnect()

def test_1(database):
    print("Test 1")
    assert database.query("SELECT 1") == 1

def test_2(database):
    print("Test 2")
    assert database.query("SELECT 1") == 1

def test_3(database):
    print("Test 3")
    assert database.query("SELECT 1") == 1

"""
Output :
[OUTIL] Creating database
Test 1
[NETTOYAGE] Closing database
[OUTIL] Creating database
Test 2
[NETTOYAGE] Closing database
[OUTIL] Creating database
Test 3
[NETTOYAGE] Closing database

[X] PROBLÈME : DB créée/détruite 3 fois !
Si DB coûteuse -> Tests lents


[OK] AVEC SCOPE MODULE
"""

@pytest.fixture(scope="module")
def database():
    """Fixture partagée pour tout le module"""
    print("[OUTIL] Creating database")
    db = Database()
    db.connect()
    yield db
    print("[NETTOYAGE] Closing database")
    db.disconnect()

"""
Output :
[OUTIL] Creating database
Test 1
Test 2
Test 3
[NETTOYAGE] Closing database

[OK] DB créée UNE SEULE FOIS pour les 3 tests !


# ----------------------------------------------------------------------------
# [GRAPHIQUE] LES 4 SCOPES PYTEST
# ----------------------------------------------------------------------------

"""
SCOPES DISPONIBLES (du plus petit au plus grand)

1. function (défaut)
2. class
3. module
4. session
5. package (rare)


1. SCOPE FUNCTION (Défaut)
--------------------------

Fixture créée/détruite pour CHAQUE test
"""

@pytest.fixture(scope="function")  # Ou juste @pytest.fixture
def user():
    print("Creating user")
    user = User("Alice")
    yield user
    print("Destroying user")

def test_1(user):
    print("Test 1")
    user.name = "Modified"

def test_2(user):
    print("Test 2")
    # user est NOUVEAU (pas "Modified")
    assert user.name == "Alice"

"""
Output :
Creating user
Test 1
Destroying user
Creating user
Test 2
Destroying user


[IDEE] QUAND UTILISER function ?

[OK] Fixture légère (rapide à créer)
[OK] Test modifie la fixture
[OK] Isolation complète nécessaire
[OK] Pas de side effects

Exemples :
- Objets simples (User, Post)
- Données de test
- Mocks


2. SCOPE CLASS
--------------

Fixture créée UNE FOIS par classe de tests
"""

@pytest.fixture(scope="class")
def database():
    print("Creating database")
    db = Database()
    db.connect()
    yield db
    print("Closing database")
    db.disconnect()

class TestUserOperations:
    def test_create(self, database):
        print("Test create")
        user = User("Alice")
        database.save(user)
    
    def test_read(self, database):
        print("Test read")
        user = database.get_user(1)
        assert user is not None
    
    def test_update(self, database):
        print("Test update")
        user = database.get_user(1)
        user.name = "Bob"
        database.update(user)

class TestPostOperations:
    def test_create_post(self, database):
        print("Test create post")
        # Nouvelle instance DB pour cette classe
        post = Post("Test")
        database.save(post)

"""
Output :
Creating database      <- Une fois pour TestUserOperations
Test create
Test read
Test update
Closing database
Creating database      <- Nouvelle pour TestPostOperations
Test create post
Closing database


[IDEE] QUAND UTILISER class ?

[OK] Tests groupés logiquement
[OK] Setup coûteux
[OK] Tests d'une même classe partagent contexte
[OK] État peut être modifié entre tests de la classe

[ATTENTION] ATTENTION : Tests de la classe PARTAGENT la fixture !


3. SCOPE MODULE
---------------

Fixture créée UNE FOIS par fichier de test
"""

# tests/test_users.py
@pytest.fixture(scope="module")
def database():
    print("Creating database")
    db = Database()
    db.connect()
    yield db
    print("Closing database")
    db.disconnect()

def test_1(database):
    print("Test 1")

def test_2(database):
    print("Test 2")

class TestUsers:
    def test_3(self, database):
        print("Test 3")
    
    def test_4(self, database):
        print("Test 4")

"""
Output :
Creating database      <- Une seule fois pour tout le module
Test 1
Test 2
Test 3
Test 4
Closing database


[IDEE] QUAND UTILISER module ?

[OK] Setup très coûteux (DB, API, serveur)
[OK] Tous les tests du fichier liés
[OK] État partagé acceptable
[OK] Lecture seule principalement

Exemples :
- Connexion base de données
- Serveur de test
- Chargement de gros fichiers


4. SCOPE SESSION
----------------

Fixture créée UNE FOIS pour TOUTE la suite de tests
"""

# conftest.py
@pytest.fixture(scope="session")
def database():
    print("[OUTIL] Creating database (session)")
    db = Database()
    db.connect()
    yield db
    print("[NETTOYAGE] Closing database (session)")
    db.disconnect()

# tests/test_users.py
def test_user_1(database):
    print("Test user 1")

def test_user_2(database):
    print("Test user 2")

# tests/test_posts.py
def test_post_1(database):
    print("Test post 1")

def test_post_2(database):
    print("Test post 2")

"""
Output :
[OUTIL] Creating database (session)
Test user 1
Test user 2
Test post 1
Test post 2
[NETTOYAGE] Closing database (session)


[IDEE] QUAND UTILISER session ?

[OK] Setup TRÈS coûteux (serveur, DB complexe)
[OK] Ressource partagée par TOUS les tests
[OK] Lecture seule ou state minimal
[OK] Configuration globale

Exemples :
- Docker container
- API externe
- Gros fichiers de données
- Configuration environnement

[ATTENTION] ATTENTION : Tous les tests partagent la même instance !


5. SCOPE PACKAGE (Rare)
-----------------------

Fixture créée une fois par package Python
"""

# tests/unit/__init__.py  (package marker)

# tests/conftest.py
@pytest.fixture(scope="package")
def package_resource():
    print("Creating package resource")
    yield "resource"
    print("Destroying package resource")

"""
[IDEE] RAREMENT UTILISÉ
Principalement pour organisation complexe


# ----------------------------------------------------------------------------
# [SCALES] COMPARAISON DES SCOPES
# ----------------------------------------------------------------------------

"""
TABLEAU COMPARATIF

┌──────────┬──────────────┬─────────────┬───────────┐
│  Scope   │  Créée       │  Partagée   │  Use Case │
├──────────┼──────────────┼─────────────┼───────────┤
│ function │ Chaque test  │ Non         │ Défaut    │
│ class    │ Par classe   │ Tests classe│ Setup moyen│
│ module   │ Par fichier  │ Tout fichier│ Setup lourd│
│ session  │ Une fois     │ Tout        │ Très lourd│
│ package  │ Par package  │ Package     │ Rare      │
└──────────┴──────────────┴─────────────┴───────────┘


EXEMPLE COMPARATIF
"""

# Test avec différents scopes
@pytest.fixture(scope="function")
def func_fixture():
    print("  [function] Creating")
    yield "function"
    print("  [function] Destroying")

@pytest.fixture(scope="class")
def class_fixture():
    print(" [class] Creating")
    yield "class"
    print(" [class] Destroying")

@pytest.fixture(scope="module")
def module_fixture():
    print("[module] Creating")
    yield "module"
    print("[module] Destroying")

class TestExample:
    def test_1(self, func_fixture, class_fixture, module_fixture):
        print("Test 1")
    
    def test_2(self, func_fixture, class_fixture, module_fixture):
        print("Test 2")

class TestAnother:
    def test_3(self, func_fixture, class_fixture, module_fixture):
        print("Test 3")

"""
Output :
[module] Creating
 [class] Creating
  [function] Creating
Test 1
  [function] Destroying
  [function] Creating
Test 2
  [function] Destroying
 [class] Destroying
 [class] Creating
  [function] Creating
Test 3
  [function] Destroying
 [class] Destroying
[module] Destroying


[IDEE] ORDRE D'EXÉCUTION

Création : session -> module -> class -> function
Destruction : function -> class -> module -> session


# ----------------------------------------------------------------------------
# [OBJECTIF] CHOISIR LE BON SCOPE
# ----------------------------------------------------------------------------

"""
ARBRE DE DÉCISION

La fixture est-elle coûteuse à créer ?
    ├─ NON -> function (défaut)
    └─ OUI v
        
        Les tests modifient-ils la fixture ?
        ├─ OUI -> function (isolation nécessaire)
        └─ NON v
            
            Les tests sont-ils dans une classe ?
            ├─ OUI -> class
            └─ NON v
                
                Les tests sont-ils dans le même module ?
                ├─ OUI -> module
                └─ NON v
                    
                    La fixture est-elle utilisée partout ?
                    ├─ OUI -> session
                    └─ NON -> module


EXEMPLES CONCRETS
"""

# [OK] function : Objets simples
@pytest.fixture
def user():
    """Rapide à créer, modifié par tests"""
    return User("Alice")

# [OK] class : Setup moyen, tests liés
@pytest.fixture(scope="class")
def api_client():
    """Client API, setup moyen, tests liés"""
    client = APIClient()
    client.authenticate()
    return client

# [OK] module : Setup coûteux, tests du fichier
@pytest.fixture(scope="module")
def database():
    """Connexion DB coûteuse"""
    db = Database()
    db.connect()
    yield db
    db.disconnect()

# [OK] session : Setup très coûteux, global
@pytest.fixture(scope="session")
def docker_container():
    """Container Docker pour toute la suite"""
    container = start_docker_container()
    yield container
    container.stop()

"""
ERREUR COURANTE : Mauvais scope
"""

# [X] MAUVAIS : Session pour donnée modifiée
@pytest.fixture(scope="session")
def counter():
    """Counter partagé = problème !"""
    return {"count": 0}

def test_1(counter):
    counter["count"] += 1
    assert counter["count"] == 1

def test_2(counter):
    counter["count"] += 1
    # [X] ÉCHOUE ! counter["count"] == 2 (side effect test_1)
    assert counter["count"] == 1

# [OK] BON : Function pour donnée modifiée
@pytest.fixture(scope="function")
def counter():
    """Nouveau counter par test"""
    return {"count": 0}


# ----------------------------------------------------------------------------
# [SYNC] LIFECYCLE COMPLET
# ----------------------------------------------------------------------------

"""
ORDRE D'EXÉCUTION DÉTAILLÉ

Tests :
"""

@pytest.fixture(scope="session")
def session_fix():
    print("1. Session setup")
    yield
    print("10. Session teardown")

@pytest.fixture(scope="module")
def module_fix(session_fix):
    print("2. Module setup")
    yield
    print("9. Module teardown")

@pytest.fixture(scope="class")
def class_fix(module_fix):
    print("3. Class setup")
    yield
    print("8. Class teardown")

@pytest.fixture(scope="function")
def func_fix(class_fix):
    print("4. Function setup")
    yield
    print("6. Function teardown")

class TestExample:
    def test_1(self, func_fix):
        print("5. Test 1 executing")
    
    def test_2(self, func_fix):
        print("7. Test 2 executing")

"""
Output :
1. Session setup
2. Module setup
3. Class setup
4. Function setup
5. Test 1 executing
6. Function teardown
4. Function setup
7. Test 2 executing
6. Function teardown
8. Class teardown
9. Module teardown
10. Session teardown


[IDEE] RÈGLES

1. Setup : Plus grand scope d'abord
2. Teardown : Inverse (plus petit d'abord)
3. Function toujours le dernier setup
4. Session toujours le dernier teardown


DÉPENDANCES ET SCOPES
"""

# [OK] OK : Scope plus petit peut dépendre de plus grand
@pytest.fixture(scope="session")
def database():
    return Database()

@pytest.fixture(scope="function")
def user(database):  # function dépend de session [OK]
    user = User("Alice")
    database.save(user)
    return user

# [X] ERREUR : Plus grand ne peut pas dépendre de plus petit
@pytest.fixture(scope="function")
def database():
    return Database()

@pytest.fixture(scope="session")
def user(database):  # [X] session dépend de function
    return User()

"""
Erreur pytest :
ScopeMismatch: You tried to access the function scoped fixture 
database with a session scoped request object


[IDEE] RÈGLE D'OR

Scope enfant <= Scope parent

Ordre valide :
session -> module -> class -> function
function -> function [OK]
module -> function [OK]
function -> session [X]


# ----------------------------------------------------------------------------
# [RAPIDE] OPTIMISATION AVEC SCOPES
# ----------------------------------------------------------------------------

"""
CAS D'USAGE : BASE DE DONNÉES

Problème : DB setup lent (2 secondes)
100 tests -> 200 secondes !
"""

# [X] LENT : function scope
@pytest.fixture
def db():
    db = Database()
    db.connect()  # 2 secondes
    yield db
    db.disconnect()

# 100 tests × 2s = 200 secondes

# [OK] RAPIDE : session scope + reset
@pytest.fixture(scope="session")
def db():
    """DB connectée une fois"""
    db = Database()
    db.connect()  # 2 secondes UNE FOIS
    yield db
    db.disconnect()

@pytest.fixture(scope="function")
def clean_db(db):
    """Nettoie DB avant chaque test"""
    yield db
    db.clear_all_tables()  # Rapide

# 1 connexion (2s) + 100 clears (0.1s chacun) = 12 secondes
# Gain : 188 secondes (94%) !

"""
PATTERN : FIXTURE SESSION + FACTORY
"""

@pytest.fixture(scope="session")
def db_session():
    """Connexion DB globale"""
    db = Database()
    db.connect()
    yield db
    db.disconnect()

@pytest.fixture
def db(db_session):
    """Transaction par test (rapide)"""
    transaction = db_session.begin()
    yield db_session
    transaction.rollback()  # Annule changements

"""
Avantages :
[OK] Une connexion (scope session)
[OK] Isolation (rollback par test)
[OK] Performance optimale


CAS D'USAGE : API EXTERNE
"""

import requests

@pytest.fixture(scope="session")
def api_token():
    """Token API (authentification coûteuse)"""
    print("Authenticating...")
    response = requests.post("https://api.com/auth", 
                            json={"user": "test", "pass": "test"})
    token = response.json()["token"]
    return token

@pytest.fixture
def api_client(api_token):
    """Client par test (réutilise token)"""
    client = APIClient(token=api_token)
    return client

def test_1(api_client):
    response = api_client.get("/users")
    assert response.status == 200

def test_2(api_client):
    response = api_client.get("/posts")
    assert response.status == 200

"""
Authenticating...  <- Une seule fois
[OK] Gain de temps énorme !


CAS D'USAGE : DOCKER
"""

@pytest.fixture(scope="session")
def docker_postgres():
    """Container PostgreSQL pour toute la session"""
    print("Starting PostgreSQL container...")
    container = docker.run(
        image="postgres:14",
        environment={"POSTGRES_PASSWORD": "test"}
    )
    
    # Attendre que DB soit prête
    wait_for_db(container)
    
    yield container
    
    print("Stopping PostgreSQL container...")
    container.stop()
    container.remove()

# 100 tests utilisent le même container
# Démarrage : 10s (une fois)
# vs 100 × 10s = 1000s sans scope !


# ----------------------------------------------------------------------------
# [ATTENTION] PIÈGES ET SOLUTIONS
# ----------------------------------------------------------------------------

"""
PIÈGE 1 : ÉTAT PARTAGÉ INVOLONTAIRE
"""

# [X] PROBLÈME
@pytest.fixture(scope="module")
def shared_list():
    return []

def test_1(shared_list):
    shared_list.append(1)
    assert len(shared_list) == 1

def test_2(shared_list):
    shared_list.append(2)
    # [X] ÉCHOUE ! len == 2 (side effect test_1)
    assert len(shared_list) == 1

# [OK] SOLUTION 1 : function scope
@pytest.fixture
def isolated_list():
    return []

# [OK] SOLUTION 2 : Reset dans fixture
@pytest.fixture(scope="module")
def shared_list():
    lst = []
    yield lst
    lst.clear()  # Clear après chaque test

# [OK] SOLUTION 3 : Factory pattern
@pytest.fixture(scope="module")
def list_factory():
    def _make_list():
        return []
    return _make_list

def test_3(list_factory):
    my_list = list_factory()
    my_list.append(1)

"""
PIÈGE 2 : DÉPENDANCES DE SCOPE INCOMPATIBLES
"""

# [X] ERREUR
@pytest.fixture
def temp_file():
    """function scope"""
    return create_temp_file()

@pytest.fixture(scope="session")
def processor(temp_file):  # [X] session dépend de function
    return FileProcessor(temp_file)

"""
Erreur : ScopeMismatch


# [OK] SOLUTION : Aligner les scopes
@pytest.fixture(scope="session")
def temp_file():
    """Fichier pour toute la session"""
    file = create_temp_file()
    yield file
    os.remove(file)

@pytest.fixture(scope="session")
def processor(temp_file):
    return FileProcessor(temp_file)


PIÈGE 3 : OUBLIER LE CLEANUP
"""

# [X] PROBLÈME
@pytest.fixture(scope="session")
def database():
    db = Database()
    db.connect()
    return db  # [X] Pas de cleanup !
    # Connexion jamais fermée !

# [OK] SOLUTION : yield
@pytest.fixture(scope="session")
def database():
    db = Database()
    db.connect()
    yield db
    db.disconnect()  # [OK] Cleanup garanti

"""
PIÈGE 4 : FIXTURE TROP LARGE
"""

# [X] PROBLÈME : Session scope pour donnée modifiable
@pytest.fixture(scope="session")
def user():
    """User partagé = problèmes !"""
    return User("Alice")

def test_1(user):
    user.name = "Modified"
    assert user.name == "Modified"

def test_2(user):
    # [X] user.name == "Modified" (pas "Alice")
    assert user.name == "Alice"  # ÉCHOUE

# [OK] SOLUTION : Scope approprié
@pytest.fixture(scope="function")
def user():
    """Nouveau user par test"""
    return User("Alice")


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 6 : OPTIMISATION AVEC SCOPES
# ----------------------------------------------------------------------------

"""
OBJECTIF : Optimiser suite de tests avec scopes appropriés


ÉTAPE 1 : SCÉNARIO

Application web avec :
- Base de données PostgreSQL (lent à démarrer)
- Cache Redis (moyen)
- API externe (authentification coûteuse)
- 50 tests


ÉTAPE 2 : VERSION NON OPTIMISÉE
"""

# [X] Tous en function scope (défaut)
import pytest
import time

@pytest.fixture
def postgres():
    """PostgreSQL - LENT (5 secondes)"""
    print("\n[POSTGRES] Starting PostgreSQL...")
    time.sleep(5)
    db = {"type": "postgres", "connected": True}
    yield db
    print("[POSTGRES] Stopping PostgreSQL...")

@pytest.fixture
def redis():
    """Redis - MOYEN (2 secondes)"""
    print("[PACKAGE] Starting Redis...")
    time.sleep(2)
    cache = {"type": "redis", "connected": True}
    yield cache
    print("[PACKAGE] Stopping Redis...")

@pytest.fixture
def api_auth():
    """API Auth - MOYEN (3 secondes)"""
    print("[SECURISE] Authenticating with API...")
    time.sleep(3)
    token = "auth_token_xyz"
    return token

# 50 tests utilisant ces fixtures
def test_1(postgres, redis, api_auth):
    assert postgres["connected"]

def test_2(postgres, redis, api_auth):
    assert redis["connected"]

# ... 48 autres tests

"""
Temps total : 50 × (5 + 2 + 3) = 500 secondes ! [!]


ÉTAPE 3 : VERSION OPTIMISÉE
"""

# tests/conftest.py
import pytest
import time

# ──────────────────────────────
# SESSION SCOPE (Très coûteux)
# ──────────────────────────────
@pytest.fixture(scope="session")
def postgres_session():
    """
    PostgreSQL pour toute la session.
    Setup une fois, utilisé partout.
    """
    print("\n[POSTGRES] Starting PostgreSQL (session)...")
    time.sleep(5)  # Simuler démarrage lent
    
    db = {
        "type": "postgres",
        "connected": True,
        "data": {}
    }
    
    yield db
    
    print("\n[POSTGRES] Stopping PostgreSQL (session)...")

@pytest.fixture(scope="session")
def api_token():
    """
    Token API pour toute la session.
    Authentification une fois.
    """
    print("\n[SECURISE] Authenticating with API (session)...")
    time.sleep(3)  # Simuler auth lente
    
    token = "auth_token_xyz_12345"
    return token

# ──────────────────────────────
# MODULE SCOPE (Tests du fichier)
# ──────────────────────────────
@pytest.fixture(scope="module")
def redis_module():
    """
    Redis pour le module.
    Partagé par tests du fichier.
    """
    print("\n[PACKAGE] Starting Redis (module)...")
    time.sleep(2)  # Simuler démarrage moyen
    
    cache = {
        "type": "redis",
        "connected": True,
        "cache": {}
    }
    
    yield cache
    
    print("\n[PACKAGE] Stopping Redis (module)...")

# ──────────────────────────────
# FUNCTION SCOPE (Isolation)
# ──────────────────────────────
@pytest.fixture
def postgres(postgres_session):
    """
    Transaction PostgreSQL par test.
    Réutilise connexion session, rollback par test.
    """
    # Démarrer transaction
    transaction = {"active": True, "changes": []}
    
    yield {**postgres_session, "transaction": transaction}
    
    # Rollback (rapide)
    transaction["active"] = False
    postgres_session["data"].clear()

@pytest.fixture
def redis(redis_module):
    """
    Cache Redis nettoyé par test.
    Réutilise connexion module.
    """
    yield redis_module
    
    # Clear cache (rapide)
    redis_module["cache"].clear()

@pytest.fixture
def api_client(api_token):
    """
    Client API par test.
    Réutilise token session.
    """
    client = {
        "token": api_token,
        "authenticated": True
    }
    return client

"""
ÉTAPE 4 : TESTS OPTIMISÉS
"""

# tests/test_users.py
def test_create_user(postgres, redis, api_client):
    """Test création user"""
    # Postgres : transaction isolée
    postgres["data"]["user_1"] = {"name": "Alice"}
    
    # Redis : cache isolé
    redis["cache"]["user:1"] = "Alice"
    
    # API : token réutilisé
    assert api_client["authenticated"]
    
    assert postgres["data"]["user_1"]["name"] == "Alice"

def test_get_user(postgres, redis, api_client):
    """Test récupération user"""
    # Nouveau test = nouvelles fixtures function
    # Mais réutilise session/module !
    
    assert "user_1" not in postgres["data"]  # Clean
    assert len(redis["cache"]) == 0  # Clean

# tests/test_posts.py
def test_create_post(postgres, redis, api_client):
    """Test création post"""
    postgres["data"]["post_1"] = {"title": "Test"}
    assert postgres["data"]["post_1"]["title"] == "Test"

# ... 47 autres tests

"""
ÉTAPE 5 : MESURE DE PERFORMANCE
"""

# Script de benchmark
import subprocess
import time

def benchmark_tests():
    """Compare performance avec/sans optimisation"""
    
    print("=" * 50)
    print("BENCHMARK : Tests non optimisés")
    print("=" * 50)
    
    start = time.time()
    subprocess.run(["pytest", "tests_slow/", "-v"])
    slow_time = time.time() - start
    
    print("\n" + "=" * 50)
    print("BENCHMARK : Tests optimisés")
    print("=" * 50)
    
    start = time.time()
    subprocess.run(["pytest", "tests_fast/", "-v"])
    fast_time = time.time() - start
    
    print("\n" + "=" * 50)
    print("RÉSULTATS")
    print("=" * 50)
    print(f"Non optimisé : {slow_time:.2f}s")
    print(f"Optimisé     : {fast_time:.2f}s")
    print(f"Gain         : {slow_time - fast_time:.2f}s ({100 * (1 - fast_time/slow_time):.1f}%)")

"""
Résultats attendus :
Non optimisé : 500s (50 × 10s)
Optimisé     : 10s  (setup unique)
Gain         : 490s (98%) ! [RAPIDE]


ÉTAPE 6 : RAPPORT DE PERFORMANCE
"""

# pytest --durations=10 pour voir les plus lents
pytest --durations=10 -v

"""
Output :
========== slowest 10 durations ==========
5.00s setup    conftest.py::postgres_session
3.00s setup    conftest.py::api_token
2.00s setup    conftest.py::redis_module
0.01s call     test_users.py::test_create_user
0.01s call     test_users.py::test_get_user
...


[IDEE] LEÇONS

1. Setup coûteux -> Scope large (session/module)
2. Isolation nécessaire -> Wrapper function scope
3. Pattern : Session connexion + Function transaction
4. Mesurer avant/après optimisation


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 6
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] 4 scopes : function, class, module, session
[OK] Choisir scope approprié
[OK] Optimiser performance avec scopes
[OK] Lifecycle complet des fixtures
[OK] Dépendances entre scopes
[OK] Pièges courants et solutions
[OK] Pattern session + function


[CLE] POINTS CLÉS

1. Scope = Durée de vie de fixture
2. Plus grand scope = Moins d'exécutions
3. function (défaut) = Isolation
4. session = Performance
5. Règle : Scope enfant <= Scope parent


[OBJECTIF] GUIDE DE DÉCISION

Coûteux + Read-only -> session
Coûteux + Modifiable -> session + function wrapper
Moyen + Tests liés -> module ou class
Léger -> function (défaut)


[GRAPHIQUE] PATTERNS ESSENTIELS

# Session connexion + Function isolation
@pytest.fixture(scope="session")
def db_connection():
    db = Database()
    db.connect()
    yield db
    db.disconnect()

@pytest.fixture
def db(db_connection):
    transaction = db_connection.begin()
    yield db_connection
    transaction.rollback()

# Factory pattern
@pytest.fixture(scope="session")
def user_factory():
    def _make_user(name):
        return User(name)
    return _make_user


[RAPIDE] OPTIMISATION

Avant : 50 tests × 10s setup = 500s
Après : 1 × 10s setup = 10s
Gain : 98% ! [RAPIDE]


-> PROCHAINE ÉTAPE : Chapitre 7 - Fixtures Paramétrées

Vous allez apprendre :
- Paramétrer fixtures
- Générer multiples variations
- Tests combinatoires
- Fixtures dynamiques

Les fixtures paramétrées sont puissantes ! [OBJECTIF]
"""

Excellent ! Le Chapitre 6 sur les scopes est terminé. C'est vraiment un concept crucial pour optimiser vos tests. 

Voulez-vous que je continue avec le **Chapitre 7 : Fixtures Paramétrées** ? C'est un sujet avancé qui permet de créer des variations de fixtures de manière élégante.

Dites-moi si vous voulez continuer ! [RAPIDE]

Je continue avec le Chapitre 7 sur les Fixtures Paramétrées - un concept avancé et très puissant !

# ============================================================================
# [GUIDE] CHAPITRE 7 : FIXTURES PARAMÉTRÉES
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Paramétrer fixtures avec params
[OK] Créer variations de fixtures
[OK] Tests combinatoires automatiques
[OK] Utiliser request.param
[OK] Fixtures paramétrées indirectes
[OK] Combiner avec @pytest.mark.parametrize
[OK] Patterns avancés de paramétrage
"""


# ----------------------------------------------------------------------------
# [REFLEXION] QU'EST-CE QU'UNE FIXTURE PARAMÉTRÉE ?
# ----------------------------------------------------------------------------

"""
FIXTURE PARAMÉTRÉE = FIXTURE AVEC PLUSIEURS VARIATIONS

[IDEE] DÉFINITION SIMPLE

Une fixture paramétrée :
1. Prend une liste de paramètres
2. S'exécute une fois PAR paramètre
3. Chaque test reçoit toutes les variations


ANALOGIE [DESIGN]

Fixture = Peinture
Paramètres = Couleurs différentes

Fixture paramétrée = Peindre avec [rouge, vert, bleu]
-> Chaque test exécuté 3 fois (une par couleur)


PROBLÈME SANS PARAMÉTRAGE
"""

# [X] Code dupliqué
@pytest.fixture
def chrome_browser():
    browser = Browser("chrome")
    yield browser
    browser.quit()

@pytest.fixture
def firefox_browser():
    browser = Browser("firefox")
    yield browser
    browser.quit()

@pytest.fixture
def safari_browser():
    browser = Browser("safari")
    yield browser
    browser.quit()

# Tests pour chaque navigateur
def test_login_chrome(chrome_browser):
    assert chrome_browser.login("user", "pass")

def test_login_firefox(firefox_browser):
    assert firefox_browser.login("user", "pass")

def test_login_safari(safari_browser):
    assert safari_browser.login("user", "pass")

"""
[X] PROBLÈMES

1. DUPLICATION : Fixtures quasi-identiques
2. MAINTENANCE : Ajouter navigateur = tout réécrire
3. VERBEUX : Tests répétitifs


[OK] AVEC FIXTURE PARAMÉTRÉE
"""

@pytest.fixture(params=["chrome", "firefox", "safari"])
def browser(request):
    """Fixture paramétrée : 3 navigateurs"""
    browser_name = request.param  # Paramètre actuel
    browser = Browser(browser_name)
    yield browser
    browser.quit()

# UN SEUL test, exécuté 3 fois !
def test_login(browser):
    assert browser.login("user", "pass")

"""
Output :
test_login[chrome] PASSED
test_login[firefox] PASSED
test_login[safari] PASSED

[OK] 1 fixture, 1 test -> 3 exécutions automatiques !


# ----------------------------------------------------------------------------
# [EDIT] SYNTAXE DE BASE
# ----------------------------------------------------------------------------

"""
CRÉER UNE FIXTURE PARAMÉTRÉE
"""

import pytest

@pytest.fixture(params=[value1, value2, value3])
def ma_fixture(request):
    """
    params : Liste des valeurs
    request.param : Valeur actuelle
    """
    valeur = request.param
    # Setup avec cette valeur
    return valeur

"""
[IDEE] DÉCRYPTAGE

params=[...]
    Liste des paramètres à tester

request
    Objet spécial pytest (toujours disponible)
    
request.param
    Valeur du paramètre actuel


EXEMPLE SIMPLE : NOMBRES
"""

@pytest.fixture(params=[1, 2, 3, 5, 10])
def number(request):
    """Fixture qui retourne différents nombres"""
    return request.param

def test_positive(number):
    """Test exécuté 5 fois"""
    assert number > 0

def test_square(number):
    """Test exécuté 5 fois"""
    assert number ** 2 >= number

"""
Output :
test_positive[1] PASSED
test_positive[2] PASSED
test_positive[3] PASSED
test_positive[5] PASSED
test_positive[10] PASSED
test_square[1] PASSED
test_square[2] PASSED
test_square[3] PASSED
test_square[5] PASSED
test_square[10] PASSED

10 tests exécutés au total ! (2 tests × 5 params)


EXEMPLE : TYPES DE DONNÉES
"""

@pytest.fixture(params=[
    [],           # Liste vide
    [1],          # Un élément
    [1, 2, 3],    # Plusieurs éléments
])
def sample_list(request):
    """Fixture avec différentes listes"""
    return request.param

def test_list_type(sample_list):
    """Vérifie que c'est une liste"""
    assert isinstance(sample_list, list)

def test_list_append(sample_list):
    """Teste append"""
    original_len = len(sample_list)
    sample_list.append(999)
    assert len(sample_list) == original_len + 1

"""
Output :
test_list_type[list0] PASSED      # []
test_list_type[list1] PASSED      # [1]
test_list_type[list2] PASSED      # [1, 2, 3]
test_list_append[list0] PASSED
test_list_append[list1] PASSED
test_list_append[list2] PASSED


# ----------------------------------------------------------------------------
# [LABEL] IDS PERSONNALISÉS
# ----------------------------------------------------------------------------

"""
PROBLÈME : IDS GÉNÉRIQUES

Par défaut, pytest génère : [list0], [list1], [list2]
Pas très descriptif !


[OK] SOLUTION : ids PERSONNALISÉS
"""

@pytest.fixture(
    params=[
        [],
        [1],
        [1, 2, 3],
    ],
    ids=[
        "empty",
        "single",
        "multiple"
    ]
)
def sample_list(request):
    return request.param

"""
Output :
test_list_type[empty] PASSED
test_list_type[single] PASSED
test_list_type[multiple] PASSED

[OK] Beaucoup plus lisible !


IDS AVEC FONCTION
"""

def list_id(param):
    """Génère ID basé sur longueur"""
    return f"len_{len(param)}"

@pytest.fixture(
    params=[[], [1], [1, 2, 3], [1, 2, 3, 4, 5]],
    ids=list_id
)
def sample_list(request):
    return request.param

"""
Output :
test[len_0] PASSED
test[len_1] PASSED
test[len_3] PASSED
test[len_5] PASSED


IDS AVEC LAMBDA
"""

@pytest.fixture(
    params=[
        {"name": "Alice", "age": 30},
        {"name": "Bob", "age": 25},
        {"name": "Charlie", "age": 35},
    ],
    ids=lambda d: f"{d['name']}_{d['age']}"
)
def user_data(request):
    return request.param

"""
Output :
test[Alice_30] PASSED
test[Bob_25] PASSED
test[Charlie_35] PASSED


# ----------------------------------------------------------------------------
# [OBJECTIF] EXEMPLES PRATIQUES
# ----------------------------------------------------------------------------

"""
EXEMPLE 1 : TESTER PLUSIEURS BASES DE DONNÉES
"""

@pytest.fixture(
    params=["sqlite", "postgresql", "mysql"],
    ids=lambda db: f"db_{db}"
)
def database(request):
    """Teste avec 3 types de DB"""
    db_type = request.param
    
    if db_type == "sqlite":
        db = SQLiteDatabase(":memory:")
    elif db_type == "postgresql":
        db = PostgreSQLDatabase("test_db")
    elif db_type == "mysql":
        db = MySQLDatabase("test_db")
    
    db.connect()
    db.create_tables()
    
    yield db
    
    db.drop_tables()
    db.disconnect()

def test_insert_user(database):
    """Test sur 3 DB différentes"""
    user = User("Alice", "alice@test.com")
    database.save(user)
    
    retrieved = database.get_user(1)
    assert retrieved.name == "Alice"

"""
Output :
test_insert_user[db_sqlite] PASSED
test_insert_user[db_postgresql] PASSED
test_insert_user[db_mysql] PASSED

[OK] Test de compatibilité multi-DB automatique !


EXEMPLE 2 : TESTER PLUSIEURS FORMATS
"""

@pytest.fixture(
    params=[
        ("user.json", "json"),
        ("user.yaml", "yaml"),
        ("user.xml", "xml"),
    ],
    ids=lambda x: x[1]  # ID = format
)
def file_format(request, tmp_path):
    """Teste parsing de différents formats"""
    filename, format_type = request.param
    
    # Créer fichier test
    filepath = tmp_path / filename
    
    data = {"name": "Alice", "age": 30}
    
    if format_type == "json":
        import json
        with open(filepath, 'w') as f:
            json.dump(data, f)
    elif format_type == "yaml":
        import yaml
        with open(filepath, 'w') as f:
            yaml.dump(data, f)
    elif format_type == "xml":
        # Créer XML
        xml_content = "<user><name>Alice</name><age>30</age></user>"
        with open(filepath, 'w') as f:
            f.write(xml_content)
    
    yield filepath, format_type, data

def test_file_parsing(file_format):
    """Parse différents formats"""
    filepath, format_type, expected_data = file_format
    
    # Parser selon format
    parser = get_parser(format_type)
    data = parser.parse(filepath)
    
    assert data["name"] == expected_data["name"]
    assert data["age"] == expected_data["age"]

"""
EXEMPLE 3 : TESTER ENCODINGS
"""

@pytest.fixture(
    params=["utf-8", "latin-1", "cp1252"],
    ids=lambda enc: f"encoding_{enc}"
)
def encoding(request):
    """Différents encodages"""
    return request.param

def test_file_encoding(tmp_path, encoding):
    """Teste lecture/écriture avec encodages"""
    filepath = tmp_path / f"test_{encoding}.txt"
    
    # Texte avec caractères spéciaux
    text = "Héllo Wörld! Çà marche? 你好"
    
    # Écrire
    try:
        with open(filepath, 'w', encoding=encoding) as f:
            f.write(text)
        
        # Lire
        with open(filepath, 'r', encoding=encoding) as f:
            content = f.read()
        
        assert content == text
    except UnicodeEncodeError:
        # Certains encodages ne supportent pas tous les caractères
        pytest.skip(f"{encoding} doesn't support all characters")

"""
EXEMPLE 4 : TESTER CONFIGURATIONS
"""

@pytest.fixture(
    params=[
        {"debug": True, "log_level": "DEBUG"},
        {"debug": False, "log_level": "INFO"},
        {"debug": False, "log_level": "WARNING"},
    ],
    ids=lambda cfg: f"debug_{cfg['debug']}_level_{cfg['log_level']}"
)
def app_config(request):
    """Différentes configurations d'app"""
    config = request.param
    
    app = Application()
    app.configure(**config)
    
    yield app
    
    app.shutdown()

def test_logging(app_config):
    """Test comportement selon config"""
    app_config.log("Test message")
    
    if app_config.config["debug"]:
        assert "DEBUG" in app_config.get_logs()


# ----------------------------------------------------------------------------
# [LIEN] FIXTURES PARAMÉTRÉES AVEC DÉPENDANCES
# ----------------------------------------------------------------------------

"""
FIXTURES PARAMÉTRÉES PEUVENT DÉPENDRE D'AUTRES FIXTURES
"""

@pytest.fixture(params=["sqlite", "postgres"])
def db_type(request):
    """Type de base de données"""
    return request.param

@pytest.fixture
def database(db_type):
    """Database qui dépend de db_type paramétré"""
    if db_type == "sqlite":
        db = SQLiteDatabase()
    else:
        db = PostgreSQLDatabase()
    
    db.connect()
    yield db
    db.disconnect()

@pytest.fixture
def user(database):
    """User qui dépend de database"""
    user = User("Alice")
    database.save(user)
    return user

def test_user_operations(user, database):
    """
    Test exécuté 2 fois :
    - Une fois avec sqlite
    - Une fois avec postgres
    """
    assert database.get_user(user.id) == user

"""
[IDEE] PROPAGATION DES PARAMÈTRES

db_type (params: 2) 
    -> database (exécuté 2 fois)
        -> user (exécuté 2 fois)
            -> test (exécuté 2 fois)


EXEMPLE COMPLEXE : STACK DE FIXTURES PARAMÉTRÉES
"""

@pytest.fixture(params=["dev", "staging", "prod"])
def environment(request):
    """Environnement"""
    return request.param

@pytest.fixture(params=["http", "https"])
def protocol(request):
    """Protocole"""
    return request.param

@pytest.fixture
def api_url(environment, protocol):
    """
    URL API générée à partir de fixtures paramétrées
    
    Combinaisons :
    - dev + http
    - dev + https
    - staging + http
    - staging + https
    - prod + http
    - prod + https
    
    Total : 3 × 2 = 6 combinaisons !
    """
    if environment == "dev":
        domain = "dev.api.com"
    elif environment == "staging":
        domain = "staging.api.com"
    else:
        domain = "api.com"
    
    return f"{protocol}://{domain}"

def test_api_connection(api_url):
    """
    Test exécuté 6 fois !
    Une fois pour chaque combinaison
    """
    response = requests.get(api_url + "/health")
    assert response.status_code == 200

"""
Output :
test_api_connection[dev-http] PASSED
test_api_connection[dev-https] PASSED
test_api_connection[staging-http] PASSED
test_api_connection[staging-https] PASSED
test_api_connection[prod-http] PASSED
test_api_connection[prod-https] PASSED

[OK] Tests combinatoires automatiques !


# ----------------------------------------------------------------------------
# [SCENARIO] PARAMETRIZE vs FIXTURE PARAMÉTRÉE
# ----------------------------------------------------------------------------

"""
DEUX APPROCHES POUR PARAMÉTRAGE

1. @pytest.mark.parametrize (sur test)
2. @pytest.fixture(params=...) (sur fixture)


QUAND UTILISER CHACUNE ?
"""

# ──────────────────────────────────────
# PARAMETRIZE : Données de test simples
# ──────────────────────────────────────

@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (5, 7, 12),
    (10, 20, 30),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

"""
[OK] QUAND UTILISER parametrize ?

- Données simples
- Pas de setup/teardown
- Paramétrage local à un test
- Combinaisons de valeurs


# ──────────────────────────────────────
# FIXTURE PARAMÉTRÉE : Setup complexe
# ──────────────────────────────────────

@pytest.fixture(params=["chrome", "firefox"])
def browser(request):
    """Setup/teardown complexe"""
    browser = Browser(request.param)
    browser.start()  # Setup
    yield browser
    browser.quit()   # Teardown

def test_navigation(browser):
    browser.navigate("https://example.com")
    assert browser.current_url == "https://example.com"

"""
[OK] QUAND UTILISER fixture paramétrée ?

- Setup/teardown nécessaire
- Ressource complexe
- Réutilisation entre tests
- Dépendances entre fixtures


COMPARAISON DIRECTE
"""

# [X] MAUVAIS : parametrize pour ressource complexe
@pytest.mark.parametrize("browser_name", ["chrome", "firefox"])
def test_navigation(browser_name):
    # [X] Setup répété dans le test
    browser = Browser(browser_name)
    browser.start()
    
    browser.navigate("https://example.com")
    assert browser.current_url == "https://example.com"
    
    # [X] Risque d'oublier cleanup
    browser.quit()

# [OK] BON : Fixture paramétrée
@pytest.fixture(params=["chrome", "firefox"])
def browser(request):
    browser = Browser(request.param)
    browser.start()
    yield browser
    browser.quit()  # [OK] Cleanup garanti

def test_navigation(browser):
    browser.navigate("https://example.com")
    assert browser.current_url == "https://example.com"

"""
COMBINER LES DEUX
"""

@pytest.fixture(params=["chrome", "firefox"])
def browser(request):
    """Fixture paramétrée"""
    browser = Browser(request.param)
    browser.start()
    yield browser
    browser.quit()

@pytest.mark.parametrize("url", [
    "https://google.com",
    "https://github.com",
    "https://stackoverflow.com",
])
def test_navigation(browser, url):
    """
    Tests combinatoires :
    2 browsers × 3 URLs = 6 tests !
    """
    browser.navigate(url)
    assert browser.current_url == url

"""
Output :
test_navigation[chrome-https://google.com] PASSED
test_navigation[chrome-https://github.com] PASSED
test_navigation[chrome-https://stackoverflow.com] PASSED
test_navigation[firefox-https://google.com] PASSED
test_navigation[firefox-https://github.com] PASSED
test_navigation[firefox-https://stackoverflow.com] PASSED


# ----------------------------------------------------------------------------
# [MELANGE] PARAMETRIZE INDIRECT
# ----------------------------------------------------------------------------

"""
PARAMETRIZE INDIRECT = Passer paramètres via fixture

[IDEE] USE CASE

Vous voulez paramétrer COMMENT la fixture est créée
"""

# Fixture qui peut prendre paramètres
@pytest.fixture
def user(request):
    """Fixture qui accepte paramètres via indirect"""
    name = request.param  # Reçoit le paramètre
    user = User(name)
    return user

# Paramétrage indirect
@pytest.mark.parametrize("user", ["Alice", "Bob", "Charlie"], indirect=True)
def test_user_name(user):
    """
    user fixture est appelée 3 fois
    Avec params : "Alice", "Bob", "Charlie"
    """
    assert len(user.name) > 0

"""
[IDEE] SANS indirect

@pytest.mark.parametrize("user", ["Alice", "Bob", "Charlie"])
def test(user):
    # user = "Alice" (string directement)
    
AVEC indirect=True

@pytest.mark.parametrize("user", ["Alice", "Bob", "Charlie"], indirect=True)
def test(user):
    # user = User("Alice") (via fixture)


EXEMPLE CONCRET : FICHIERS DE CONFIG
"""

@pytest.fixture
def config(request):
    """Charge config depuis fichier"""
    filename = request.param
    
    # Charger config
    with open(filename) as f:
        config_data = json.load(f)
    
    return config_data

@pytest.mark.parametrize(
    "config",
    ["config_dev.json", "config_staging.json", "config_prod.json"],
    indirect=True
)
def test_config_valid(config):
    """Teste 3 fichiers de config"""
    assert "database" in config
    assert "api_key" in config

"""
INDIRECT PARTIEL
"""

@pytest.fixture
def app(request):
    """App fixture"""
    config = request.param
    return Application(config)

@pytest.mark.parametrize(
    "app, expected_code",
    [
        ({"debug": True}, 200),
        ({"debug": False}, 200),
    ],
    indirect=["app"]  # <- Seulement app est indirect
)
def test_app(app, expected_code):
    """
    app : passé via fixture (indirect)
    expected_code : passé directement
    """
    response = app.handle_request()
    assert response.code == expected_code


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 7 : FIXTURES PARAMÉTRÉES
# ----------------------------------------------------------------------------

"""
OBJECTIF : Tester système de notifications multi-canaux


ÉTAPE 1 : SYSTÈME À TESTER
"""

# notification_system.py
from abc import ABC, abstractmethod

class Notification(ABC):
    """Interface notification"""
    
    @abstractmethod
    def send(self, message: str, recipient: str) -> bool:
        pass
    
    @abstractmethod
    def get_delivery_status(self) -> dict:
        pass

class EmailNotification(Notification):
    def __init__(self, smtp_server: str):
        self.smtp_server = smtp_server
        self.sent_messages = []
    
    def send(self, message: str, recipient: str) -> bool:
        # Simuler envoi email
        self.sent_messages.append({
            "type": "email",
            "recipient": recipient,
            "message": message,
            "server": self.smtp_server
        })
        return True
    
    def get_delivery_status(self) -> dict:
        return {
            "total_sent": len(self.sent_messages),
            "last_server": self.smtp_server
        }

class SMSNotification(Notification):
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.sent_messages = []
    
    def send(self, message: str, recipient: str) -> bool:
        # Simuler envoi SMS
        if len(message) > 160:
            return False  # SMS trop long
        
        self.sent_messages.append({
            "type": "sms",
            "recipient": recipient,
            "message": message
        })
        return True
    
    def get_delivery_status(self) -> dict:
        return {
            "total_sent": len(self.sent_messages),
            "api_key": self.api_key[:4] + "***"
        }

class PushNotification(Notification):
    def __init__(self, device_token: str):
        self.device_token = device_token
        self.sent_messages = []
    
    def send(self, message: str, recipient: str) -> bool:
        # Simuler push notification
        self.sent_messages.append({
            "type": "push",
            "recipient": recipient,
            "message": message,
            "device": self.device_token
        })
        return True
    
    def get_delivery_status(self) -> dict:
        return {
            "total_sent": len(self.sent_messages),
            "device_token": self.device_token
        }

"""
ÉTAPE 2 : FIXTURES PARAMÉTRÉES
"""

# tests/conftest.py
import pytest
from notification_system import (
    EmailNotification,
    SMSNotification,
    PushNotification
)

# ──────────────────────────────────────
# FIXTURE PARAMÉTRÉE : Type de notification
# ──────────────────────────────────────

@pytest.fixture(
    params=[
        ("email", {"smtp_server": "smtp.test.com"}),
        ("sms", {"api_key": "test_api_key_12345"}),
        ("push", {"device_token": "token_abcdef"}),
    ],
    ids=["Email", "SMS", "Push"]
)
def notification_service(request):
    """
    Fixture qui crée 3 types de services de notification.
    
    Chaque test utilisant cette fixture sera exécuté 3 fois :
    - Une fois avec EmailNotification
    - Une fois avec SMSNotification
    - Une fois avec PushNotification
    """
    notification_type, config = request.param
    
    # Créer le service approprié
    if notification_type == "email":
        service = EmailNotification(**config)
    elif notification_type == "sms":
        service = SMSNotification(**config)
    elif notification_type == "push":
        service = PushNotification(**config)
    
    yield service
    
    # Cleanup : Vider les messages envoyés
    service.sent_messages.clear()

# ──────────────────────────────────────
# FIXTURE PARAMÉTRÉE : Messages de test
# ──────────────────────────────────────

@pytest.fixture(
    params=[
        ("short", "Hi!"),
        ("medium", "This is a test message for notifications."),
        ("long", "A" * 200),  # Message très long
    ],
    ids=lambda x: f"msg_{x[0]}"
)
def test_message(request):
    """Différentes longueurs de messages"""
    msg_type, content = request.param
    return content

# ──────────────────────────────────────
# FIXTURE PARAMÉTRÉE : Destinataires
# ──────────────────────────────────────

@pytest.fixture(
    params=[
        "user@example.com",
        "+1234567890",
        "device_token_xyz",
    ],
    ids=["email_recipient", "phone_recipient", "device_recipient"]
)
def recipient(request):
    """Différents types de destinataires"""
    return request.param

"""
ÉTAPE 3 : TESTS
"""

# tests/test_notifications.py

def test_send_notification(notification_service, recipient):
    """
    Test envoi basique.
    
    Exécuté 3 × 3 = 9 fois !
    (3 services × 3 destinataires)
    """
    message = "Test notification"
    result = notification_service.send(message, recipient)
    
    # Tous les services doivent pouvoir envoyer
    assert result is True

def test_delivery_status(notification_service):
    """
    Test statut de livraison.
    
    Exécuté 3 fois (3 services)
    """
    # Envoyer message
    notification_service.send("Test", "recipient@test.com")
    
    # Vérifier statut
    status = notification_service.get_delivery_status()
    assert status["total_sent"] == 1

def test_multiple_sends(notification_service):
    """
    Test envois multiples.
    
    Exécuté 3 fois
    """
    for i in range(5):
        notification_service.send(f"Message {i}", "recipient")
    
    status = notification_service.get_delivery_status()
    assert status["total_sent"] == 5

def test_message_length_handling(notification_service, test_message):
    """
    Test avec différentes longueurs de messages.
    
    Exécuté 3 × 3 = 9 fois !
    (3 services × 3 longueurs)
    """
    result = notification_service.send(test_message, "recipient")
    
    # SMS a limite 160 caractères
    if isinstance(notification_service, SMSNotification):
        if len(test_message) > 160:
            assert result is False  # SMS trop long
        else:
            assert result is True
    else:
        # Email et Push acceptent tout
        assert result is True

# ──────────────────────────────────────
# TESTS SPÉCIFIQUES PAR TYPE
# ──────────────────────────────────────

@pytest.fixture(params=[
    "smtp.gmail.com",
    "smtp.yahoo.com",
    "smtp.outlook.com",
])
def smtp_server(request):
    """Différents serveurs SMTP"""
    return request.param

def test_email_smtp_server(smtp_server):
    """Test spécifique Email avec différents serveurs"""
    email_service = EmailNotification(smtp_server)
    email_service.send("Test", "user@test.com")
    
    status = email_service.get_delivery_status()
    assert status["last_server"] == smtp_server

"""
ÉTAPE 4 : TESTS COMBINATOIRES AVANCÉS
"""

# Combiner fixture paramétrée + parametrize

@pytest.mark.parametrize("count", [1, 5, 10, 100])
def test_bulk_send(notification_service, count):
    """
    Test envoi en masse.
    
    Exécuté 3 × 4 = 12 fois !
    (3 services × 4 counts)
    """
    for i in range(count):
        notification_service.send(f"Message {i}", "recipient")
    
    status = notification_service.get_delivery_status()
    assert status["total_sent"] == count

"""
ÉTAPE 5 : EXÉCUTION ET RÉSULTATS
"""

# Exécuter tous les tests
pytest tests/test_notifications.py -v

"""
Output attendu :

tests/test_notifications.py::test_send_notification[Email-email_recipient] PASSED
tests/test_notifications.py::test_send_notification[Email-phone_recipient] PASSED
tests/test_notifications.py::test_send_notification[Email-device_recipient] PASSED
tests/test_notifications.py::test_send_notification[SMS-email_recipient] PASSED
tests/test_notifications.py::test_send_notification[SMS-phone_recipient] PASSED
tests/test_notifications.py::test_send_notification[SMS-device_recipient] PASSED
tests/test_notifications.py::test_send_notification[Push-email_recipient] PASSED
tests/test_notifications.py::test_send_notification[Push-phone_recipient] PASSED
tests/test_notifications.py::test_send_notification[Push-device_recipient] PASSED

tests/test_notifications.py::test_delivery_status[Email] PASSED
tests/test_notifications.py::test_delivery_status[SMS] PASSED
tests/test_notifications.py::test_delivery_status[Push] PASSED

tests/test_notifications.py::test_message_length_handling[Email-msg_short] PASSED
tests/test_notifications.py::test_message_length_handling[Email-msg_medium] PASSED
tests/test_notifications.py::test_message_length_handling[Email-msg_long] PASSED
tests/test_notifications.py::test_message_length_handling[SMS-msg_short] PASSED
tests/test_notifications.py::test_message_length_handling[SMS-msg_medium] PASSED
tests/test_notifications.py::test_message_length_handling[SMS-msg_long] PASSED
tests/test_notifications.py::test_message_length_handling[Push-msg_short] PASSED
tests/test_notifications.py::test_message_length_handling[Push-msg_medium] PASSED
tests/test_notifications.py::test_message_length_handling[Push-msg_long] PASSED

tests/test_notifications.py::test_bulk_send[Email-1] PASSED
tests/test_notifications.py::test_bulk_send[Email-5] PASSED
tests/test_notifications.py::test_bulk_send[Email-10] PASSED
tests/test_notifications.py::test_bulk_send[Email-100] PASSED
tests/test_notifications.py::test_bulk_send[SMS-1] PASSED
tests/test_notifications.py::test_bulk_send[SMS-5] PASSED
tests/test_notifications.py::test_bulk_send[SMS-10] PASSED
tests/test_notifications.py::test_bulk_send[SMS-100] PASSED
tests/test_notifications.py::test_bulk_send[Push-1] PASSED
tests/test_notifications.py::test_bulk_send[Push-5] PASSED
tests/test_notifications.py::test_bulk_send[Push-10] PASSED
tests/test_notifications.py::test_bulk_send[Push-100] PASSED

========================== 36 passed in 0.15s ==========================


[IDEE] ANALYSE

- 4 fonctions de test
- Fixtures paramétrées créent 36 exécutions !
- Tests de compatibilité multi-canaux automatiques
- Couverture exhaustive avec peu de code


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 7
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Fixtures paramétrées avec params
[OK] request.param pour accéder au paramètre
[OK] IDs personnalisés pour lisibilité
[OK] Dépendances entre fixtures paramétrées
[OK] Tests combinatoires automatiques
[OK] parametrize vs fixture paramétrée
[OK] indirect pour parametrize via fixtures


[CLE] POINTS CLÉS

1. params=[...] = Liste de variations
2. request.param = Paramètre actuel
3. ids=[...] = Noms personnalisés
4. Tests combinatoires : n × m tests automatiques
5. Fixture paramétrée = Setup complexe
6. parametrize = Données simples


[OBJECTIF] PATTERNS ESSENTIELS

# Fixture paramétrée basique
@pytest.fixture(params=[val1, val2, val3])
def resource(request):
    return create_resource(request.param)

# Avec IDs personnalisés
@pytest.fixture(
    params=[val1, val2],
    ids=["name1", "name2"]
)
def resource(request):
    return create_resource(request.param)

# Avec setup/teardown
@pytest.fixture(params=["db1", "db2"])
def database(request):
    db = Database(request.param)
    db.connect()
    yield db
    db.disconnect()

# Combinatoire
@pytest.fixture(params=["a", "b"])
def fix1(request):
    return request.param

@pytest.fixture(params=[1, 2])
def fix2(request):
    return request.param

def test_combined(fix1, fix2):
    # Exécuté 2 × 2 = 4 fois
    pass


[GRAPHIQUE] QUAND UTILISER

Fixture paramétrée :
[OK] Tester plusieurs implémentations
[OK] Tests multi-platformes/navigateurs
[OK] Compatibilité multi-DB/formats
[OK] Setup/teardown complexe

parametrize :
[OK] Données de test simples
[OK] Pas de setup nécessaire
[OK] Local à un test


-> PROCHAINE ÉTAPE : Chapitre 8 - Fixtures Built-in

Vous allez apprendre :
- tmp_path et tmpdir
- capsys et capfd
- monkeypatch
- request
- Autres fixtures pytest natives

Les fixtures built-in sont très utiles ! [OUTILS]
"""

Excellent ! Le Chapitre 7 sur les fixtures paramétrées est terminé. 

Voulez-vous que je continue avec le **Chapitre 8 : Fixtures Built-in** ? Ce sont des fixtures fournies par pytest qui sont extrêmement utiles pour les cas d'usage courants (fichiers temporaires, capture de sortie, mocking, etc.).

Dites-moi si vous voulez continuer ! [RAPIDE]

Je continue avec le Chapitre 8 sur les Fixtures Built-in de pytest - ultra-détaillé comme toujours !

# ============================================================================
# [GUIDE] CHAPITRE 8 : FIXTURES BUILT-IN PYTEST
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Utiliser tmp_path et tmpdir (fichiers temporaires)
[OK] Capturer sortie avec capsys et capfd
[OK] Modifier comportement avec monkeypatch
[OK] Accéder métadonnées avec request
[OK] Gérer warnings avec recwarn
[OK] Utiliser cache pour optimisation
[OK] Toutes les fixtures built-in essentielles
"""


# ----------------------------------------------------------------------------
# [DOSSIER] tmp_path ET tmpdir : FICHIERS TEMPORAIRES
# ----------------------------------------------------------------------------

"""
PROBLÈME : TESTS AVEC FICHIERS

Tests doivent :
1. Créer fichiers de test
2. Ne pas polluer système
3. Nettoyer après exécution


[X] MAUVAISE APPROCHE
"""

def test_file_creation():
    # Créer fichier dans dossier réel
    with open('test_file.txt', 'w') as f:
        f.write("test")
    
    # Test
    with open('test_file.txt', 'r') as f:
        content = f.read()
    assert content == "test"
    
    # [X] Oublier de supprimer ?
    # [X] Conflit si plusieurs tests ?
    # [X] Pollution du système ?

"""
[OK] SOLUTION : tmp_path (Recommandé - Python 3.6+)

tmp_path = Fixture qui fournit un Path temporaire unique
"""

import pytest
from pathlib import Path

def test_file_creation(tmp_path):
    """
    tmp_path : objet pathlib.Path
    Dossier temporaire UNIQUE pour ce test
    """
    # Créer fichier dans dossier temporaire
    file_path = tmp_path / "test_file.txt"
    file_path.write_text("Hello World")
    
    # Lire et vérifier
    content = file_path.read_text()
    assert content == "Hello World"
    
    # [OK] Pas besoin de cleanup !
    # tmp_path automatiquement supprimé après le test

"""
[IDEE] DÉCRYPTAGE tmp_path

Type : pathlib.Path
Scope : function (nouveau dossier par test)
Location : /tmp/pytest-of-<user>/pytest-<id>/<test-name>/
Auto-cleanup : Oui (après le test)


MÉTHODES pathlib.Path UTILES
"""

def test_tmp_path_operations(tmp_path):
    """Exploration de tmp_path"""
    
    # tmp_path est un Path
    assert isinstance(tmp_path, Path)
    
    # Créer fichier
    file1 = tmp_path / "file1.txt"
    file1.write_text("Content 1")
    
    # Créer sous-dossier
    subdir = tmp_path / "subdir"
    subdir.mkdir()
    
    # Fichier dans sous-dossier
    file2 = subdir / "file2.txt"
    file2.write_text("Content 2")
    
    # Vérifications
    assert file1.exists()
    assert subdir.is_dir()
    assert file2.exists()
    
    # Lister contenu
    files = list(tmp_path.iterdir())
    assert len(files) == 2  # file1 et subdir

"""
EXEMPLES PRATIQUES
"""

# ──────────────────────────────
# EXEMPLE 1 : TESTER CSV
# ──────────────────────────────

def test_csv_parsing(tmp_path):
    """Tester parsing de fichier CSV"""
    import csv
    
    # Créer CSV temporaire
    csv_file = tmp_path / "data.csv"
    
    # Écrire données
    with csv_file.open('w', newline='') as f:
        writer = csv.writer(f)
        writer.writerow(["Name", "Age", "City"])
        writer.writerow(["Alice", "30", "Paris"])
        writer.writerow(["Bob", "25", "London"])
    
    # Parser CSV
    from my_app import parse_csv
    data = parse_csv(csv_file)
    
    # Vérifier
    assert len(data) == 2
    assert data[0]["Name"] == "Alice"
    assert data[1]["Age"] == "25"

# ──────────────────────────────
# EXEMPLE 2 : TESTER JSON
# ──────────────────────────────

def test_json_save_load(tmp_path):
    """Tester sauvegarde/chargement JSON"""
    import json
    
    # Données de test
    data = {
        "users": [
            {"id": 1, "name": "Alice"},
            {"id": 2, "name": "Bob"}
        ]
    }
    
    # Sauvegarder
    json_file = tmp_path / "data.json"
    with json_file.open('w') as f:
        json.dump(data, f)
    
    # Charger
    with json_file.open('r') as f:
        loaded_data = json.load(f)
    
    # Vérifier
    assert loaded_data == data

# ──────────────────────────────
# EXEMPLE 3 : TESTER CONFIGURATION
# ──────────────────────────────

def test_config_file(tmp_path):
    """Tester chargement de configuration"""
    # Créer fichier config
    config_file = tmp_path / "config.ini"
    config_file.write_text("""
[database]
host = localhost
port = 5432
name = testdb

[api]
key = secret123
timeout = 30
""")
    
    # Charger config
    from my_app import load_config
    config = load_config(config_file)
    
    # Vérifier
    assert config["database"]["host"] == "localhost"
    assert config["database"]["port"] == "5432"
    assert config["api"]["key"] == "secret123"

# ──────────────────────────────
# EXEMPLE 4 : TESTER IMAGE
# ──────────────────────────────

def test_image_processing(tmp_path):
    """Tester traitement d'image"""
    from PIL import Image
    
    # Créer image test
    img_path = tmp_path / "test.png"
    img = Image.new('RGB', (100, 100), color='red')
    img.save(img_path)
    
    # Traiter image
    from my_app import resize_image
    output_path = tmp_path / "resized.png"
    resize_image(img_path, output_path, (50, 50))
    
    # Vérifier
    resized = Image.open(output_path)
    assert resized.size == (50, 50)

# ──────────────────────────────
# EXEMPLE 5 : STRUCTURE DE DOSSIERS
# ──────────────────────────────

def test_directory_structure(tmp_path):
    """Tester création de structure de dossiers"""
    # Créer structure
    project = tmp_path / "my_project"
    project.mkdir()
    
    (project / "src").mkdir()
    (project / "tests").mkdir()
    (project / "docs").mkdir()
    
    # Créer fichiers
    (project / "README.md").write_text("# My Project")
    (project / "src" / "__init__.py").write_text("")
    (project / "tests" / "test_main.py").write_text("")
    
    # Vérifier structure
    assert project.exists()
    assert (project / "src").is_dir()
    assert (project / "tests").is_dir()
    assert (project / "docs").is_dir()
    assert (project / "README.md").is_file()

"""
tmpdir : ANCIENNE VERSION (py.path.local)

tmpdir = Fixture legacy (avant pathlib)
"""

def test_with_tmpdir(tmpdir):
    """
    tmpdir : objet py.path.local (legacy)
    API différente de pathlib
    """
    # Créer fichier (syntaxe différente)
    file_path = tmpdir.join("test.txt")
    file_path.write("Content")
    
    # Lire
    content = file_path.read()
    assert content == "Content"

"""
[IDEE] tmp_path vs tmpdir

┌─────────────┬──────────────────┬─────────────────┐
│             │ tmp_path         │ tmpdir          │
├─────────────┼──────────────────┼─────────────────┤
│ Type        │ pathlib.Path     │ py.path.local   │
│ Style       │ Moderne          │ Legacy          │
│ API         │ pathlib standard │ py.path custom  │
│ Recommandé  │ [OK] OUI           │ [X] Non          │
└─────────────┴──────────────────┴─────────────────┘

[OK] UTILISEZ tmp_path (moderne, standard)


tmp_path_factory : PARTAGER ENTRE TESTS

tmp_path_factory = Créer dossiers temp scope plus large
"""

@pytest.fixture(scope="session")
def image_dataset(tmp_path_factory):
    """
    Dataset d'images partagé pour toute la session
    Créé une fois, utilisé par tous les tests
    """
    dataset_dir = tmp_path_factory.mktemp("dataset")
    
    # Créer images de test
    from PIL import Image
    for i in range(10):
        img = Image.new('RGB', (100, 100), color='blue')
        img.save(dataset_dir / f"image_{i}.png")
    
    return dataset_dir

def test_image_count(image_dataset):
    """Utilise dataset partagé"""
    images = list(image_dataset.glob("*.png"))
    assert len(images) == 10

def test_image_size(image_dataset):
    """Utilise même dataset"""
    from PIL import Image
    img = Image.open(image_dataset / "image_0.png")
    assert img.size == (100, 100)


# ----------------------------------------------------------------------------
# [TELEVISION] capsys, capfd, caplog : CAPTURE DE SORTIE
# ----------------------------------------------------------------------------

"""
PROBLÈME : TESTER PRINT ET LOGS

Comment tester qu'une fonction :
- Affiche le bon message ?
- Log correctement ?


[OK] SOLUTION : capsys (Capture System Output)

capsys = Capture stdout et stderr
"""

def greet(name):
    """Fonction qui affiche message"""
    print(f"Hello, {name}!")

def test_greet_output(capsys):
    """
    capsys : Fixture pour capturer stdout/stderr
    """
    # Appeler fonction
    greet("Alice")
    
    # Capturer sortie
    captured = capsys.readouterr()
    
    # Vérifier stdout
    assert captured.out == "Hello, Alice!\n"
    assert captured.err == ""  # Pas d'erreur

"""
[IDEE] DÉCRYPTAGE capsys

capsys.readouterr() -> Retourne CaptureResult
    .out : stdout (print)
    .err : stderr (erreurs)


MÉTHODES capsys
"""

def test_capsys_methods(capsys):
    """Exploration de capsys"""
    
    # 1. Capturer sortie
    print("Message 1")
    captured = capsys.readouterr()
    assert captured.out == "Message 1\n"
    
    # 2. Plusieurs captures
    print("Message 2")
    captured = capsys.readouterr()
    assert captured.out == "Message 2\n"
    
    # 3. Capture vide après readouterr
    captured = capsys.readouterr()
    assert captured.out == ""
    
    # 4. Désactiver capture temporairement
    with capsys.disabled():
        print("Ceci sera affiché même pendant le test")
    
    # 5. stdout ET stderr
    import sys
    print("Normal output")
    print("Error output", file=sys.stderr)
    captured = capsys.readouterr()
    assert captured.out == "Normal output\n"
    assert captured.err == "Error output\n"

"""
EXEMPLES PRATIQUES
"""

# ──────────────────────────────
# EXEMPLE 1 : TESTER CLI
# ──────────────────────────────

def display_user_info(user):
    """Affiche info utilisateur"""
    print(f"Name: {user['name']}")
    print(f"Email: {user['email']}")
    print(f"Age: {user['age']}")

def test_display_user_info(capsys):
    """Tester affichage"""
    user = {
        "name": "Alice",
        "email": "alice@test.com",
        "age": 30
    }
    
    display_user_info(user)
    
    captured = capsys.readouterr()
    
    # Vérifier contenu
    assert "Name: Alice" in captured.out
    assert "Email: alice@test.com" in captured.out
    assert "Age: 30" in captured.out

# ──────────────────────────────
# EXEMPLE 2 : TESTER PROGRESS BAR
# ──────────────────────────────

def process_items(items):
    """Traite items avec affichage progression"""
    total = len(items)
    for i, item in enumerate(items, 1):
        # Traitement
        result = item * 2
        # Affichage progression
        print(f"Processing {i}/{total}...", end='\r')
    print()  # Nouvelle ligne à la fin
    return True

def test_process_items_output(capsys):
    """Tester affichage progression"""
    items = [1, 2, 3, 4, 5]
    
    process_items(items)
    
    captured = capsys.readouterr()
    
    # Vérifier dernier message
    assert "Processing 5/5" in captured.out

# ──────────────────────────────
# EXEMPLE 3 : TESTER WARNINGS
# ──────────────────────────────

import sys

def risky_operation(value):
    """Opération avec warnings"""
    if value < 0:
        print("WARNING: Negative value", file=sys.stderr)
        return 0
    return value * 2

def test_risky_operation_warning(capsys):
    """Tester warning dans stderr"""
    result = risky_operation(-5)
    
    captured = capsys.readouterr()
    
    # Vérifier warning
    assert "WARNING: Negative value" in captured.err
    assert result == 0

"""
capfd : CAPTURE FILE DESCRIPTORS

capfd = Comme capsys mais capture aussi subprocess
"""

def test_subprocess_output(capfd):
    """Tester sortie de subprocess"""
    import subprocess
    
    # Exécuter commande
    subprocess.run(["echo", "Hello from subprocess"])
    
    # Capturer
    captured = capfd.readouterr()
    assert "Hello from subprocess" in captured.out

"""
[IDEE] capsys vs capfd

capsys : Capture sys.stdout/stderr (Python)
capfd  : Capture file descriptors 1/2 (système + subprocess)

Utilisez capfd si :
- Tests avec subprocess
- Bibliothèques C
- Code bas niveau


caplog : CAPTURE LOGS

caplog = Capture messages de logging
"""

import logging

def process_data(data):
    """Fonction avec logging"""
    logger = logging.getLogger(__name__)
    
    logger.info("Starting data processing")
    
    if not data:
        logger.warning("Empty data received")
        return []
    
    logger.debug(f"Processing {len(data)} items")
    
    try:
        result = [x * 2 for x in data]
        logger.info("Processing completed successfully")
        return result
    except Exception as e:
        logger.error(f"Error during processing: {e}")
        raise

def test_process_data_logging(caplog):
    """
    caplog : Fixture pour capturer logs
    """
    # Configurer niveau
    caplog.set_level(logging.INFO)
    
    # Appeler fonction
    data = [1, 2, 3]
    result = process_data(data)
    
    # Vérifier logs
    assert "Starting data processing" in caplog.text
    assert "Processing completed successfully" in caplog.text
    
    # Vérifier records
    assert len(caplog.records) == 2  # info start + info completed
    
    # Vérifier niveau
    assert caplog.records[0].levelname == "INFO"

"""
MÉTHODES caplog
"""

def test_caplog_methods(caplog):
    """Exploration de caplog"""
    logger = logging.getLogger("test")
    
    # Logs de différents niveaux
    logger.debug("Debug message")
    logger.info("Info message")
    logger.warning("Warning message")
    logger.error("Error message")
    
    # 1. caplog.text : Tout le texte
    assert "Info message" in caplog.text
    
    # 2. caplog.records : Liste de LogRecord
    assert len(caplog.records) == 4
    
    # 3. Filtrer par niveau
    warnings = [r for r in caplog.records if r.levelname == "WARNING"]
    assert len(warnings) == 1
    
    # 4. Accéder aux détails
    first_record = caplog.records[0]
    assert first_record.levelname == "DEBUG"
    assert first_record.message == "Debug message"
    assert first_record.name == "test"
    
    # 5. Clear logs
    caplog.clear()
    logger.info("After clear")
    assert len(caplog.records) == 1

"""
EXEMPLE COMPLET : TESTER APPLICATION AVEC LOGS
"""

# app.py
import logging

logger = logging.getLogger(__name__)

class UserService:
    def __init__(self):
        self.users = {}
        self._next_id = 1
    
    def create_user(self, name, email):
        """Crée utilisateur avec logging"""
        logger.info(f"Creating user: {name}")
        
        # Validation
        if not name:
            logger.error("User name cannot be empty")
            raise ValueError("Name required")
        
        if not email or '@' not in email:
            logger.error(f"Invalid email: {email}")
            raise ValueError("Invalid email")
        
        # Créer user
        user = {
            "id": self._next_id,
            "name": name,
            "email": email
        }
        
        self.users[self._next_id] = user
        self._next_id += 1
        
        logger.info(f"User created successfully: {user['id']}")
        logger.debug(f"Total users: {len(self.users)}")
        
        return user

# test_app.py
def test_user_creation_success(caplog):
    """Test création user réussie"""
    caplog.set_level(logging.INFO)
    
    service = UserService()
    user = service.create_user("Alice", "alice@test.com")
    
    # Vérifier user
    assert user["name"] == "Alice"
    
    # Vérifier logs
    assert "Creating user: Alice" in caplog.text
    assert "User created successfully" in caplog.text
    
    # Vérifier ordre
    assert caplog.records[0].message == "Creating user: Alice"
    assert "User created successfully" in caplog.records[1].message

def test_user_creation_invalid_email(caplog):
    """Test validation email"""
    caplog.set_level(logging.ERROR)
    
    service = UserService()
    
    with pytest.raises(ValueError, match="Invalid email"):
        service.create_user("Bob", "invalid-email")
    
    # Vérifier log d'erreur
    assert "Invalid email: invalid-email" in caplog.text
    assert caplog.records[0].levelname == "ERROR"


# ----------------------------------------------------------------------------
# [OUTIL] monkeypatch : MODIFIER COMPORTEMENT
# ----------------------------------------------------------------------------

"""
PROBLÈME : TESTER CODE AVEC DÉPENDANCES EXTERNES

Comment tester :
- Code qui appelle API ?
- Code qui lit variables d'environnement ?
- Code qui utilise datetime.now() ?


[OK] SOLUTION : monkeypatch

monkeypatch = Modifier temporairement comportement
"""

# Code à tester
import os
import requests

def get_api_key():
    """Lit clé API depuis env"""
    return os.getenv("API_KEY")

def fetch_user_data(user_id):
    """Récupère données user depuis API"""
    api_key = get_api_key()
    url = f"https://api.example.com/users/{user_id}"
    
    response = requests.get(url, headers={"Authorization": f"Bearer {api_key}"})
    return response.json()

"""
Test SANS monkeypatch (problématique)
"""

def test_fetch_user_data():
    # [X] Nécessite vraie API_KEY dans env
    # [X] Fait vraie requête HTTP
    # [X] Dépend d'API externe
    data = fetch_user_data(123)
    assert data["id"] == 123

"""
[OK] Test AVEC monkeypatch
"""

def test_get_api_key(monkeypatch):
    """
    monkeypatch : Fixture pour modifier comportement
    """
    # Modifier variable d'environnement
    monkeypatch.setenv("API_KEY", "test_key_12345")
    
    # Tester
    api_key = get_api_key()
    assert api_key == "test_key_12345"

def test_fetch_user_data(monkeypatch):
    """Tester avec mock de requests"""
    # Mock variable env
    monkeypatch.setenv("API_KEY", "test_key")
    
    # Mock requests.get
    def mock_get(url, headers=None):
        # Créer mock response
        class MockResponse:
            def json(self):
                return {"id": 123, "name": "Alice"}
        
        return MockResponse()
    
    monkeypatch.setattr(requests, "get", mock_get)
    
    # Tester
    data = fetch_user_data(123)
    assert data["id"] == 123
    assert data["name"] == "Alice"

"""
[IDEE] MÉTHODES monkeypatch

setenv(name, value)         : Modifier variable env
delenv(name)                : Supprimer variable env
setattr(obj, name, value)   : Modifier attribut
delattr(obj, name)          : Supprimer attribut
setitem(dict, key, value)   : Modifier dict
delitem(dict, key)          : Supprimer clé dict
chdir(path)                 : Changer directory
syspath_prepend(path)       : Ajouter au sys.path


EXEMPLES DÉTAILLÉS
"""

# ──────────────────────────────
# 1. VARIABLES D'ENVIRONNEMENT
# ──────────────────────────────

def test_env_variables(monkeypatch):
    """Tester avec variables env"""
    
    # Définir variable
    monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test")
    monkeypatch.setenv("DEBUG", "true")
    
    # Vérifier
    assert os.getenv("DATABASE_URL") == "postgresql://localhost/test"
    assert os.getenv("DEBUG") == "true"
    
    # Supprimer variable
    monkeypatch.delenv("DEBUG")
    assert os.getenv("DEBUG") is None

# ──────────────────────────────
# 2. MODIFIER DATETIME
# ──────────────────────────────

from datetime import datetime

def get_current_timestamp():
    """Retourne timestamp actuel"""
    return datetime.now().timestamp()

def test_timestamp(monkeypatch):
    """Tester avec datetime fixe"""
    
    # Datetime fixe
    fixed_datetime = datetime(2024, 1, 15, 12, 0, 0)
    
    # Mock datetime.now
    class MockDatetime:
        @classmethod
        def now(cls):
            return fixed_datetime
    
    monkeypatch.setattr("datetime.datetime", MockDatetime)
    
    # Tester
    timestamp = get_current_timestamp()
    expected = fixed_datetime.timestamp()
    assert timestamp == expected

# ──────────────────────────────
# 3. MODIFIER FONCTION
# ──────────────────────────────

import random

def generate_random_id():
    """Génère ID aléatoire"""
    return random.randint(1000, 9999)

def test_random_id(monkeypatch):
    """Tester avec random fixe"""
    
    # Mock random.randint
    def mock_randint(a, b):
        return 5555  # Toujours 5555
    
    monkeypatch.setattr(random, "randint", mock_randint)
    
    # Tester
    id1 = generate_random_id()
    id2 = generate_random_id()
    
    assert id1 == 5555
    assert id2 == 5555  # Toujours pareil

# ──────────────────────────────
# 4. MODIFIER ATTRIBUT D'OBJET
# ──────────────────────────────

class Database:
    connection_string = "production_db"
    
    def connect(self):
        return f"Connected to {self.connection_string}"

def test_database_connection(monkeypatch):
    """Tester avec DB de test"""
    
    db = Database()
    
    # Modifier attribut
    monkeypatch.setattr(db, "connection_string", "test_db")
    
    result = db.connect()
    assert result == "Connected to test_db"

# ──────────────────────────────
# 5. MODIFIER DICTIONNAIRE
# ──────────────────────────────

app_config = {
    "timeout": 30,
    "retries": 3
}

def get_timeout():
    return app_config["timeout"]

def test_config(monkeypatch):
    """Tester avec config modifiée"""
    
    # Modifier valeur
    monkeypatch.setitem(app_config, "timeout", 60)
    
    assert get_timeout() == 60
    
    # Supprimer clé
    monkeypatch.delitem(app_config, "retries")
    assert "retries" not in app_config

# ──────────────────────────────
# 6. CHANGER DIRECTORY
# ──────────────────────────────

def test_working_directory(monkeypatch, tmp_path):
    """Tester avec directory temporaire"""
    
    # Changer vers tmp_path
    monkeypatch.chdir(tmp_path)
    
    # Vérifier
    import os
    assert os.getcwd() == str(tmp_path)
    
    # Créer fichier dans current dir
    with open("test.txt", "w") as f:
        f.write("test")
    
    assert (tmp_path / "test.txt").exists()

"""
EXEMPLE COMPLET : APPLICATION MÉTÉO
"""

# weather_app.py
import requests
import os
from datetime import datetime

class WeatherService:
    def __init__(self):
        self.api_key = os.getenv("WEATHER_API_KEY")
        self.base_url = "https://api.weather.com"
    
    def get_weather(self, city):
        """Récupère météo pour une ville"""
        if not self.api_key:
            raise ValueError("API key not configured")
        
        url = f"{self.base_url}/current"
        params = {
            "city": city,
            "key": self.api_key
        }
        
        response = requests.get(url, params=params)
        
        if response.status_code != 200:
            raise ConnectionError("API unavailable")
        
        data = response.json()
        
        return {
            "city": city,
            "temperature": data["temp"],
            "condition": data["condition"],
            "timestamp": datetime.now().isoformat()
        }

# test_weather_app.py
def test_weather_service_success(monkeypatch):
    """Test récupération météo réussie"""
    
    # Mock API key
    monkeypatch.setenv("WEATHER_API_KEY", "test_key_123")
    
    # Mock datetime
    from datetime import datetime
    fixed_time = datetime(2024, 1, 15, 12, 0, 0)
    
    class MockDatetime:
        @classmethod
        def now(cls):
            return fixed_time
        
        @classmethod
        def isoformat(cls):
            return fixed_time.isoformat()
    
    monkeypatch.setattr("datetime.datetime", MockDatetime)
    
    # Mock requests.get
    def mock_get(url, params=None):
        class MockResponse:
            status_code = 200
            
            def json(self):
                return {
                    "temp": 22.5,
                    "condition": "Sunny"
                }
        
        return MockResponse()
    
    monkeypatch.setattr(requests, "get", mock_get)
    
    # Tester
    service = WeatherService()
    weather = service.get_weather("Paris")
    
    assert weather["city"] == "Paris"
    assert weather["temperature"] == 22.5
    assert weather["condition"] == "Sunny"
    assert weather["timestamp"] == fixed_time.isoformat()

def test_weather_service_no_api_key(monkeypatch):
    """Test sans API key"""
    
    # S'assurer qu'API key n'existe pas
    monkeypatch.delenv("WEATHER_API_KEY", raising=False)
    
    service = WeatherService()
    
    with pytest.raises(ValueError, match="API key not configured"):
        service.get_weather("Paris")

def test_weather_service_api_error(monkeypatch):
    """Test avec erreur API"""
    
    monkeypatch.setenv("WEATHER_API_KEY", "test_key")
    
    # Mock requests avec erreur
    def mock_get(url, params=None):
        class MockResponse:
            status_code = 500
        
        return MockResponse()
    
    monkeypatch.setattr(requests, "get", mock_get)
    
    service = WeatherService()
    
    with pytest.raises(ConnectionError, match="API unavailable"):
        service.get_weather("Paris")


# ----------------------------------------------------------------------------
# [LISTE] request : MÉTADONNÉES DU TEST
# ----------------------------------------------------------------------------

"""
request = Fixture donnant accès aux métadonnées du test

Informations disponibles :
- Nom du test
- Markers
- Paramètres (pour fixtures paramétrées)
- Node (arbre de tests)
- Config pytest
"""

def test_request_info(request):
    """Explorer request"""
    
    # Nom de la fonction de test
    print(f"Test name: {request.node.name}")
    
    # Nom complet (avec path)
    print(f"Full name: {request.node.nodeid}")
    
    # Markers
    print(f"Markers: {list(request.node.iter_markers())}")
    
    # Config pytest
    print(f"Config: {request.config}")

"""
UTILISATION PRATIQUE : NOMS DE FICHIERS DYNAMIQUES
"""

@pytest.fixture
def test_output_file(request, tmp_path):
    """Crée fichier de sortie basé sur nom du test"""
    
    # Nom du test
    test_name = request.node.name
    
    # Créer fichier avec nom du test
    output_file = tmp_path / f"{test_name}_output.txt"
    
    yield output_file
    
    # Log dans le fichier après le test
    with output_file.open('a') as f:
        f.write(f"\nTest '{test_name}' completed\n")

def test_example_1(test_output_file):
    """Test qui utilise fichier dynamique"""
    test_output_file.write_text("Data from test 1")
    assert test_output_file.name == "test_example_1_output.txt"

def test_example_2(test_output_file):
    """Autre test avec fichier différent"""
    test_output_file.write_text("Data from test 2")
    assert test_output_file.name == "test_example_2_output.txt"

"""
ACCÉDER AUX MARKERS
"""

@pytest.fixture
def skip_on_marker(request):
    """Skip test si marker 'skip_this' présent"""
    if request.node.get_closest_marker("skip_this"):
        pytest.skip("Test marked to skip")

@pytest.mark.skip_this
def test_will_be_skipped(skip_on_marker):
    """Ce test sera skippé"""
    assert False  # Jamais exécuté

def test_will_run(skip_on_marker):
    """Ce test s'exécute normalement"""
    assert True

"""
CONFIGURATION DYNAMIQUE
"""

@pytest.fixture
def database_type(request):
    """Type de DB basé sur marker"""
    
    # Chercher marker db_type
    marker = request.node.get_closest_marker("db_type")
    
    if marker:
        return marker.args[0]
    else:
        return "sqlite"  # Défaut

@pytest.mark.db_type("postgresql")
def test_with_postgres(database_type):
    """Test avec PostgreSQL"""
    assert database_type == "postgresql"

@pytest.mark.db_type("mysql")
def test_with_mysql(database_type):
    """Test avec MySQL"""
    assert database_type == "mysql"

def test_with_default(database_type):
    """Test avec DB par défaut"""
    assert database_type == "sqlite"


# ----------------------------------------------------------------------------
# [ATTENTION] recwarn : CAPTURE DE WARNINGS
# ----------------------------------------------------------------------------

"""
recwarn = Capture warnings Python

Utile pour :
- Vérifier qu'un warning est émis
- Tester code déprécié
- Valider messages de warning
"""

import warnings

def deprecated_function():
    """Fonction dépréciée"""
    warnings.warn(
        "deprecated_function is deprecated, use new_function instead",
        DeprecationWarning
    )
    return "result"

def test_deprecated_warning(recwarn):
    """
    recwarn : Fixture pour capturer warnings
    """
    # Appeler fonction
    result = deprecated_function()
    
    # Vérifier résultat
    assert result == "result"
    
    # Vérifier warning
    assert len(recwarn) == 1
    
    warning = recwarn[0]
    assert issubclass(warning.category, DeprecationWarning)
    assert "deprecated_function is deprecated" in str(warning.message)

"""
MÉTHODES recwarn
"""

def test_multiple_warnings(recwarn):
    """Tester multiples warnings"""
    
    # Émettre warnings
    warnings.warn("Warning 1", UserWarning)
    warnings.warn("Warning 2", FutureWarning)
    warnings.warn("Warning 3", UserWarning)
    
    # Vérifier nombre
    assert len(recwarn) == 3
    
    # Filtrer par type
    user_warnings = [w for w in recwarn if issubclass(w.category, UserWarning)]
    assert len(user_warnings) == 2
    
    # Vérifier messages
    messages = [str(w.message) for w in recwarn]
    assert "Warning 1" in messages
    assert "Warning 2" in messages
    assert "Warning 3" in messages
    
    # Clear warnings
    recwarn.clear()
    warnings.warn("Warning 4", UserWarning)
    assert len(recwarn) == 1

"""
DIFFÉRENCE pytest.warns vs recwarn

pytest.warns : Vérifier qu'UN warning spécifique est émis
recwarn      : Capturer TOUS les warnings pour inspection
"""

def test_with_pytest_warns():
    """Avec pytest.warns"""
    with pytest.warns(DeprecationWarning, match="deprecated"):
        deprecated_function()

def test_with_recwarn(recwarn):
    """Avec recwarn"""
    deprecated_function()
    
    assert len(recwarn) == 1
    assert "deprecated" in str(recwarn[0].message)


# ----------------------------------------------------------------------------
# [SAUVEGARDE] cache : OPTIMISATION ENTRE EXÉCUTIONS
# ----------------------------------------------------------------------------

"""
cache = Persister données entre exécutions de tests

Stockage : .pytest_cache/

Utile pour :
- Résultats de calculs lourds
- Données de configuration
- Derniers tests échoués (--lf, --ff)
"""

def test_cache_usage(cache):
    """
    cache : Fixture pour stocker/récupérer données
    """
    # Stocker valeur
    cache.set("my_key", {"data": "value"})
    
    # Récupérer valeur
    value = cache.get("my_key", None)
    assert value == {"data": "value"}

"""
EXEMPLE : CACHE DE RÉSULTATS LOURDS
"""

import time

def expensive_computation():
    """Calcul coûteux (simule 5 secondes)"""
    time.sleep(5)
    return {"result": "computed value"}

@pytest.fixture(scope="session")
def computed_data(cache):
    """Calcul avec cache"""
    
    # Essayer de récupérer du cache
    cached = cache.get("computed_data", None)
    
    if cached is not None:
        print("Using cached result")
        return cached
    
    # Sinon, calculer
    print("Computing (this will take 5 seconds)...")
    result = expensive_computation()
    
    # Stocker dans cache
    cache.set("computed_data", result)
    
    return result

def test_1(computed_data):
    """Premier test"""
    assert computed_data["result"] == "computed value"

def test_2(computed_data):
    """Deuxième test (réutilise cache)"""
    assert computed_data["result"] == "computed value"

"""
Première exécution : 5 secondes (calcul)
Exécutions suivantes : Instantané (cache)


MÉTHODES cache
"""

def test_cache_methods(cache):
    """Explorer cache"""
    
    # 1. set : Stocker
    cache.set("key1", "value1")
    cache.set("key2", [1, 2, 3])
    cache.set("key3", {"nested": {"data": True}})
    
    # 2. get : Récupérer
    assert cache.get("key1", None) == "value1"
    assert cache.get("key2", None) == [1, 2, 3]
    
    # 3. get avec défaut
    assert cache.get("nonexistent", "default") == "default"
    
    # 4. Vérifier clés (via cache.mkdir)
    # Cache utilise structure de répertoires


# ----------------------------------------------------------------------------
# [PACKAGE] AUTRES FIXTURES BUILT-IN UTILES
# ----------------------------------------------------------------------------

"""
pytestconfig : Configuration pytest
"""

def test_pytestconfig(pytestconfig):
    """Accéder à la config pytest"""
    
    # Options CLI
    verbose = pytestconfig.getoption("verbose")
    print(f"Verbose: {verbose}")
    
    # Fichier ini
    ini_path = pytestconfig.inifile
    print(f"Config file: {ini_path}")
    
    # Rootdir
    rootdir = pytestconfig.rootdir
    print(f"Root dir: {rootdir}")

"""
doctest_namespace : Namespace pour doctests
"""

@pytest.fixture(autouse=True)
def add_np(doctest_namespace):
    """Ajouter imports au namespace doctest"""
    import numpy as np
    doctest_namespace["np"] = np

"""
Maintenant tous les doctests ont accès à np


record_property : Enregistrer propriétés pour rapport
"""

def test_with_properties(record_property):
    """Test avec métadonnées"""
    record_property("browser", "chrome")
    record_property("os", "linux")
    record_property("resolution", "1920x1080")
    
    assert True

"""
Propriétés ajoutées au rapport XML


record_testsuite_property : Propriétés de la suite
"""

def test_suite_property(record_testsuite_property):
    """Propriétés de la suite complète"""
    record_testsuite_property("suite_version", "1.0.0")
    record_testsuite_property("environment", "testing")
    
    assert True


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 8 : FIXTURES BUILT-IN
# ----------------------------------------------------------------------------

"""
OBJECTIF : Application complète utilisant toutes les fixtures built-in


ÉTAPE 1 : APPLICATION À TESTER
"""

# report_generator.py
import os
import logging
from datetime import datetime
from pathlib import Path
import json

logger = logging.getLogger(__name__)

class ReportGenerator:
    """Générateur de rapports"""
    
    def __init__(self, output_dir=None):
        """
        Args:
            output_dir: Dossier de sortie (défaut: REPORT_DIR env var)
        """
        self.output_dir = output_dir or os.getenv("REPORT_DIR", "./reports")
        self.output_dir = Path(self.output_dir)
        
        logger.info(f"Report generator initialized with output: {self.output_dir}")
    
    def ensure_output_dir(self):
        """Crée dossier de sortie si nécessaire"""
        if not self.output_dir.exists():
            logger.debug(f"Creating output directory: {self.output_dir}")
            self.output_dir.mkdir(parents=True, exist_ok=True)
    
    def generate_report(self, data, format="json"):
        """
        Génère rapport.
        
        Args:
            data: Données du rapport
            format: Format (json, txt, csv)
        
        Returns:
            Path du fichier créé
        """
        self.ensure_output_dir()
        
        # Timestamp pour nom de fichier
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"report_{timestamp}.{format}"
        filepath = self.output_dir / filename
        
        logger.info(f"Generating {format} report: {filepath}")
        
        try:
            if format == "json":
                with filepath.open('w') as f:
                    json.dump(data, f, indent=2)
            
            elif format == "txt":
                with filepath.open('w') as f:
                    f.write("=== REPORT ===\n")
                    for key, value in data.items():
                        f.write(f"{key}: {value}\n")
            
            elif format == "csv":
                import csv
                with filepath.open('w', newline='') as f:
                    if data:
                        writer = csv.DictWriter(f, fieldnames=data[0].keys())
                        writer.writeheader()
                        writer.writerows(data)
            
            else:
                logger.error(f"Unsupported format: {format}")
                raise ValueError(f"Unsupported format: {format}")
            
            logger.info(f"Report generated successfully: {filepath}")
            print(f"[OK] Report saved to: {filepath}")
            
            return filepath
        
        except Exception as e:
            logger.error(f"Failed to generate report: {e}")
            raise
    
    def list_reports(self):
        """Liste tous les rapports générés"""
        if not self.output_dir.exists():
            logger.warning("Output directory does not exist")
            return []
        
        reports = list(self.output_dir.glob("report_*.*"))
        logger.debug(f"Found {len(reports)} reports")
        
        return reports

"""
ÉTAPE 2 : TESTS AVEC FIXTURES BUILT-IN
"""

# tests/test_report_generator.py
import pytest
import os
import json
from pathlib import Path
from datetime import datetime
import logging

from report_generator import ReportGenerator

# ──────────────────────────────────────
# TESTS AVEC tmp_path
# ──────────────────────────────────────

def test_report_generator_init(tmp_path, monkeypatch):
    """Test initialisation avec tmp_path"""
    # Utiliser tmp_path comme output dir
    monkeypatch.setenv("REPORT_DIR", str(tmp_path))
    
    generator = ReportGenerator()
    
    assert generator.output_dir == tmp_path

def test_generate_json_report(tmp_path):
    """Test génération rapport JSON"""
    generator = ReportGenerator(output_dir=tmp_path)
    
    data = {
        "title": "Test Report",
        "items": 42,
        "status": "success"
    }
    
    # Générer rapport
    report_path = generator.generate_report(data, format="json")
    
    # Vérifier fichier créé
    assert report_path.exists()
    assert report_path.suffix == ".json"
    
    # Vérifier contenu
    with report_path.open() as f:
        loaded = json.load(f)
    
    assert loaded == data

def test_generate_txt_report(tmp_path):
    """Test génération rapport TXT"""
    generator = ReportGenerator(output_dir=tmp_path)
    
    data = {
        "name": "Alice",
        "score": 95
    }
    
    report_path = generator.generate_report(data, format="txt")
    
    # Vérifier contenu
    content = report_path.read_text()
    assert "=== REPORT ===" in content
    assert "name: Alice" in content
    assert "score: 95" in content

def test_generate_csv_report(tmp_path):
    """Test génération rapport CSV"""
    generator = ReportGenerator(output_dir=tmp_path)
    
    data = [
        {"name": "Alice", "age": 30},
        {"name": "Bob", "age": 25},
    ]
    
    report_path = generator.generate_report(data, format="csv")
    
    # Vérifier contenu
    import csv
    with report_path.open() as f:
        reader = csv.DictReader(f)
        rows = list(reader)
    
    assert len(rows) == 2
    assert rows[0]["name"] == "Alice"

# ──────────────────────────────────────
# TESTS AVEC capsys (capture print)
# ──────────────────────────────────────

def test_report_generation_output(tmp_path, capsys):
    """Test que génération affiche message"""
    generator = ReportGenerator(output_dir=tmp_path)
    
    data = {"test": "data"}
    generator.generate_report(data, format="json")
    
    # Capturer stdout
    captured = capsys.readouterr()
    
    # Vérifier message affiché
    assert "[OK] Report saved to:" in captured.out

# ──────────────────────────────────────
# TESTS AVEC caplog (capture logs)
# ──────────────────────────────────────

def test_report_generation_logging(tmp_path, caplog):
    """Test logging pendant génération"""
    caplog.set_level(logging.INFO)
    
    generator = ReportGenerator(output_dir=tmp_path)
    
    data = {"test": "data"}
    generator.generate_report(data, format="json")
    
    # Vérifier logs
    assert "Report generator initialized" in caplog.text
    assert "Generating json report" in caplog.text
    assert "Report generated successfully" in caplog.text

def test_invalid_format_logging(tmp_path, caplog):
    """Test log d'erreur pour format invalide"""
    caplog.set_level(logging.ERROR)
    
    generator = ReportGenerator(output_dir=tmp_path)
    
    with pytest.raises(ValueError):
        generator.generate_report({}, format="invalid")
    
    # Vérifier log d'erreur
    assert "Unsupported format: invalid" in caplog.text

# ──────────────────────────────────────
# TESTS AVEC monkeypatch
# ──────────────────────────────────────

def test_environment_variable(monkeypatch, tmp_path):
    """Test variable d'environnement"""
    # Définir REPORT_DIR
    monkeypatch.setenv("REPORT_DIR", str(tmp_path / "custom"))
    
    generator = ReportGenerator()
    
    assert generator.output_dir == tmp_path / "custom"

def test_fixed_timestamp(monkeypatch, tmp_path):
    """Test avec timestamp fixe"""
    # Mock datetime.now
    fixed_time = datetime(2024, 1, 15, 10, 30, 45)
    
    class MockDatetime:
        @classmethod
        def now(cls):
            return fixed_time
        
        @classmethod
        def strftime(cls, fmt):
            return fixed_time.strftime(fmt)
    
    import report_generator
    monkeypatch.setattr(report_generator, "datetime", MockDatetime)
    
    generator = ReportGenerator(output_dir=tmp_path)
    report_path = generator.generate_report({}, format="json")
    
    # Vérifier nom de fichier contient timestamp fixe
    assert "20240115_103045" in report_path.name

# ──────────────────────────────────────
# TESTS AVEC request
# ──────────────────────────────────────

@pytest.fixture
def report_file_for_test(request, tmp_path):
    """Crée fichier rapport nommé d'après le test"""
    test_name = request.node.name
    report_file = tmp_path / f"{test_name}.json"
    
    yield report_file
    
    # Log après test
    if report_file.exists():
        print(f"Report for {test_name}: {report_file}")

def test_with_custom_report_file(report_file_for_test):
    """Test avec fichier personnalisé"""
    data = {"test": "data"}
    
    with report_file_for_test.open('w') as f:
        json.dump(data, f)
    
    assert report_file_for_test.name == "test_with_custom_report_file.json"

# ──────────────────────────────────────
# TESTS AVEC recwarn
# ──────────────────────────────────────

import warnings

def generate_report_with_warning(generator, data):
    """Fonction qui émet warning si trop de données"""
    if len(str(data)) > 1000:
        warnings.warn("Large dataset detected", UserWarning)
    
    return generator.generate_report(data, format="json")

def test_large_dataset_warning(tmp_path, recwarn):
    """Test warning pour gros dataset"""
    generator = ReportGenerator(output_dir=tmp_path)
    
    # Créer gros dataset
    large_data = {"items": ["x" * 100 for _ in range(50)]}
    
    generate_report_with_warning(generator, large_data)
    
    # Vérifier warning
    assert len(recwarn) == 1
    assert issubclass(recwarn[0].category, UserWarning)
    assert "Large dataset detected" in str(recwarn[0].message)

# ──────────────────────────────────────
# TEST D'INTÉGRATION COMPLET
# ──────────────────────────────────────

def test_full_workflow(tmp_path, monkeypatch, capsys, caplog):
    """
    Test complet utilisant TOUTES les fixtures built-in
    """
    # 1. tmp_path : Dossier temporaire
    output_dir = tmp_path / "reports"
    
    # 2. monkeypatch : Variable env
    monkeypatch.setenv("REPORT_DIR", str(output_dir))
    
    # 3. caplog : Capturer logs
    caplog.set_level(logging.INFO)
    
    # Initialiser
    generator = ReportGenerator()
    
    # Générer plusieurs rapports
    data1 = {"type": "daily", "count": 100}
    data2 = {"type": "weekly", "count": 500}
    
    report1 = generator.generate_report(data1, format="json")
    report2 = generator.generate_report(data2, format="txt")
    
    # 4. capsys : Vérifier output
    captured = capsys.readouterr()
    assert captured.out.count("[OK] Report saved to:") == 2
    
    # 5. Vérifier logs
    assert "Report generator initialized" in caplog.text
    assert caplog.text.count("Report generated successfully") == 2
    
    # 6. Vérifier fichiers
    reports = generator.list_reports()
    assert len(reports) == 2
    
    assert report1.exists()
    assert report2.exists()
    
    # Vérifier contenu
    with report1.open() as f:
        data = json.load(f)
        assert data["type"] == "daily"

"""
ÉTAPE 3 : EXÉCUTER
"""

# Tous les tests
pytest tests/test_report_generator.py -v

# Avec logs visibles
pytest tests/test_report_generator.py -v -s --log-cli-level=INFO

# Avec coverage
pytest tests/test_report_generator.py -v --cov=report_generator


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 8
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] tmp_path : Fichiers/dossiers temporaires (pathlib)
[OK] tmpdir : Version legacy (py.path)
[OK] capsys/capfd : Capture stdout/stderr
[OK] caplog : Capture logs
[OK] monkeypatch : Modifier comportement
[OK] request : Métadonnées du test
[OK] recwarn : Capture warnings
[OK] cache : Persister données
[OK] Autres fixtures utiles


[CLE] POINTS CLÉS

1. tmp_path : Préférer à tmpdir (moderne)
2. capsys : Tester print()
3. caplog : Tester logging
4. monkeypatch : Mock sans bibliothèque externe
5. request : Accès au contexte du test
6. Combiner fixtures pour tests complets


[OBJECTIF] TABLEAU RÉCAPITULATIF

┌──────────────┬────────────────────┬─────────────────┐
│ Fixture      │ Usage              │ Exemple         │
├──────────────┼────────────────────┼─────────────────┤
│ tmp_path     │ Fichiers temp      │ Test I/O        │
│ capsys       │ Capture print()    │ Test CLI        │
│ caplog       │ Capture logs       │ Test logging    │
│ monkeypatch  │ Mock/Patch         │ Test APIs       │
│ request      │ Métadonnées        │ Nom dynamique   │
│ recwarn      │ Capture warnings   │ Test deprecated │
│ cache        │ Persistance        │ Optimisation    │
└──────────────┴────────────────────┴─────────────────┘


[GRAPHIQUE] PATTERNS ESSENTIELS

# Fichiers temporaires
def test_file(tmp_path):
    file = tmp_path / "test.txt"
    file.write_text("content")
    assert file.read_text() == "content"

# Capture print
def test_output(capsys):
    print("hello")
    captured = capsys.readouterr()
    assert captured.out == "hello\n"

# Capture logs
def test_logging(caplog):
    logger.info("message")
    assert "message" in caplog.text

# Monkeypatch
def test_env(monkeypatch):
    monkeypatch.setenv("KEY", "value")
    assert os.getenv("KEY") == "value"


[BRAVO] FIN DE LA PARTIE 2 : FIXTURES

Vous maîtrisez maintenant :
[OK] Fixtures de base
[OK] Scopes et lifecycle
[OK] Fixtures paramétrées
[OK] Fixtures built-in

-> PROCHAINE ÉTAPE : PARTIE 3 - TECHNIQUES AVANCÉES

La Partie 3 couvrira :
- Chapitre 9 : Tests Paramétrés Avancés
- Chapitre 10 : Mocking et Patching
- Chapitre 11 : Tests Asynchrones
- Chapitre 12 : Plugins et Extensions

Les techniques avancées ! [RAPIDE]
"""

Excellent ! Le Chapitre 8 et toute la Partie 2 sur les Fixtures sont maintenant terminés ! 

Voulez-vous que je continue avec la **Partie 3 : Techniques Avancées** en commençant par le **Chapitre 9 : Tests Paramétrés Avancés** ? 

Ou préférez-vous :
1. Un **récapitulatif complet** de ce qu'on a vu jusqu'ici (Parties 1 & 2)
2. Continuer directement avec la Partie 3

Dites-moi comment vous souhaitez procéder ! [DOCS]

Je continue avec le Chapitre 10 sur le Mocking et Patching - ultra-détaillé avec COMMENT/POURQUOI/QUAND !

# ============================================================================
# [GUIDE] CHAPITRE 10 : MOCKING ET PATCHING
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Comprendre le concept de mocking en profondeur
[OK] Utiliser unittest.mock (Mock, MagicMock, patch)
[OK] Maîtriser pytest-mock (mocker fixture)
[OK] Mocker APIs, bases de données, fichiers
[OK] Vérifier appels et assertions sur mocks
[OK] Stratégies de mocking avancées
[OK] Éviter les pièges courants
"""


# ----------------------------------------------------------------------------
# [REFLEXION] QU'EST-CE QUE LE MOCKING ?
# ----------------------------------------------------------------------------

"""
CONCEPT FONDAMENTAL : ISOLATION DES TESTS

COMMENT ? Remplacer dépendances par des faux objets

POURQUOI ? Isoler le code testé

QUAND ? Dépendances externes, lentes, ou imprévisibles


ANALOGIE [SCENARIO] : RÉPÉTITION DE THÉÂTRE

Scène : Acteur principal (code testé)
Partenaires : Autres acteurs (dépendances)

En répétition :
- Acteur principal présent (vrai code)
- Partenaires absents -> Remplacés par "doublures" (mocks)
- Focus sur acteur principal uniquement

Mock = Doublure qui joue un rôle prédéfini


PROBLÈME SANS MOCKING
"""

# Code à tester
def send_welcome_email(user):
    """
    COMMENT ? Envoie email de bienvenue
    POURQUOI ? Onboarding utilisateur
    QUAND ? Après création compte
    """
    email_service = EmailService()
    template = email_service.get_template("welcome")
    email_service.send(
        to=user.email,
        subject="Bienvenue !",
        body=template.format(name=user.name)
    )
    return True

"""
[X] Test SANS mock :
"""

def test_send_welcome_email():
    user = User(name="Alice", email="alice@example.com")
    
    result = send_welcome_email(user)  # [X] Problèmes !
    
    assert result is True

"""
PROBLÈMES :

1. DÉPENDANCE EXTERNE
   - Nécessite serveur email configuré
   - Compte email de test
   - Connexion réseau
   
2. LENTEUR
   - Appel réseau = secondes
   - Tests lents = productivité v
   
3. IMPRÉVISIBILITÉ
   - Serveur peut être down
   - Réseau instable
   - Tests flaky (intermittents)
   
4. EFFETS DE BORD
   - Email réellement envoyé !
   - Pollution de boîte mail
   - Coûts (si service payant)
   
5. DIFFICULTÉ À TESTER ERREURS
   - Comment simuler échec serveur ?
   - Comment tester timeout ?


[OK] SOLUTION : MOCKING
"""

from unittest.mock import Mock, patch

def test_send_welcome_email_with_mock():
    """
    COMMENT ? Remplacer EmailService par mock
    POURQUOI ? Isoler la fonction send_welcome_email
    QUAND ? Test unitaire pur
    """
    user = User(name="Alice", email="alice@example.com")
    
    # COMMENT ? patch remplace EmailService
    with patch('module.EmailService') as MockEmailService:
        # COMMENT ? Configurer comportement du mock
        mock_instance = MockEmailService.return_value
        mock_instance.get_template.return_value = "Hello {name}!"
        mock_instance.send.return_value = True
        
        # POURQUOI ? Tester le code, pas les dépendances
        result = send_welcome_email(user)
        
        # QUAND ? Vérifications
        assert result is True
        
        # Vérifier que send a été appelé correctement
        mock_instance.send.assert_called_once_with(
            to="alice@example.com",
            subject="Bienvenue !",
            body="Hello Alice!"
        )

"""
[OK] AVANTAGES DU MOCKING

1. RAPIDITÉ
   - Pas d'appel réseau
   - Tests en millisecondes
   
2. FIABILITÉ
   - Pas de dépendances externes
   - Tests déterministes
   
3. ISOLATION
   - Teste SEULEMENT le code
   - Pas les dépendances
   
4. CONTRÔLE
   - Simuler n'importe quel scénario
   - Erreurs, timeouts, edge cases
   
5. PAS D'EFFETS DE BORD
   - Aucun email envoyé
   - Aucune donnée créée


[IDEE] DÉFINITIONS CLÉS

MOCK (substantif)
    Objet simulé qui remplace une dépendance
    
MOCKING (verbe)
    Action de créer et utiliser des mocks
    
STUB
    Mock qui retourne valeurs prédéfinies
    
SPY
    Mock qui enregistre les appels
    
PATCH
    Remplacer temporairement un objet par un mock


QUAND UTILISER LE MOCKING ?

[OK] OUI pour :
- APIs externes (HTTP, SOAP, etc.)
- Bases de données
- Système de fichiers
- Services cloud (S3, etc.)
- Services de paiement
- Email/SMS
- Dates/temps (datetime.now)
- Random (pour tests déterministes)
- Code tiers lent/complexe

[X] NON pour :
- Code simple (calculs, strings)
- Logique métier pure
- Structures de données (list, dict)
- Sur-mocking = tests fragiles
"""


# ----------------------------------------------------------------------------
# [SCENARIO] unittest.mock : LES BASES
# ----------------------------------------------------------------------------

"""
BIBLIOTHÈQUE STANDARD PYTHON

COMMENT ? Import depuis unittest
"""

from unittest.mock import Mock, MagicMock, patch, call

"""
[IDEE] Mock vs MagicMock

Mock       : Mock basique
MagicMock  : Mock avec méthodes magiques (__str__, __len__, etc.)

Généralement : Utiliser MagicMock (plus flexible)


CRÉER UN MOCK : Mock()

COMMENT ? Instancier Mock
"""

mock = Mock()

"""
POURQUOI ? Mock = objet qui accepte TOUT

Le mock peut :
- Être appelé comme fonction
- Avoir n'importe quel attribut
- Retourner autre mock par défaut
"""

# Appeler comme fonction
result = mock()
print(result)  # <Mock name='mock()' id='...'>

# Accéder à attributs (créés à la volée)
mock.anything
mock.foo.bar.baz()  # Chaîne infinie de mocks

# Définir valeur de retour
mock.return_value = 42
print(mock())  # 42

"""
[IDEE] DÉCRYPTAGE

mock.return_value = 42
    │              │
    │              └─ Valeur retournée quand mock appelé
    └────────────── Le mock lui-même

COMMENT ? Configurer comportements


EXEMPLE COMPLET : Mock simple
"""

def test_mock_basics():
    """
    COMMENT ? Exploration Mock de base
    POURQUOI ? Comprendre comportement fondamental
    QUAND ? Apprentissage
    """
    # Créer mock
    mock_func = Mock()
    
    # Par défaut, retourne un autre Mock
    result = mock_func()
    assert isinstance(result, Mock)
    
    # Configurer retour
    mock_func.return_value = "Hello"
    assert mock_func() == "Hello"
    
    # Mock avec attributs
    mock_obj = Mock()
    mock_obj.name = "Alice"
    mock_obj.age = 30
    assert mock_obj.name == "Alice"
    
    # Mock de méthode
    mock_obj.greet = Mock(return_value="Hi!")
    assert mock_obj.greet() == "Hi!"

"""
CONFIGURER COMPORTEMENT : return_value et side_effect

COMMENT ? return_value pour valeur fixe
"""

mock = Mock()
mock.return_value = 42

assert mock() == 42
assert mock() == 42  # Toujours 42

"""
COMMENT ? side_effect pour comportement dynamique
"""

# 1. Lever exception
mock = Mock()
mock.side_effect = ValueError("Erreur simulée")

try:
    mock()  # Lève ValueError
except ValueError as e:
    print(e)  # "Erreur simulée"

# 2. Retourner valeurs différentes (itérable)
mock = Mock()
mock.side_effect = [1, 2, 3]

print(mock())  # 1
print(mock())  # 2
print(mock())  # 3
# mock()  # Lèverait StopIteration

# 3. Fonction personnalisée
def custom_behavior(arg):
    return arg * 2

mock = Mock()
mock.side_effect = custom_behavior

print(mock(5))   # 10
print(mock(10))  # 20

"""
[IDEE] POURQUOI side_effect ?

return_value   : Comportement statique
side_effect    : Comportement dynamique

QUAND utiliser side_effect ?
- Simuler exceptions
- Valeurs différentes par appel
- Logique conditionnelle


VÉRIFIER LES APPELS : assert_called_*

COMMENT ? Vérifier qu'un mock a été appelé
"""

mock = Mock()

# Appeler le mock
mock(1, 2, key="value")

# Vérifications disponibles
mock.assert_called()              # Au moins un appel
mock.assert_called_once()         # Exactement un appel
mock.assert_called_with(1, 2, key="value")  # Dernier appel
mock.assert_called_once_with(1, 2, key="value")  # Un seul appel avec ces args

"""
[IDEE] DIFFÉRENCES

assert_called_with       : Dernier appel (peu importe combien)
assert_called_once_with  : Un seul appel total + ces args


EXEMPLE : Vérifications d'appels
"""

def test_mock_call_assertions():
    """
    COMMENT ? Vérifier appels sur mocks
    POURQUOI ? S'assurer code appelle dépendances correctement
    QUAND ? Tests d'intégration avec dépendances mockées
    """
    mock = Mock()
    
    # Appeler plusieurs fois
    mock(1, 2)
    mock(3, 4)
    
    # Vérifier appelé
    mock.assert_called()
    
    # Vérifier dernier appel
    mock.assert_called_with(3, 4)
    
    # Vérifier tous les appels
    assert mock.call_count == 2
    assert mock.call_args_list == [
        call(1, 2),
        call(3, 4)
    ]

"""
ACCÉDER À L'HISTORIQUE D'APPELS

COMMENT ? Propriétés du mock
"""

mock = Mock()
mock(1, 2, key="value")
mock(3, 4)

# call_count : Nombre d'appels
print(mock.call_count)  # 2

# called : Booléen (au moins un appel)
print(mock.called)  # True

# call_args : Dernier appel
print(mock.call_args)  # call(3, 4)

# call_args_list : Tous les appels
print(mock.call_args_list)
# [call(1, 2, key='value'), call(3, 4)]

"""
[IDEE] OBJET call

call(1, 2, key="value")
    │  │      │
    │  │      └─ Kwargs
    │  └──────── Args positionnels
    └─────────── Wrapper pour représenter un appel


EXEMPLE PRATIQUE : Vérifier appels API
"""

class APIClient:
    def get_user(self, user_id):
        # Vraie implémentation ferait requête HTTP
        pass
    
    def update_user(self, user_id, data):
        pass

def sync_user_profile(user_id, new_data):
    """
    COMMENT ? Synchronise profil utilisateur
    POURQUOI ? Mise à jour depuis source externe
    QUAND ? Webhook, import, etc.
    """
    client = APIClient()
    
    # Récupérer user actuel
    current = client.get_user(user_id)
    
    # Merger données
    merged = {**current, **new_data}
    
    # Mettre à jour
    client.update_user(user_id, merged)
    
    return merged

def test_sync_user_profile():
    """
    COMMENT ? Tester sync avec mock API
    POURQUOI ? Pas d'appel HTTP réel
    QUAND ? Test unitaire
    """
    with patch('module.APIClient') as MockAPI:
        # Configurer mock
        mock_instance = MockAPI.return_value
        mock_instance.get_user.return_value = {
            "id": 123,
            "name": "Alice",
            "email": "old@example.com"
        }
        
        # Appeler fonction
        result = sync_user_profile(123, {"email": "new@example.com"})
        
        # Vérifier appels
        mock_instance.get_user.assert_called_once_with(123)
        mock_instance.update_user.assert_called_once_with(
            123,
            {"id": 123, "name": "Alice", "email": "new@example.com"}
        )
        
        # Vérifier résultat
        assert result["email"] == "new@example.com"


# ----------------------------------------------------------------------------
# [OUTIL] patch : REMPLACER TEMPORAIREMENT
# ----------------------------------------------------------------------------

"""
CONCEPT : MONKEY PATCHING TEMPORAIRE

COMMENT ? patch remplace objet pendant test

POURQUOI ? Isoler code sans modifier imports

QUAND ? Mocker dépendances externes


SYNTAXE : patch(cible)

cible = Chemin complet de l'objet à patcher
Format : 'module.path.to.object'
"""

# ──────────────────────────────
# UTILISATION 1 : CONTEXT MANAGER
# ──────────────────────────────

"""
COMMENT ? with patch(...)
"""

from unittest.mock import patch

def test_with_patch_context_manager():
    """
    COMMENT ? patch en context manager
    POURQUOI ? Patch temporaire (seulement dans with)
    QUAND ? Patch pour un seul test
    """
    # Code original
    import datetime
    
    # Avant patch
    now1 = datetime.datetime.now()
    
    # Pendant patch
    with patch('datetime.datetime') as mock_datetime:
        # Configurer mock
        mock_datetime.now.return_value = datetime.datetime(2024, 1, 1, 12, 0, 0)
        
        # datetime.now() retourne notre valeur
        now2 = datetime.datetime.now()
        assert now2 == datetime.datetime(2024, 1, 1, 12, 0, 0)
    
    # Après patch (restauré)
    now3 = datetime.datetime.now()
    # now3 est la vraie date

"""
[IDEE] PORTÉE DU PATCH

DANS le with : Objet patché
HORS du with : Objet original restauré


# ──────────────────────────────
# UTILISATION 2 : DÉCORATEUR
# ──────────────────────────────

COMMENT ? @patch en décorateur
"""

@patch('module.some_function')
def test_with_patch_decorator(mock_function):
    """
    COMMENT ? patch comme décorateur
    POURQUOI ? Patch actif pour tout le test
    QUAND ? Patch utilisé dans tout le test
    
    Args:
        mock_function: Injecté automatiquement par patch
    """
    # Configurer mock
    mock_function.return_value = 42
    
    # Utiliser
    result = some_function()
    assert result == 42

"""
[IDEE] ORDRE DES DÉCORATEURS

Plusieurs patches = ordre inverse
"""

@patch('module.function_b')
@patch('module.function_a')
def test_multiple_patches(mock_a, mock_b):
    """
    COMMENT ? Ordre : Bas vers haut
    
    Args:
        mock_a: Patch de function_a (2e décorateur)
        mock_b: Patch de function_b (1er décorateur)
    """
    pass

"""
Règle : Décorateur du bas = Premier paramètre


# ──────────────────────────────
# UTILISATION 3 : setUp/tearDown
# ──────────────────────────────

COMMENT ? patcher.start() et patcher.stop()
"""

class TestWithPatchManual:
    def setup_method(self):
        """
        COMMENT ? Démarrer patch manuellement
        POURQUOI ? Patch pour toute la classe
        QUAND ? Setup complexe partagé
        """
        self.patcher = patch('module.function')
        self.mock = self.patcher.start()
        self.mock.return_value = 42
    
    def teardown_method(self):
        """
        COMMENT ? Arrêter patch
        POURQUOI ? Restaurer état original
        QUAND ? Cleanup
        """
        self.patcher.stop()
    
    def test_something(self):
        result = function()
        assert result == 42

"""
[IDEE] POURQUOI start/stop ?

Contrôle manuel du cycle de vie
Utile pour setup/teardown de classe


# ──────────────────────────────
# OÙ PATCHER ? RÈGLE D'OR
# ──────────────────────────────

RÈGLE : Patcher OÙ c'est UTILISÉ, pas où c'est DÉFINI

COMMENT ? Trouver le bon chemin
"""

# Module A : définition
# module_a.py
def fetch_data():
    return "real data"

# Module B : utilisation
# module_b.py
from module_a import fetch_data

def process():
    data = fetch_data()
    return data.upper()

# Test
# test_module_b.py

# [X] MAUVAIS : Patcher où c'est défini
@patch('module_a.fetch_data')
def test_process_wrong(mock_fetch):
    """Ne marchera PAS !"""
    mock_fetch.return_value = "mocked"
    result = process()
    # result sera "REAL DATA", pas "MOCKED"

# [OK] BON : Patcher où c'est utilisé
@patch('module_b.fetch_data')
def test_process_correct(mock_fetch):
    """Marche !"""
    mock_fetch.return_value = "mocked"
    result = process()
    assert result == "MOCKED"

"""
[IDEE] POURQUOI ?

module_b a sa propre référence à fetch_data
Il faut patcher cette référence, pas l'originale


COMMENT ? Trouver où patcher

1. Regarder l'import dans le module testé
2. Patcher dans CE module


EXEMPLES DÉTAILLÉS : patch.object

COMMENT ? Patcher attribut d'un objet
"""

class Calculator:
    def add(self, a, b):
        return a + b
    
    def multiply(self, a, b):
        return a * b

def test_with_patch_object():
    """
    COMMENT ? patch.object pour patcher méthode
    POURQUOI ? Plus précis que patcher classe entière
    QUAND ? Patcher méthode spécifique
    """
    calc = Calculator()
    
    # Patcher méthode multiply
    with patch.object(calc, 'multiply', return_value=100):
        # multiply est mocké
        assert calc.multiply(5, 6) == 100
        
        # add n'est PAS mocké
        assert calc.add(5, 6) == 11

"""
[IDEE] patch vs patch.object

patch('module.Class.method')     : Patch global
patch.object(instance, 'method') : Patch sur instance


PATCH MULTIPLE : patch.multiple

COMMENT ? Patcher plusieurs objets d'un coup
"""

@patch.multiple(
    'module',
    function_a=Mock(return_value=1),
    function_b=Mock(return_value=2),
    CLASS_C=Mock()
)
def test_multiple():
    """
    COMMENT ? Patcher plusieurs en une fois
    POURQUOI ? Simplifier quand beaucoup de patches
    QUAND ? Plusieurs dépendances d'un module
    """
    assert function_a() == 1
    assert function_b() == 2


# ----------------------------------------------------------------------------
# [WRAPPED_PRESENT] pytest-mock : FIXTURE mocker
# ----------------------------------------------------------------------------

"""
PROBLÈME : unittest.mock dans pytest

unittest.mock fonctionne, mais :
- Syntaxe verbose
- Pas de fixtures
- Cleanup manuel


[OK] SOLUTION : pytest-mock

COMMENT ? Installation
"""

pip install pytest-mock

"""
POURQUOI ? pytest-mock

1. Fixture mocker automatique
2. Cleanup automatique
3. Syntaxe plus simple
4. Intégration pytest


FIXTURE mocker

COMMENT ? Utiliser mocker
"""

def test_with_mocker(mocker):
    """
    COMMENT ? mocker fixture
    POURQUOI ? Simplifier mocking dans pytest
    QUAND ? Tests pytest avec mocks
    
    Args:
        mocker: Fixture pytest-mock
    """
    # Créer mock
    mock = mocker.Mock()
    mock.return_value = 42
    assert mock() == 42
    
    # Patch
    mock_func = mocker.patch('module.function')
    mock_func.return_value = "mocked"

"""
[IDEE] AVANTAGES mocker

1. Cleanup automatique (fixture)
2. Pas de with ou décorateur nécessaire
3. Méthodes helper


MÉTHODES mocker

mocker.Mock()              : Créer Mock
mocker.MagicMock()         : Créer MagicMock
mocker.patch(target)       : Patcher
mocker.patch.object(...)   : Patcher attribut
mocker.spy(obj, method)    : Espionner (garde comportement)
mocker.stub()              : Créer stub


EXEMPLE : mocker.patch
"""

def get_username():
    """Récupère username depuis API"""
    import requests
    response = requests.get('https://api.example.com/user')
    return response.json()['username']

def test_get_username(mocker):
    """
    COMMENT ? Mocker avec pytest-mock
    POURQUOI ? Plus simple que unittest.mock
    QUAND ? Tests pytest
    """
    # Mock requests.get
    mock_response = mocker.Mock()
    mock_response.json.return_value = {'username': 'alice'}
    
    mocker.patch('requests.get', return_value=mock_response)
    
    # Tester
    username = get_username()
    assert username == 'alice'

"""
EXEMPLE : mocker.spy

COMMENT ? Espionner sans remplacer
"""

class EmailService:
    def send(self, to, subject, body):
        """Vraie implémentation"""
        print(f"Sending email to {to}")
        # ... code d'envoi réel
        return True

def test_with_spy(mocker):
    """
    COMMENT ? Spy garde comportement original
    POURQUOI ? Vérifier appels sans mocker
    QUAND ? Tester qu'une méthode est appelée
    """
    service = EmailService()
    
    # Espionner send (garde comportement)
    spy = mocker.spy(service, 'send')
    
    # Appeler
    result = service.send("alice@example.com", "Hello", "Content")
    
    # Comportement original exécuté
    assert result is True
    
    # Mais on peut vérifier l'appel
    spy.assert_called_once_with("alice@example.com", "Hello", "Content")

"""
[IDEE] Mock vs Spy

Mock : Remplace complètement
Spy  : Enregistre + exécute original


COMPARAISON unittest.mock vs pytest-mock
"""

# ──────────────────────────────
# AVEC unittest.mock
# ──────────────────────────────

from unittest.mock import patch, Mock

def test_with_unittest_mock():
    with patch('module.APIClient') as MockAPI:
        mock_instance = MockAPI.return_value
        mock_instance.get.return_value = "data"
        
        # Test...

# ──────────────────────────────
# AVEC pytest-mock
# ──────────────────────────────

def test_with_pytest_mock(mocker):
    mock_api = mocker.patch('module.APIClient')
    mock_api.return_value.get.return_value = "data"
    
    # Test...

"""
[OK] pytest-mock est plus concis !


# ----------------------------------------------------------------------------
# [WEB] MOCKER APIS EXTERNES
# ----------------------------------------------------------------------------

"""
CAS D'USAGE COURANT : Requêtes HTTP

COMMENT ? Mocker requests
"""

import requests

def fetch_user_data(user_id):
    """
    COMMENT ? Récupère données user via API
    POURQUOI ? Intégration service externe
    QUAND ? Application avec API
    """
    response = requests.get(f'https://api.example.com/users/{user_id}')
    response.raise_for_status()
    return response.json()

def test_fetch_user_data(mocker):
    """
    COMMENT ? Mocker requests.get
    POURQUOI ? Pas d'appel HTTP réel
    QUAND ? Test unitaire
    """
    # Créer mock response
    mock_response = mocker.Mock()
    mock_response.json.return_value = {
        'id': 123,
        'name': 'Alice',
        'email': 'alice@example.com'
    }
    mock_response.status_code = 200
    
    # Patcher requests.get
    mocker.patch('requests.get', return_value=mock_response)
    
    # Tester
    data = fetch_user_data(123)
    
    assert data['name'] == 'Alice'
    
    # Vérifier URL appelée
    requests.get.assert_called_once_with('https://api.example.com/users/123')

"""
COMMENT ? Simuler erreurs HTTP
"""

def test_fetch_user_data_not_found(mocker):
    """
    COMMENT ? Simuler 404
    POURQUOI ? Tester gestion d'erreurs
    QUAND ? Tests négatifs
    """
    # Mock response avec erreur
    mock_response = mocker.Mock()
    mock_response.status_code = 404
    mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
    
    mocker.patch('requests.get', return_value=mock_response)
    
    # Vérifier exception levée
    with pytest.raises(requests.HTTPError):
        fetch_user_data(999)

"""
COMMENT ? Simuler timeout
"""

def test_fetch_user_data_timeout(mocker):
    """
    COMMENT ? Simuler timeout réseau
    POURQUOI ? Tester resilience
    QUAND ? Tests de robustesse
    """
    # Lever Timeout exception
    mocker.patch('requests.get', side_effect=requests.Timeout("Connection timeout"))
    
    with pytest.raises(requests.Timeout):
        fetch_user_data(123)

"""
PATTERN AVANCÉ : Réponses multiples

COMMENT ? Simuler plusieurs appels API
"""

def get_user_posts(user_id):
    """
    COMMENT ? Récupère user puis ses posts
    POURQUOI ? Agrégation de données
    QUAND ? Dashboard, profil
    """
    # Appel 1 : User
    user_response = requests.get(f'https://api.example.com/users/{user_id}')
    user = user_response.json()
    
    # Appel 2 : Posts
    posts_response = requests.get(f'https://api.example.com/users/{user_id}/posts')
    posts = posts_response.json()
    
    return {
        'user': user,
        'posts': posts
    }

def test_get_user_posts(mocker):
    """
    COMMENT ? Mock avec side_effect pour appels multiples
    POURQUOI ? Simuler séquence d'appels
    QUAND ? Fonction avec plusieurs requêtes
    """
    # Mock responses
    mock_user_response = mocker.Mock()
    mock_user_response.json.return_value = {'id': 123, 'name': 'Alice'}
    
    mock_posts_response = mocker.Mock()
    mock_posts_response.json.return_value = [
        {'id': 1, 'title': 'Post 1'},
        {'id': 2, 'title': 'Post 2'}
    ]
    
    # side_effect avec liste = valeurs successives
    mocker.patch('requests.get', side_effect=[
        mock_user_response,   # 1er appel
        mock_posts_response   # 2e appel
    ])
    
    # Tester
    result = get_user_posts(123)
    
    assert result['user']['name'] == 'Alice'
    assert len(result['posts']) == 2


# ----------------------------------------------------------------------------
# [SAUVEGARDE] MOCKER BASES DE DONNÉES
# ----------------------------------------------------------------------------

"""
STRATÉGIE : Ne pas mocker l'ORM, mocker les requêtes

POURQUOI ?
- ORM complexe (SQLAlchemy, Django ORM)
- Mocker = tests fragiles
- Mieux : BD en mémoire ou mocks ciblés


EXEMPLE : Mocker requêtes DB
"""

class UserRepository:
    def __init__(self, db_session):
        self.db = db_session
    
    def get_user(self, user_id):
        """
        COMMENT ? Récupère user depuis DB
        POURQUOI ? Couche d'accès données
        QUAND ? Architecture en couches
        """
        return self.db.query(User).filter(User.id == user_id).first()
    
    def create_user(self, name, email):
        user = User(name=name, email=email)
        self.db.add(user)
        self.db.commit()
        return user

def test_get_user(mocker):
    """
    COMMENT ? Mocker session DB
    POURQUOI ? Pas de vraie DB nécessaire
    QUAND ? Test unitaire du repository
    """
    # Mock DB session
    mock_session = mocker.Mock()
    
    # Mock query chain
    mock_query = mocker.Mock()
    mock_session.query.return_value = mock_query
    
    mock_filter = mocker.Mock()
    mock_query.filter.return_value = mock_filter
    
    # Mock résultat
    mock_user = User(id=123, name='Alice', email='alice@example.com')
    mock_filter.first.return_value = mock_user
    
    # Créer repository avec mock session
    repo = UserRepository(mock_session)
    
    # Tester
    user = repo.get_user(123)
    
    assert user.name == 'Alice'
    
    # Vérifier query appelée
    mock_session.query.assert_called_once_with(User)

"""
[IDEE] PROBLÈME : Mock complex

Mocker SQLAlchemy = beaucoup de code
Fragile si query change


[OK] MEILLEURE APPROCHE : DB en mémoire

COMMENT ? SQLite en mémoire pour tests
"""

@pytest.fixture(scope="function")
def db_session():
    """
    COMMENT ? Vraie DB SQLite en mémoire
    POURQUOI ? Tests rapides + vraie DB
    QUAND ? Tests d'intégration DB
    """
    from sqlalchemy import create_engine
    from sqlalchemy.orm import sessionmaker
    
    # Engine en mémoire
    engine = create_engine('sqlite:///:memory:')
    
    # Créer tables
    Base.metadata.create_all(engine)
    
    # Session
    Session = sessionmaker(bind=engine)
    session = Session()
    
    yield session
    
    # Cleanup
    session.close()

def test_user_repository_with_real_db(db_session):
    """
    COMMENT ? Test avec vraie DB en mémoire
    POURQUOI ? Tests plus fiables
    QUAND ? Tests d'intégration
    """
    repo = UserRepository(db_session)
    
    # Créer user (vraie insertion)
    user = repo.create_user('Alice', 'alice@example.com')
    
    # Récupérer (vraie query)
    retrieved = repo.get_user(user.id)
    
    assert retrieved.name == 'Alice'

"""
[IDEE] QUAND MOCKER LA DB ?

[OK] Mock si :
- Test unitaire pur (logique métier)
- DB non critique pour le test
- Isolation stricte

[OK] Vraie DB en mémoire si :
- Test d'intégration
- Requêtes complexes
- Transactions


EXEMPLE : Mocker connexion DB (pas queries)
"""

def get_database_connection():
    """
    COMMENT ? Établit connexion DB
    POURQUOI ? Pool de connexions
    QUAND ? Initialisation app
    """
    import psycopg2
    return psycopg2.connect(
        host="localhost",
        database="mydb",
        user="user",
        password="pass"
    )

def test_get_database_connection(mocker):
    """
    COMMENT ? Mocker psycopg2.connect
    POURQUOI ? Pas de vraie connexion PostgreSQL
    QUAND ? Test de configuration
    """
    mock_connection = mocker.Mock()
    mocker.patch('psycopg2.connect', return_value=mock_connection)
    
    conn = get_database_connection()
    
    assert conn == mock_connection
    
    # Vérifier paramètres de connexion
    import psycopg2
    psycopg2.connect.assert_called_once_with(
        host="localhost",
        database="mydb",
        user="user",
        password="pass"
    )


# ----------------------------------------------------------------------------
# [DOSSIER] MOCKER SYSTÈME DE FICHIERS
# ----------------------------------------------------------------------------

"""
STRATÉGIE : Mocker open() ou utiliser tmp_path

COMMENT ? Mocker open() pour lecture
"""

def read_config_file(filepath):
    """
    COMMENT ? Lit fichier de configuration
    POURQUOI ? Configuration depuis fichier
    QUAND ? Démarrage application
    """
    with open(filepath, 'r') as f:
        return f.read()

def test_read_config_file(mocker):
    """
    COMMENT ? Mocker open() built-in
    POURQUOI ? Pas de fichier réel nécessaire
    QUAND ? Test lecture fichier
    """
    # Mock open()
    mock_open = mocker.mock_open(read_data="key=value\nfoo=bar")
    mocker.patch('builtins.open', mock_open)
    
    # Tester
    content = read_config_file('/fake/path/config.txt')
    
    assert "key=value" in content
    
    # Vérifier open appelé
    mock_open.assert_called_once_with('/fake/path/config.txt', 'r')

"""
[IDEE] mocker.mock_open

Crée mock pour open()
read_data = contenu retourné


COMMENT ? Mocker open() pour écriture
"""

def write_log(filepath, message):
    """
    COMMENT ? Écrit dans fichier log
    POURQUOI ? Logging applicatif
    QUAND ? Événements importants
    """
    with open(filepath, 'a') as f:
        f.write(message + '\n')

def test_write_log(mocker):
    """
    COMMENT ? Vérifier écriture sans fichier
    POURQUOI ? Test sans I/O disque
    QUAND ? Test fonction d'écriture
    """
    mock_open = mocker.mock_open()
    mocker.patch('builtins.open', mock_open)
    
    # Tester
    write_log('/fake/log.txt', 'Error occurred')
    
    # Vérifier open appelé en mode append
    mock_open.assert_called_once_with('/fake/log.txt', 'a')
    
    # Vérifier write appelé
    handle = mock_open()
    handle.write.assert_called_once_with('Error occurred\n')

"""
PATTERN : Vérifier contenu écrit
"""

def test_write_log_content(mocker):
    """
    COMMENT ? Capturer ce qui est écrit
    POURQUOI ? Vérifier contenu exact
    QUAND ? Test précis d'écriture
    """
    mock_file = mocker.mock_open()
    mocker.patch('builtins.open', mock_file)
    
    write_log('/fake/log.txt', 'Test message')
    
    # Récupérer tous les appels write
    handle = mock_file()
    written_data = ''.join(
        call.args[0] for call in handle.write.call_args_list
    )
    
    assert written_data == 'Test message\n'

"""
[OK] ALTERNATIVE : tmp_path (recommandé)

POURQUOI ? Tests plus fiables avec vrais fichiers
"""

def test_read_config_with_tmp_path(tmp_path):
    """
    COMMENT ? Créer vrai fichier temporaire
    POURQUOI ? Test plus réaliste
    QUAND ? Si logique de lecture complexe
    """
    # Créer fichier config
    config_file = tmp_path / "config.txt"
    config_file.write_text("key=value\nfoo=bar")
    
    # Tester avec vrai fichier
    content = read_config_file(config_file)
    
    assert "key=value" in content

"""
[IDEE] QUAND UTILISER QUEL APPROCHE ?

Mock open() :
[OK] Tests unitaires purs
[OK] Logique sans I/O réel
[OK] Vérifier paramètres d'ouverture

tmp_path :
[OK] Tests d'intégration
[OK] Logique complexe de fichiers
[OK] Format de fichier important


MOCKER Path (pathlib)
"""

from pathlib import Path

def get_file_size(filepath):
    """
    COMMENT ? Taille d'un fichier
    POURQUOI ? Vérification avant traitement
    QUAND ? Validation fichier upload
    """
    path = Path(filepath)
    return path.stat().st_size

def test_get_file_size(mocker):
    """
    COMMENT ? Mocker Path.stat()
    POURQUOI ? Pas de fichier réel
    QUAND ? Test logique basée sur métadonnées
    """
    # Mock stat
    mock_stat = mocker.Mock()
    mock_stat.st_size = 1024
    
    mocker.patch('pathlib.Path.stat', return_value=mock_stat)
    
    size = get_file_size('/fake/file.txt')
    assert size == 1024

"""
MOCKER os.path
"""

import os

def check_file_exists(filepath):
    """
    COMMENT ? Vérifie existence fichier
    POURQUOI ? Validation avant traitement
    QUAND ? Gestion de fichiers
    """
    return os.path.exists(filepath)

def test_check_file_exists(mocker):
    """
    COMMENT ? Mocker os.path.exists
    POURQUOI ? Contrôler existence
    QUAND ? Test conditions différentes
    """
    # Mock pour fichier existant
    mocker.patch('os.path.exists', return_value=True)
    assert check_file_exists('/fake/file.txt') is True
    
    # Mock pour fichier inexistant
    mocker.patch('os.path.exists', return_value=False)
    assert check_file_exists('/fake/file.txt') is False


# ----------------------------------------------------------------------------
# [ALARM_CLOCK] MOCKER DATETIME ET RANDOM
# ----------------------------------------------------------------------------

"""
PROBLÈME : Tests non-déterministes

Code avec datetime.now() ou random :
- Résultats différents à chaque exécution
- Tests fragiles
- Difficiles à reproduire


SOLUTION : Mocker pour valeurs fixes


MOCKER datetime.now()
"""

from datetime import datetime

def get_greeting():
    """
    COMMENT ? Salutation selon heure
    POURQUOI ? UX personnalisée
    QUAND ? Page d'accueil
    """
    now = datetime.now()
    hour = now.hour
    
    if hour < 12:
        return "Good morning!"
    elif hour < 18:
        return "Good afternoon!"
    else:
        return "Good evening!"

def test_morning_greeting(mocker):
    """
    COMMENT ? Fixer datetime à 10h
    POURQUOI ? Tester cas "matin"
    QUAND ? Test déterministe
    """
    # Mock datetime.now() à 10:00
    mock_now = datetime(2024, 1, 15, 10, 0, 0)
    mocker.patch('datetime.datetime', autospec=True)
    datetime.now.return_value = mock_now
    
    greeting = get_greeting()
    assert greeting == "Good morning!"

def test_afternoon_greeting(mocker):
    """
    COMMENT ? Fixer à 15h
    POURQUOI ? Tester cas "après-midi"
    """
    mock_now = datetime(2024, 1, 15, 15, 0, 0)
    mocker.patch('datetime.datetime', autospec=True)
    datetime.now.return_value = mock_now
    
    greeting = get_greeting()
    assert greeting == "Good afternoon!"

def test_evening_greeting(mocker):
    """
    COMMENT ? Fixer à 20h
    POURQUOI ? Tester cas "soir"
    """
    mock_now = datetime(2024, 1, 15, 20, 0, 0)
    mocker.patch('datetime.datetime', autospec=True)
    datetime.now.return_value = mock_now
    
    greeting = get_greeting()
    assert greeting == "Good evening!"

"""
[IDEE] POURQUOI autospec=True ?

Préserve signature de datetime
Évite bugs si code change


ALTERNATIVE : freezegun (bibliothèque dédiée)
"""

pip install freezegun

from freezegun import freeze_time

@freeze_time("2024-01-15 10:00:00")
def test_greeting_with_freezegun():
    """
    COMMENT ? freezegun gèle le temps
    POURQUOI ? Syntaxe plus simple
    QUAND ? Beaucoup de tests avec dates
    """
    greeting = get_greeting()
    assert greeting == "Good morning!"

"""
[OK] freezegun est plus simple !


MOCKER random
"""

import random

def generate_verification_code():
    """
    COMMENT ? Génère code à 6 chiffres
    POURQUOI ? Authentification 2FA
    QUAND ? Envoi SMS/Email
    """
    return random.randint(100000, 999999)

def test_generate_verification_code(mocker):
    """
    COMMENT ? Fixer random.randint
    POURQUOI ? Test déterministe
    QUAND ? Vérifier format, usage
    """
    mocker.patch('random.randint', return_value=123456)
    
    code = generate_verification_code()
    assert code == 123456
    
    # Vérifier paramètres
    random.randint.assert_called_once_with(100000, 999999)

"""
MOCKER random.choice
"""

def select_random_winner(participants):
    """
    COMMENT ? Sélectionne gagnant aléatoire
    POURQUOI ? Tirage au sort
    QUAND ? Concours, loterie
    """
    return random.choice(participants)

def test_select_random_winner(mocker):
    """
    COMMENT ? Contrôler random.choice
    POURQUOI ? Test déterministe
    """
    mocker.patch('random.choice', return_value='Alice')
    
    participants = ['Alice', 'Bob', 'Charlie']
    winner = select_random_winner(participants)
    
    assert winner == 'Alice'
    random.choice.assert_called_once_with(participants)


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 10 : SYSTÈME COMPLET AVEC MOCKING
# ----------------------------------------------------------------------------

"""
OBJECTIF : Application de notification avec mocking complet


ÉTAPE 1 : CODE APPLICATION
"""

# notification_system.py
import requests
from datetime import datetime
from pathlib import Path
import json

class NotificationService:
    """
    COMMENT ? Service de notifications multi-canaux
    POURQUOI ? Alerter utilisateurs (email, SMS, push)
    QUAND ? Événements importants application
    """
    
    def __init__(self, config_path='config.json'):
        """
        COMMENT ? Initialise avec configuration
        POURQUOI ? Paramètres depuis fichier
        """
        self.config = self._load_config(config_path)
        self.log_file = Path(self.config.get('log_file', 'notifications.log'))
    
    def _load_config(self, config_path):
        """
        COMMENT ? Charge configuration depuis JSON
        POURQUOI ? Centraliser paramètres
        """
        with open(config_path, 'r') as f:
            return json.load(f)
    
    def _log(self, message):
        """
        COMMENT ? Log dans fichier
        POURQUOI ? Traçabilité
        """
        timestamp = datetime.now().isoformat()
        log_entry = f"[{timestamp}] {message}\n"
        
        with open(self.log_file, 'a') as f:
            f.write(log_entry)
    
    def send_email(self, to, subject, body):
        """
        COMMENT ? Envoie email via API
        POURQUOI ? Notification par email
        QUAND ? Alertes importantes
        """
        api_key = self.config['email_api_key']
        api_url = self.config['email_api_url']
        
        # Appel API
        response = requests.post(
            api_url,
            headers={'Authorization': f'Bearer {api_key}'},
            json={
                'to': to,
                'subject': subject,
                'body': body
            }
        )
        
        if response.status_code == 200:
            self._log(f"Email sent to {to}: {subject}")
            return True
        else:
            self._log(f"Email failed to {to}: {response.status_code}")
            return False
    
    def send_sms(self, phone, message):
        """
        COMMENT ? Envoie SMS via API
        POURQUOI ? Notification urgente
        QUAND ? 2FA, alertes critiques
        """
        if len(message) > 160:
            raise ValueError("SMS message too long (max 160 chars)")
        
        api_key = self.config['sms_api_key']
        api_url = self.config['sms_api_url']
        
        response = requests.post(
            api_url,
            headers={'Authorization': f'Bearer {api_key}'},
            json={
                'to': phone,
                'message': message
            }
        )
        
        if response.status_code == 200:
            self._log(f"SMS sent to {phone}")
            return True
        else:
            self._log(f"SMS failed to {phone}: {response.status_code}")
            return False
    
    def notify_user(self, user, notification_type, message):
        """
        COMMENT ? Envoie notification selon préférences user
        POURQUOI ? Respecter choix utilisateur
        QUAND ? Toute notification
        """
        success = False
        
        if notification_type == 'email':
            success = self.send_email(
                to=user['email'],
                subject='Notification',
                body=message
            )
        elif notification_type == 'sms':
            success = self.send_sms(
                phone=user['phone'],
                message=message
            )
        
        if success:
            self._log(f"User {user['id']} notified via {notification_type}")
        
        return success

"""
ÉTAPE 2 : TESTS AVEC MOCKING COMPLET
"""

# tests/test_notification_service.py
import pytest
from pathlib import Path
import json
from datetime import datetime
from notification_system import NotificationService

# ══════════════════════════════════════════════════════════════
# TESTS : CHARGEMENT CONFIGURATION
# ══════════════════════════════════════════════════════════════

def test_load_config(mocker):
    """
    COMMENT ? Mocker lecture fichier config
    POURQUOI ? Pas de fichier réel nécessaire
    QUAND ? Test initialisation
    """
    # Mock config
    fake_config = {
        'email_api_key': 'email_key_123',
        'email_api_url': 'https://email.api.com',
        'sms_api_key': 'sms_key_456',
        'sms_api_url': 'https://sms.api.com',
        'log_file': 'test.log'
    }
    
    # Mock open()
    mock_open = mocker.mock_open(read_data=json.dumps(fake_config))
    mocker.patch('builtins.open', mock_open)
    
    # Créer service
    service = NotificationService('config.json')
    
    # Vérifier config chargée
    assert service.config['email_api_key'] == 'email_key_123'
    assert service.config['sms_api_key'] == 'sms_key_456'
    
    # Vérifier open appelé
    mock_open.assert_called_once_with('config.json', 'r')

# ══════════════════════════════════════════════════════════════
# TESTS : LOGGING
# ══════════════════════════════════════════════════════════════

def test_log_message(mocker):
    """
    COMMENT ? Tester logging sans fichier
    POURQUOI ? Vérifier format et contenu
    QUAND ? Test fonction _log
    """
    # Mock config
    mocker.patch.object(
        NotificationService,
        '_load_config',
        return_value={'log_file': 'test.log'}
    )
    
    # Mock datetime
    fixed_time = datetime(2024, 1, 15, 10, 30, 0)
    mocker.patch('notification_system.datetime')
    datetime.now.return_value = fixed_time
    
    # Mock open
    mock_file = mocker.mock_open()
    mocker.patch('builtins.open', mock_file)
    
    # Créer service et logger
    service = NotificationService()
    service._log("Test message")
    
    # Vérifier écriture
    mock_file.assert_called_with(Path('test.log'), 'a')
    handle = mock_file()
    handle.write.assert_called_once_with(
        "[2024-01-15T10:30:00] Test message\n"
    )

# ══════════════════════════════════════════════════════════════
# TESTS : ENVOI EMAIL
# ══════════════════════════════════════════════════════════════

def test_send_email_success(mocker):
    """
    COMMENT ? Mocker appel API email réussi
    POURQUOI ? Tester succès sans API réelle
    QUAND ? Test positif
    """
    # Mock config
    mocker.patch.object(
        NotificationService,
        '_load_config',
        return_value={
            'email_api_key': 'key_123',
            'email_api_url': 'https://email.api.com/send',
            'log_file': 'test.log'
        }
    )
    
    # Mock requests.post
    mock_response = mocker.Mock()
    mock_response.status_code = 200
    mocker.patch('requests.post', return_value=mock_response)
    
    # Mock logging
    mocker.patch.object(NotificationService, '_log')
    
    # Tester
    service = NotificationService()
    result = service.send_email(
        to='alice@example.com',
        subject='Test',
        body='Hello'
    )
    
    # Vérifier succès
    assert result is True
    
    # Vérifier appel API
    import requests
    requests.post.assert_called_once_with(
        'https://email.api.com/send',
        headers={'Authorization': 'Bearer key_123'},
        json={
            'to': 'alice@example.com',
            'subject': 'Test',
            'body': 'Hello'
        }
    )
    
    # Vérifier log
    service._log.assert_called_once_with(
        "Email sent to alice@example.com: Test"
    )

def test_send_email_failure(mocker):
    """
    COMMENT ? Simuler échec API
    POURQUOI ? Tester gestion d'erreurs
    QUAND ? Test négatif
    """
    mocker.patch.object(
        NotificationService,
        '_load_config',
        return_value={
            'email_api_key': 'key_123',
            'email_api_url': 'https://email.api.com/send',
            'log_file': 'test.log'
        }
    )
    
    # Mock réponse erreur
    mock_response = mocker.Mock()
    mock_response.status_code = 500
    mocker.patch('requests.post', return_value=mock_response)
    
    mocker.patch.object(NotificationService, '_log')
    
    service = NotificationService()
    result = service.send_email('alice@example.com', 'Test', 'Hello')
    
    # Vérifier échec
    assert result is False
    
    # Vérifier log d'erreur
    service._log.assert_called_once_with(
        "Email failed to alice@example.com: 500"
    )

# ══════════════════════════════════════════════════════════════
# TESTS : ENVOI SMS
# ══════════════════════════════════════════════════════════════

def test_send_sms_success(mocker):
    """
    COMMENT ? Tester envoi SMS réussi
    POURQUOI ? Vérifier logique SMS
    """
    mocker.patch.object(
        NotificationService,
        '_load_config',
        return_value={
            'sms_api_key': 'sms_key',
            'sms_api_url': 'https://sms.api.com/send',
            'log_file': 'test.log'
        }
    )
    
    mock_response = mocker.Mock()
    mock_response.status_code = 200
    mocker.patch('requests.post', return_value=mock_response)
    
    mocker.patch.object(NotificationService, '_log')
    
    service = NotificationService()
    result = service.send_sms('+33612345678', 'Test message')
    
    assert result is True
    
    import requests
    requests.post.assert_called_once()

def test_send_sms_too_long(mocker):
    """
    COMMENT ? Tester validation longueur SMS
    POURQUOI ? SMS limité à 160 caractères
    QUAND ? Validation métier
    """
    mocker.patch.object(
        NotificationService,
        '_load_config',
        return_value={'log_file': 'test.log'}
    )
    
    service = NotificationService()
    
    # Message trop long
    long_message = "A" * 161
    
    with pytest.raises(ValueError, match="SMS message too long"):
        service.send_sms('+33612345678', long_message)

# ══════════════════════════════════════════════════════════════
# TESTS : NOTIFICATION UTILISATEUR
# ══════════════════════════════════════════════════════════════

def test_notify_user_email(mocker):
    """
    COMMENT ? Tester notification par email
    POURQUOI ? Orchestration complète
    QUAND ? Test d'intégration
    """
    mocker.patch.object(
        NotificationService,
        '_load_config',
        return_value={'log_file': 'test.log'}
    )
    
    # Mock send_email
    mocker.patch.object(NotificationService, 'send_email', return_value=True)
    mocker.patch.object(NotificationService, '_log')
    
    service = NotificationService()
    user = {
        'id': 123,
        'email': 'alice@example.com',
        'phone': '+33612345678'
    }
    
    result = service.notify_user(user, 'email', 'Test notification')
    
    # Vérifier succès
    assert result is True
    
    # Vérifier send_email appelé
    service.send_email.assert_called_once_with(
        to='alice@example.com',
        subject='Notification',
        body='Test notification'
    )
    
    # Vérifier log
    service._log.assert_called()

def test_notify_user_sms(mocker):
    """
    COMMENT ? Tester notification par SMS
    """
    mocker.patch.object(
        NotificationService,
        '_load_config',
        return_value={'log_file': 'test.log'}
    )
    
    mocker.patch.object(NotificationService, 'send_sms', return_value=True)
    mocker.patch.object(NotificationService, '_log')
    
    service = NotificationService()
    user = {
        'id': 123,
        'email': 'alice@example.com',
        'phone': '+33612345678'
    }
    
    result = service.notify_user(user, 'sms', 'Urgent alert')
    
    assert result is True
    
    service.send_sms.assert_called_once_with(
        phone='+33612345678',
        message='Urgent alert'
    )

# ══════════════════════════════════════════════════════════════
# TEST D'INTÉGRATION COMPLET (AVEC VRAIS FICHIERS)
# ══════════════════════════════════════════════════════════════

def test_full_workflow_with_real_files(tmp_path, mocker):
    """
    COMMENT ? Test complet avec fichiers réels
    POURQUOI ? Test plus réaliste
    QUAND ? Test d'intégration
    """
    # Créer vrai fichier config
    config_file = tmp_path / "config.json"
    config_data = {
        'email_api_key': 'real_email_key',
        'email_api_url': 'https://email.api.com',
        'sms_api_key': 'real_sms_key',
        'sms_api_url': 'https://sms.api.com',
        'log_file': str(tmp_path / 'notifications.log')
    }
    config_file.write_text(json.dumps(config_data))
    
    # Mock seulement les appels HTTP
    mock_response = mocker.Mock()
    mock_response.status_code = 200
    mocker.patch('requests.post', return_value=mock_response)
    
    # Mock datetime pour log prévisible
    fixed_time = datetime(2024, 1, 15, 12, 0, 0)
    mocker.patch('notification_system.datetime')
    datetime.now.return_value = fixed_time
    
    # Créer service (charge vrai config)
    service = NotificationService(str(config_file))
    
    # Envoyer email
    result = service.send_email('alice@example.com', 'Test', 'Hello')
    
    assert result is True
    
    # Vérifier log écrit dans vrai fichier
    log_file = tmp_path / 'notifications.log'
    assert log_file.exists()
    
    log_content = log_file.read_text()
    assert "[2024-01-15T12:00:00]" in log_content
    assert "Email sent to alice@example.com" in log_content

"""
ÉTAPE 3 : EXÉCUTION

# Tous les tests
pytest tests/test_notification_service.py -v

# Avec coverage
pytest tests/test_notification_service.py --cov=notification_system

Output attendu :
tests/test_notification_service.py::test_load_config PASSED
tests/test_notification_service.py::test_log_message PASSED
tests/test_notification_service.py::test_send_email_success PASSED
tests/test_notification_service.py::test_send_email_failure PASSED
tests/test_notification_service.py::test_send_sms_success PASSED
tests/test_notification_service.py::test_send_sms_too_long PASSED
tests/test_notification_service.py::test_notify_user_email PASSED
tests/test_notification_service.py::test_notify_user_sms PASSED
tests/test_notification_service.py::test_full_workflow_with_real_files PASSED

9 passed in 0.15s

Coverage : 100% !
"""


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 10
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Concept de mocking (isolation)
[OK] unittest.mock (Mock, MagicMock, patch)
[OK] pytest-mock (mocker fixture)
[OK] Vérifications d'appels (assert_called_*)
[OK] Mocker APIs (requests)
[OK] Mocker bases de données
[OK] Mocker système de fichiers
[OK] Mocker datetime et random
[OK] Stratégies de mocking avancées


[CLE] POINTS CLÉS

1. Mock = Remplace dépendance par faux objet
2. patch = Remplace temporairement
3. return_value = Comportement statique
4. side_effect = Comportement dynamique
5. mocker (pytest-mock) = Syntaxe simplifiée
6. Patcher OÙ utilisé, pas où défini


[OBJECTIF] FRAMEWORK COMMENT/POURQUOI/QUAND

COMMENT ?
- Mock() pour créer mock
- patch() pour remplacer
- return_value pour configurer
- assert_called_* pour vérifier

POURQUOI ?
- Isolation des tests
- Rapidité (pas d'appels réels)
- Contrôle (simuler erreurs)
- Déterminisme

QUAND ?
- APIs externes
- Bases de données
- Fichiers
- Services cloud
- Dates/random


[GRAPHIQUE] PATTERNS ESSENTIELS

# Mock basique
mock = Mock()
mock.return_value = 42
assert mock() == 42

# Patch avec unittest.mock
with patch('module.function') as mock_func:
    mock_func.return_value = "value"
    # test...

# Patch avec pytest-mock
def test(mocker):
    mock = mocker.patch('module.function')
    mock.return_value = "value"

# Vérifier appels
mock.assert_called_once_with(arg1, arg2)
assert mock.call_count == 3

# Side effect (exceptions)
mock.side_effect = ValueError("Error")

# Side effect (séquence)
mock.side_effect = [1, 2, 3]


[ATTENTION] PIÈGES À ÉVITER

1. [X] Sur-mocking (mocker tout)
   [OK] Mocker seulement dépendances externes

2. [X] Patcher où défini
   [OK] Patcher où utilisé

3. [X] Tests fragiles (trop couplés au mock)
   [OK] Tester comportement, pas implémentation

4. [X] Oublier cleanup
   [OK] Utiliser mocker fixture ou with


[IDEE] BONNES PRATIQUES

1. Préférer pytest-mock à unittest.mock
2. Utiliser tmp_path pour fichiers si possible
3. Mocker au bon niveau (pas trop bas)
4. Vérifier les appels importants seulement
5. DB en mémoire > mocker queries
6. Nommer les mocks clairement


-> PROCHAINE ÉTAPE : Chapitre 11 - Tests Asynchrones

Vous allez apprendre :
- Tests de code async/await
- pytest-asyncio
- Mocker code asynchrone
- Tests de coroutines
- Patterns async avancés

Les tests asynchrones ! [RAPIDE]
"""

Excellent ! Le Chapitre 10 sur le Mocking et Patching est terminé avec le framework COMMENT/POURQUOI/QUAND appliqué partout.

Voulez-vous que je continue avec le **Chapitre 11 : Tests Asynchrones** en gardant le même niveau ultra-détaillé ? C'est un sujet important pour le code moderne avec async/await ! [RAPIDE]

Je continue avec le Chapitre 11 sur les Tests Asynchrones - ultra-détaillé avec pytest et le framework COMMENT/POURQUOI/QUAND !

# ============================================================================
# [GUIDE] CHAPITRE 11 : TESTS ASYNCHRONES AVEC PYTEST
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Comprendre programmation asynchrone (async/await)
[OK] Tester fonctions async avec pytest-asyncio
[OK] Créer fixtures asynchrones
[OK] Mocker code asynchrone
[OK] Tester APIs asynchrones (aiohttp, httpx)
[OK] Tests de bases de données asynchrones
[OK] Patterns avancés pour tests async
[OK] Éviter les pièges courants
"""


# ----------------------------------------------------------------------------
# [REFLEXION] PROGRAMMATION ASYNCHRONE : RAPPEL FONDAMENTAL
# ----------------------------------------------------------------------------

"""
CONCEPT : CONCURRENCE VS PARALLÉLISME

COMMENT ? async/await en Python

POURQUOI ? I/O non-bloquant

QUAND ? Opérations réseau, fichiers, DB


ANALOGIE [COOKING] : CUISINE

SYNCHRONE (Bloquant) :
1. Mettre eau à bouillir
2. ATTENDRE que l'eau bouille (bloqué [STOP])
3. Cuire pâtes
4. ATTENDRE cuisson (bloqué [STOP])
5. Préparer sauce
6. ATTENDRE sauce (bloqué [STOP])

Temps total : 30 minutes


ASYNCHRONE (Non-bloquant) :
1. Mettre eau à bouillir
2. Pendant que ça chauffe -> Préparer sauce [SYNC]
3. Eau bout -> Cuire pâtes
4. Pendant cuisson -> Finir sauce [SYNC]
5. Tout prêt en même temps !

Temps total : 15 minutes


[IDEE] DIFFÉRENCE CLÉ

Synchrone  : Une tâche à la fois (bloque)
Asynchrone : Plusieurs tâches en parallèle (non-bloquant)


CODE SYNCHRONE VS ASYNCHRONE
"""

# ──────────────────────────────
# SYNCHRONE (Bloquant)
# ──────────────────────────────

import time

def fetch_user(user_id):
    """
    COMMENT ? Appel API synchrone
    POURQUOI ? Code classique
    QUAND ? I/O simple, pas de concurrence
    """
    print(f"Fetching user {user_id}...")
    time.sleep(2)  # Simule appel réseau
    return {"id": user_id, "name": f"User {user_id}"}

def main_sync():
    """
    COMMENT ? Traitement séquentiel
    POURQUOI ? Chaque appel bloque
    """
    start = time.time()
    
    # Récupérer 3 users
    user1 = fetch_user(1)  # 2 secondes
    user2 = fetch_user(2)  # 2 secondes
    user3 = fetch_user(3)  # 2 secondes
    
    duration = time.time() - start
    print(f"Total: {duration:.1f}s")  # ~6 secondes [X]

"""
Output :
Fetching user 1...
Fetching user 2...
Fetching user 3...
Total: 6.0s


# ──────────────────────────────
# ASYNCHRONE (Non-bloquant)
# ──────────────────────────────
"""

import asyncio

async def fetch_user_async(user_id):
    """
    COMMENT ? Appel API asynchrone
    POURQUOI ? Non-bloquant
    QUAND ? Opérations I/O multiples
    """
    print(f"Fetching user {user_id}...")
    await asyncio.sleep(2)  # Simule appel réseau async
    return {"id": user_id, "name": f"User {user_id}"}

async def main_async():
    """
    COMMENT ? Traitement concurrent
    POURQUOI ? Tous les appels en parallèle
    """
    start = time.time()
    
    # Lancer 3 appels EN PARALLÈLE
    users = await asyncio.gather(
        fetch_user_async(1),
        fetch_user_async(2),
        fetch_user_async(3)
    )
    
    duration = time.time() - start
    print(f"Total: {duration:.1f}s")  # ~2 secondes [OK]

"""
Output :
Fetching user 1...
Fetching user 2...
Fetching user 3...
Total: 2.0s

[OK] 3× plus rapide !


[IDEE] MOTS-CLÉS PYTHON ASYNC

async def  : Définit fonction asynchrone (coroutine)
await      : Attend résultat d'une coroutine
asyncio    : Bibliothèque standard pour async


POURQUOI TESTER DU CODE ASYNC ?

Code moderne Python :
- FastAPI (async web framework)
- aiohttp (HTTP async)
- databases (DB async)
- asyncpg, motor, etc.

[OK] Besoin de tests asynchrones !


PROBLÈME : Tests synchrones ne marchent PAS
"""

# [X] NE FONCTIONNE PAS
def test_fetch_user_async():
    """Test synchrone pour code async"""
    result = fetch_user_async(1)  # [X] Retourne coroutine, pas résultat
    assert result["name"] == "User 1"  # [X] TypeError

"""
Error : 
TypeError: 'coroutine' object is not subscriptable

POURQUOI ?
fetch_user_async(1) retourne une COROUTINE
Il faut await pour obtenir le résultat


[OK] SOLUTION : pytest-asyncio
"""


# ----------------------------------------------------------------------------
# [TEST] pytest-asyncio : INSTALLATION ET CONFIGURATION
# ----------------------------------------------------------------------------

"""
COMMENT ? Installer pytest-asyncio
"""

pip install pytest-asyncio

"""
POURQUOI ? pytest-asyncio

Permet de :
1. Tester fonctions async avec pytest
2. Créer fixtures async
3. Gérer event loop automatiquement


CONFIGURATION : MODE AUTO

COMMENT ? Activer mode auto
"""

# pytest.ini
"""
[pytest]
asyncio_mode = auto
"""

"""
[IDEE] asyncio_mode = auto

Détecte automatiquement les tests async
Pas besoin de @pytest.mark.asyncio partout

QUAND activer ?
- Beaucoup de tests async
- Projet principalement async


ALTERNATIVE : Mode strict (par défaut)
"""

# Sans asyncio_mode = auto
# Marquer chaque test async

import pytest

@pytest.mark.asyncio
async def test_my_async_function():
    """
    COMMENT ? Marquer test async
    POURQUOI ? pytest sait que c'est async
    QUAND ? Mode strict (défaut)
    """
    result = await fetch_user_async(1)
    assert result["name"] == "User 1"

"""
[IDEE] RECOMMANDATION

asyncio_mode = auto -> Projet async
@pytest.mark.asyncio -> Quelques tests async


# ----------------------------------------------------------------------------
# [EDIT] PREMIER TEST ASYNCHRONE
# ----------------------------------------------------------------------------

"""
EXEMPLE SIMPLE : Fonction async basique
"""

# code.py
import asyncio

async def add_async(a, b):
    """
    COMMENT ? Addition asynchrone
    POURQUOI ? Exemple simple
    QUAND ? Apprentissage
    """
    await asyncio.sleep(0.1)  # Simule opération async
    return a + b

# test_code.py
import pytest

@pytest.mark.asyncio
async def test_add_async():
    """
    COMMENT ? Test de fonction async
    POURQUOI ? Vérifier comportement
    QUAND ? Test unitaire async
    """
    result = await add_async(2, 3)
    assert result == 5

"""
[IDEE] DÉCRYPTAGE

@pytest.mark.asyncio
    Indique à pytest que c'est un test async
    
async def test_add_async():
    Fonction de test asynchrone
    
await add_async(2, 3)
    Appelle fonction async et attend résultat


EXÉCUTION
"""

pytest test_code.py -v

"""
Output :
test_code.py::test_add_async PASSED


EXEMPLE : Test avec assertions multiples
"""

@pytest.mark.asyncio
async def test_multiple_async_calls():
    """
    COMMENT ? Plusieurs appels async
    POURQUOI ? Tester comportement avec concurrence
    """
    # Lancer plusieurs appels
    result1 = await add_async(1, 2)
    result2 = await add_async(3, 4)
    result3 = await add_async(5, 6)
    
    assert result1 == 3
    assert result2 == 7
    assert result3 == 11

"""
EXEMPLE : Test avec asyncio.gather
"""

@pytest.mark.asyncio
async def test_concurrent_calls():
    """
    COMMENT ? Appels concurrents
    POURQUOI ? Tester performances
    QUAND ? Besoin de parallélisme
    """
    import time
    
    start = time.time()
    
    # Lancer 3 appels EN PARALLÈLE
    results = await asyncio.gather(
        add_async(1, 2),
        add_async(3, 4),
        add_async(5, 6)
    )
    
    duration = time.time() - start
    
    # Vérifier résultats
    assert results == [3, 7, 11]
    
    # Vérifier que c'était concurrent (pas 3× plus lent)
    assert duration < 0.3  # ~0.1s (concurrent), pas ~0.3s (séquentiel)


# ----------------------------------------------------------------------------
# [WRAPPED_PRESENT] FIXTURES ASYNCHRONES
# ----------------------------------------------------------------------------

"""
PROBLÈME : Fixtures synchrones pour code async

Setup async (DB, connexions) dans tests async


SOLUTION : Fixtures async

COMMENT ? async def pour fixture
"""

@pytest.fixture
async def async_client():
    """
    COMMENT ? Fixture async
    POURQUOI ? Setup asynchrone nécessaire
    QUAND ? Connexions, clients async
    """
    # Setup
    client = AsyncHTTPClient()
    await client.connect()
    
    yield client
    
    # Teardown
    await client.close()

"""
[IDEE] DÉCRYPTAGE

@pytest.fixture
async def async_client():
    Fixture asynchrone
    
await client.connect()
    Setup async
    
yield client
    Fournir au test
    
await client.close()
    Teardown async


UTILISATION
"""

@pytest.mark.asyncio
async def test_with_async_fixture(async_client):
    """
    COMMENT ? Utiliser fixture async
    POURQUOI ? Client HTTP async prêt
    """
    response = await async_client.get('/users/1')
    assert response.status_code == 200

"""
EXEMPLE COMPLET : Fixture base de données async
"""

import pytest
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

@pytest.fixture(scope="function")
async def db_session():
    """
    COMMENT ? Session DB async
    POURQUOI ? Tests avec DB asynchrone
    QUAND ? SQLAlchemy async, asyncpg
    """
    # Créer engine async
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        echo=False
    )
    
    # Créer tables
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    
    # Session factory
    async_session = sessionmaker(
        engine, 
        class_=AsyncSession,
        expire_on_commit=False
    )
    
    # Créer session
    async with async_session() as session:
        yield session
        await session.rollback()
    
    # Cleanup
    await engine.dispose()

@pytest.mark.asyncio
async def test_user_creation(db_session):
    """
    COMMENT ? Test avec DB async
    POURQUOI ? Créer/récupérer user
    """
    # Créer user
    user = User(name="Alice", email="alice@test.com")
    db_session.add(user)
    await db_session.commit()
    
    # Récupérer
    from sqlalchemy import select
    result = await db_session.execute(
        select(User).where(User.name == "Alice")
    )
    retrieved = result.scalar_one()
    
    assert retrieved.name == "Alice"

"""
SCOPES AVEC FIXTURES ASYNC

COMMENT ? Scope avec fixtures async
"""

@pytest.fixture(scope="session")
async def event_loop_session():
    """
    COMMENT ? Event loop pour session
    POURQUOI ? Partager entre tests
    QUAND ? Setup coûteux (Docker, etc.)
    """
    loop = asyncio.get_event_loop_policy().new_event_loop()
    yield loop
    loop.close()

@pytest.fixture(scope="session")
async def docker_container(event_loop_session):
    """
    COMMENT ? Container Docker pour session
    POURQUOI ? Démarrage lent
    """
    container = await start_docker_container()
    yield container
    await stop_docker_container(container)

"""
[IDEE] ATTENTION : Scopes async

function : OK (défaut)
class    : OK
module   : OK
session  : Nécessite event_loop custom


FIXTURES ASYNC PARAMÉTRÉES
"""

@pytest.fixture(params=["sqlite", "postgresql"])
async def db_engine(request):
    """
    COMMENT ? Fixture async paramétrée
    POURQUOI ? Tester plusieurs DB
    QUAND ? Compatibilité multi-DB
    """
    db_type = request.param
    
    if db_type == "sqlite":
        engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    elif db_type == "postgresql":
        engine = create_async_engine("postgresql+asyncpg://localhost/test")
    
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    
    yield engine
    
    await engine.dispose()

@pytest.mark.asyncio
async def test_with_different_dbs(db_engine):
    """
    COMMENT ? Test exécuté 2 fois (sqlite, postgresql)
    POURQUOI ? Vérifier compatibilité
    """
    # Test fonctionne avec les 2 DB
    async with AsyncSession(db_engine) as session:
        user = User(name="Test")
        session.add(user)
        await session.commit()


# ----------------------------------------------------------------------------
# [SCENARIO] MOCKER CODE ASYNCHRONE
# ----------------------------------------------------------------------------

"""
PROBLÈME : Mocker fonctions async

Code async appelle fonctions async
Mocks doivent être async aussi !


SOLUTION : AsyncMock (Python 3.8+)
"""

from unittest.mock import AsyncMock
import pytest

# Code à tester
async def get_user_data(user_id):
    """
    COMMENT ? Récupère données user
    POURQUOI ? Agrégation depuis API
    """
    api_client = APIClient()
    user = await api_client.fetch_user(user_id)
    posts = await api_client.fetch_posts(user_id)
    
    return {
        "user": user,
        "posts": posts
    }

# Test avec AsyncMock
@pytest.mark.asyncio
async def test_get_user_data(mocker):
    """
    COMMENT ? Mocker avec AsyncMock
    POURQUOI ? fetch_user et fetch_posts sont async
    QUAND ? Test de code async
    """
    # Créer AsyncMock
    mock_client = mocker.AsyncMock()
    
    # Configurer retours
    mock_client.fetch_user.return_value = {"id": 1, "name": "Alice"}
    mock_client.fetch_posts.return_value = [
        {"id": 1, "title": "Post 1"},
        {"id": 2, "title": "Post 2"}
    ]
    
    # Patcher APIClient
    mocker.patch('module.APIClient', return_value=mock_client)
    
    # Tester
    result = await get_user_data(1)
    
    assert result["user"]["name"] == "Alice"
    assert len(result["posts"]) == 2
    
    # Vérifier appels
    mock_client.fetch_user.assert_awaited_once_with(1)
    mock_client.fetch_posts.assert_awaited_once_with(1)

"""
[IDEE] DIFFÉRENCE Mock vs AsyncMock

Mock      : Pour fonctions synchrones
AsyncMock : Pour fonctions asynchrones (coroutines)


ASSERTIONS SPÉCIALES ASYNCMOCK

assert_awaited()              : Au moins un await
assert_awaited_once()         : Exactement un await
assert_awaited_with(...)      : Dernier await avec args
assert_awaited_once_with(...) : Un seul await avec args
await_count                   : Nombre d'awaits


EXEMPLE : Vérifications détaillées
"""

@pytest.mark.asyncio
async def test_async_mock_assertions(mocker):
    """
    COMMENT ? Vérifier appels async
    POURQUOI ? S'assurer du comportement
    """
    mock_func = mocker.AsyncMock()
    
    # Appeler plusieurs fois
    await mock_func(1, 2)
    await mock_func(3, 4)
    
    # Vérifications
    assert mock_func.await_count == 2
    mock_func.assert_awaited()
    mock_func.assert_awaited_with(3, 4)  # Dernier appel

"""
SIDE_EFFECT AVEC ASYNCMOCK

COMMENT ? Exception async
"""

@pytest.mark.asyncio
async def test_async_exception(mocker):
    """
    COMMENT ? Simuler exception dans code async
    POURQUOI ? Tester gestion d'erreurs
    """
    mock_api = mocker.AsyncMock()
    mock_api.fetch_user.side_effect = ConnectionError("API unreachable")
    
    mocker.patch('module.APIClient', return_value=mock_api)
    
    with pytest.raises(ConnectionError):
        await get_user_data(1)

"""
SIDE_EFFECT : Valeurs multiples
"""

@pytest.mark.asyncio
async def test_async_multiple_returns(mocker):
    """
    COMMENT ? Retours différents par appel
    POURQUOI ? Simuler séquence
    """
    mock_func = mocker.AsyncMock()
    mock_func.side_effect = [
        {"id": 1, "name": "Alice"},
        {"id": 2, "name": "Bob"},
        {"id": 3, "name": "Charlie"}
    ]
    
    # Appels successifs
    result1 = await mock_func()
    result2 = await mock_func()
    result3 = await mock_func()
    
    assert result1["name"] == "Alice"
    assert result2["name"] == "Bob"
    assert result3["name"] == "Charlie"

"""
MOCKER AVEC pytest-mock
"""

@pytest.mark.asyncio
async def test_with_mocker_async(mocker):
    """
    COMMENT ? mocker.patch pour code async
    POURQUOI ? Syntaxe pytest-mock
    """
    # Créer mock async
    mock_fetch = mocker.AsyncMock(return_value={"data": "mocked"})
    
    # Patcher
    mocker.patch('module.fetch_data', mock_fetch)
    
    # Tester
    from module import fetch_data
    result = await fetch_data()
    
    assert result["data"] == "mocked"


# ----------------------------------------------------------------------------
# [WEB] TESTER APIs ASYNCHRONES (aiohttp, httpx)
# ----------------------------------------------------------------------------

"""
CAS D'USAGE : Client HTTP asynchrone

COMMENT ? Tester aiohttp
"""

import aiohttp

async def fetch_github_user(username):
    """
    COMMENT ? Récupère profil GitHub
    POURQUOI ? Intégration API externe
    QUAND ? Application avec données GitHub
    """
    async with aiohttp.ClientSession() as session:
        async with session.get(f'https://api.github.com/users/{username}') as response:
            return await response.json()

@pytest.mark.asyncio
async def test_fetch_github_user(mocker):
    """
    COMMENT ? Mocker aiohttp
    POURQUOI ? Pas d'appel API réel
    QUAND ? Test unitaire
    """
    # Mock response
    mock_response = mocker.AsyncMock()
    mock_response.json.return_value = {
        "login": "octocat",
        "id": 1,
        "name": "The Octocat"
    }
    
    # Mock session.get
    mock_get = mocker.AsyncMock(return_value=mock_response)
    mock_session = mocker.AsyncMock()
    mock_session.get = mocker.MagicMock(return_value=mock_get)
    
    # Patcher ClientSession
    mocker.patch('aiohttp.ClientSession', return_value=mock_session)
    
    # Tester
    user = await fetch_github_user('octocat')
    
    assert user["login"] == "octocat"

"""
[IDEE] PROBLÈME : aiohttp compliqué à mocker

Context managers imbriqués
Mock complexe


[OK] MEILLEURE APPROCHE : aioresponses
"""

pip install aioresponses

"""
COMMENT ? Utiliser aioresponses
"""

from aioresponses import aioresponses

@pytest.mark.asyncio
async def test_fetch_github_user_with_aioresponses():
    """
    COMMENT ? aioresponses pour mocker aiohttp
    POURQUOI ? Plus simple et lisible
    QUAND ? Tests avec aiohttp
    """
    with aioresponses() as m:
        # Mock l'URL
        m.get(
            'https://api.github.com/users/octocat',
            payload={
                "login": "octocat",
                "id": 1,
                "name": "The Octocat"
            }
        )
        
        # Tester
        user = await fetch_github_user('octocat')
        
        assert user["login"] == "octocat"

"""
[OK] Beaucoup plus simple !


TESTER HTTPX (Alternative moderne à aiohttp)
"""

import httpx

async def fetch_user_httpx(user_id):
    """
    COMMENT ? Requête avec httpx
    POURQUOI ? Client HTTP moderne
    QUAND ? Alternative à aiohttp
    """
    async with httpx.AsyncClient() as client:
        response = await client.get(f'https://api.example.com/users/{user_id}')
        response.raise_for_status()
        return response.json()

@pytest.mark.asyncio
async def test_fetch_user_httpx(respx_mock):
    """
    COMMENT ? Tester httpx avec respx
    POURQUOI ? Bibliothèque dédiée
    QUAND ? Tests httpx
    """
    # Mock l'endpoint
    respx_mock.get('https://api.example.com/users/1').mock(
        return_value=httpx.Response(
            200,
            json={"id": 1, "name": "Alice"}
        )
    )
    
    # Tester
    user = await fetch_user_httpx(1)
    
    assert user["name"] == "Alice"

"""
Installation respx :
"""
pip install respx

"""
FIXTURE POUR CLIENT HTTP ASYNC
"""

@pytest.fixture
async def http_client():
    """
    COMMENT ? Client httpx réutilisable
    POURQUOI ? Partager entre tests
    QUAND ? Beaucoup de tests API
    """
    async with httpx.AsyncClient(base_url="https://api.example.com") as client:
        yield client

@pytest.mark.asyncio
async def test_with_http_client(http_client, respx_mock):
    """
    COMMENT ? Utiliser fixture client
    """
    respx_mock.get('https://api.example.com/users/1').mock(
        return_value=httpx.Response(200, json={"id": 1, "name": "Alice"})
    )
    
    response = await http_client.get('/users/1')
    data = response.json()
    
    assert data["name"] == "Alice"


# ----------------------------------------------------------------------------
# [SAUVEGARDE] TESTER BASES DE DONNÉES ASYNCHRONES
# ----------------------------------------------------------------------------

"""
FRAMEWORKS ASYNC POUR DB

- SQLAlchemy async (asyncpg pour PostgreSQL, aiosqlite pour SQLite)
- Motor (MongoDB async)
- aiopg (PostgreSQL)
- aiomysql (MySQL)


EXEMPLE : SQLAlchemy async complet
"""

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy import Column, Integer, String, select

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'
    
    id = Column(Integer, primary_key=True)
    name = Column(String(50))
    email = Column(String(100))

class UserRepository:
    """
    COMMENT ? Repository pour User
    POURQUOI ? Couche d'accès données
    QUAND ? Architecture en couches
    """
    
    def __init__(self, session: AsyncSession):
        self.session = session
    
    async def create(self, name: str, email: str):
        """
        COMMENT ? Crée user async
        """
        user = User(name=name, email=email)
        self.session.add(user)
        await self.session.commit()
        await self.session.refresh(user)
        return user
    
    async def get_by_id(self, user_id: int):
        """
        COMMENT ? Récupère user par ID
        """
        result = await self.session.execute(
            select(User).where(User.id == user_id)
        )
        return result.scalar_one_or_none()
    
    async def get_all(self):
        """
        COMMENT ? Récupère tous les users
        """
        result = await self.session.execute(select(User))
        return result.scalars().all()
    
    async def update(self, user_id: int, name: str = None, email: str = None):
        """
        COMMENT ? Met à jour user
        """
        user = await self.get_by_id(user_id)
        if user:
            if name:
                user.name = name
            if email:
                user.email = email
            await self.session.commit()
            await self.session.refresh(user)
        return user
    
    async def delete(self, user_id: int):
        """
        COMMENT ? Supprime user
        """
        user = await self.get_by_id(user_id)
        if user:
            await self.session.delete(user)
            await self.session.commit()
        return user

"""
TESTS AVEC SQLAlchemy async
"""

# tests/conftest.py
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

@pytest_asyncio.fixture
async def async_engine():
    """
    COMMENT ? Engine SQLite async en mémoire
    POURQUOI ? Tests rapides sans DB externe
    QUAND ? Tests d'intégration DB
    """
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        echo=False
    )
    
    # Créer tables
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    
    yield engine
    
    # Cleanup
    await engine.dispose()

@pytest_asyncio.fixture
async def db_session(async_engine):
    """
    COMMENT ? Session async pour chaque test
    POURQUOI ? Isolation des tests
    """
    async_session = sessionmaker(
        async_engine,
        class_=AsyncSession,
        expire_on_commit=False
    )
    
    async with async_session() as session:
        yield session
        await session.rollback()

@pytest_asyncio.fixture
async def user_repository(db_session):
    """
    COMMENT ? Repository prêt à l'emploi
    POURQUOI ? Simplifier tests
    """
    return UserRepository(db_session)

# tests/test_user_repository.py
import pytest

@pytest.mark.asyncio
async def test_create_user(user_repository):
    """
    COMMENT ? Test création user
    POURQUOI ? Vérifier insertion DB
    """
    user = await user_repository.create("Alice", "alice@test.com")
    
    assert user.id is not None
    assert user.name == "Alice"
    assert user.email == "alice@test.com"

@pytest.mark.asyncio
async def test_get_user_by_id(user_repository):
    """
    COMMENT ? Test récupération par ID
    """
    # Créer user
    created = await user_repository.create("Bob", "bob@test.com")
    
    # Récupérer
    retrieved = await user_repository.get_by_id(created.id)
    
    assert retrieved is not None
    assert retrieved.name == "Bob"

@pytest.mark.asyncio
async def test_get_nonexistent_user(user_repository):
    """
    COMMENT ? Test user inexistant
    POURQUOI ? Cas d'erreur
    """
    user = await user_repository.get_by_id(999)
    assert user is None

@pytest.mark.asyncio
async def test_get_all_users(user_repository):
    """
    COMMENT ? Test récupération de tous les users
    """
    # Créer plusieurs users
    await user_repository.create("Alice", "alice@test.com")
    await user_repository.create("Bob", "bob@test.com")
    await user_repository.create("Charlie", "charlie@test.com")
    
    # Récupérer tous
    users = await user_repository.get_all()
    
    assert len(users) == 3
    names = [u.name for u in users]
    assert "Alice" in names
    assert "Bob" in names
    assert "Charlie" in names

@pytest.mark.asyncio
async def test_update_user(user_repository):
    """
    COMMENT ? Test mise à jour
    """
    # Créer
    user = await user_repository.create("Alice", "alice@test.com")
    
    # Mettre à jour
    updated = await user_repository.update(
        user.id,
        name="Alice Updated",
        email="alice.new@test.com"
    )
    
    assert updated.name == "Alice Updated"
    assert updated.email == "alice.new@test.com"

@pytest.mark.asyncio
async def test_delete_user(user_repository):
    """
    COMMENT ? Test suppression
    """
    # Créer
    user = await user_repository.create("Bob", "bob@test.com")
    user_id = user.id
    
    # Supprimer
    deleted = await user_repository.delete(user_id)
    assert deleted.id == user_id
    
    # Vérifier supprimé
    retrieved = await user_repository.get_by_id(user_id)
    assert retrieved is None

"""
TESTS AVEC TRANSACTIONS
"""

@pytest.mark.asyncio
async def test_transaction_rollback(db_session):
    """
    COMMENT ? Test rollback de transaction
    POURQUOI ? Vérifier gestion d'erreurs
    QUAND ? Tests de transactions
    """
    repo = UserRepository(db_session)
    
    try:
        # Créer user
        user = await repo.create("Alice", "alice@test.com")
        
        # Simuler erreur
        raise Exception("Something went wrong")
        
        await db_session.commit()
    except:
        await db_session.rollback()
    
    # Vérifier que rien n'a été créé
    users = await repo.get_all()
    assert len(users) == 0


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 11 : API ASYNCHRONE COMPLÈTE
# ----------------------------------------------------------------------------

"""
OBJECTIF : Service de météo asynchrone avec tests complets


ÉTAPE 1 : CODE SERVICE MÉTÉO
"""

# weather_service.py
import httpx
from typing import Dict, List
from datetime import datetime
import asyncio

class WeatherAPIError(Exception):
    """Exception pour erreurs API météo"""
    pass

class WeatherService:
    """
    COMMENT ? Service météo asynchrone
    POURQUOI ? Récupérer données météo de plusieurs villes
    QUAND ? Application météo, dashboard
    """
    
    def __init__(self, api_key: str, base_url: str = "https://api.weather.com"):
        self.api_key = api_key
        self.base_url = base_url
        self.client = None
    
    async def __aenter__(self):
        """
        COMMENT ? Context manager async (entrée)
        POURQUOI ? Gérer connexion client HTTP
        """
        self.client = httpx.AsyncClient(base_url=self.base_url)
        return self
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """
        COMMENT ? Context manager async (sortie)
        POURQUOI ? Fermer connexion proprement
        """
        if self.client:
            await self.client.aclose()
    
    async def get_current_weather(self, city: str) -> Dict:
        """
        COMMENT ? Récupère météo actuelle d'une ville
        POURQUOI ? Données temps réel
        
        Returns:
            Dict avec temperature, condition, humidity
        
        Raises:
            WeatherAPIError: Si erreur API
        """
        try:
            response = await self.client.get(
                f"/current",
                params={"city": city, "key": self.api_key}
            )
            response.raise_for_status()
            
            data = response.json()
            
            return {
                "city": city,
                "temperature": data["temp"],
                "condition": data["condition"],
                "humidity": data["humidity"],
                "timestamp": datetime.now().isoformat()
            }
        
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise WeatherAPIError(f"City '{city}' not found")
            elif e.response.status_code == 401:
                raise WeatherAPIError("Invalid API key")
            else:
                raise WeatherAPIError(f"API error: {e.response.status_code}")
        
        except httpx.RequestError as e:
            raise WeatherAPIError(f"Network error: {str(e)}")
    
    async def get_forecast(self, city: str, days: int = 7) -> List[Dict]:
        """
        COMMENT ? Récupère prévisions météo
        POURQUOI ? Planification
        
        Args:
            city: Nom de la ville
            days: Nombre de jours (1-14)
        
        Returns:
            Liste de prévisions par jour
        """
        if not 1 <= days <= 14:
            raise ValueError("Days must be between 1 and 14")
        
        response = await self.client.get(
            f"/forecast",
            params={"city": city, "days": days, "key": self.api_key}
        )
        response.raise_for_status()
        
        data = response.json()
        return data["forecast"]
    
    async def get_multiple_cities(self, cities: List[str]) -> Dict[str, Dict]:
        """
        COMMENT ? Récupère météo de plusieurs villes EN PARALLÈLE
        POURQUOI ? Performances (concurrent)
        QUAND ? Dashboard multi-villes
        
        Returns:
            Dict {city: weather_data}
        """
        # Lancer toutes les requêtes en parallèle
        tasks = [self.get_current_weather(city) for city in cities]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        # Construire dictionnaire résultats
        weather_data = {}
        for city, result in zip(cities, results):
            if isinstance(result, Exception):
                weather_data[city] = {"error": str(result)}
            else:
                weather_data[city] = result
        
        return weather_data
    
    async def get_weather_alerts(self, city: str) -> List[Dict]:
        """
        COMMENT ? Récupère alertes météo
        POURQUOI ? Prévenir dangers (tempête, etc.)
        
        Returns:
            Liste d'alertes actives
        """
        response = await self.client.get(
            f"/alerts",
            params={"city": city, "key": self.api_key}
        )
        response.raise_for_status()
        
        data = response.json()
        return data.get("alerts", [])

"""
ÉTAPE 2 : TESTS COMPLETS
"""

# tests/conftest.py
import pytest
import pytest_asyncio
from weather_service import WeatherService

@pytest_asyncio.fixture
async def weather_service():
    """
    COMMENT ? Fixture WeatherService
    POURQUOI ? Service prêt pour tests
    """
    async with WeatherService(api_key="test_key_123") as service:
        yield service

# tests/test_weather_service.py
import pytest
import httpx
from datetime import datetime
from weather_service import WeatherService, WeatherAPIError

# ══════════════════════════════════════════════════════════════
# TESTS : get_current_weather
# ══════════════════════════════════════════════════════════════

@pytest.mark.asyncio
async def test_get_current_weather_success(weather_service, respx_mock):
    """
    COMMENT ? Test récupération météo réussie
    POURQUOI ? Cas normal
    """
    # Mock API response
    respx_mock.get(
        "https://api.weather.com/current",
        params={"city": "Paris", "key": "test_key_123"}
    ).mock(
        return_value=httpx.Response(
            200,
            json={
                "temp": 22.5,
                "condition": "Sunny",
                "humidity": 65
            }
        )
    )
    
    # Tester
    weather = await weather_service.get_current_weather("Paris")
    
    # Vérifications
    assert weather["city"] == "Paris"
    assert weather["temperature"] == 22.5
    assert weather["condition"] == "Sunny"
    assert weather["humidity"] == 65
    assert "timestamp" in weather

@pytest.mark.asyncio
async def test_get_current_weather_city_not_found(weather_service, respx_mock):
    """
    COMMENT ? Test ville inexistante
    POURQUOI ? Gestion erreur 404
    """
    respx_mock.get(
        "https://api.weather.com/current"
    ).mock(
        return_value=httpx.Response(404)
    )
    
    with pytest.raises(WeatherAPIError, match="not found"):
        await weather_service.get_current_weather("InvalidCity")

@pytest.mark.asyncio
async def test_get_current_weather_invalid_api_key(weather_service, respx_mock):
    """
    COMMENT ? Test clé API invalide
    POURQUOI ? Gestion erreur 401
    """
    respx_mock.get(
        "https://api.weather.com/current"
    ).mock(
        return_value=httpx.Response(401)
    )
    
    with pytest.raises(WeatherAPIError, match="Invalid API key"):
        await weather_service.get_current_weather("Paris")

@pytest.mark.asyncio
async def test_get_current_weather_network_error(weather_service, respx_mock):
    """
    COMMENT ? Test erreur réseau
    POURQUOI ? Gestion erreur connexion
    """
    respx_mock.get(
        "https://api.weather.com/current"
    ).mock(
        side_effect=httpx.ConnectError("Connection failed")
    )
    
    with pytest.raises(WeatherAPIError, match="Network error"):
        await weather_service.get_current_weather("Paris")

# ══════════════════════════════════════════════════════════════
# TESTS : get_forecast
# ══════════════════════════════════════════════════════════════

@pytest.mark.asyncio
async def test_get_forecast_success(weather_service, respx_mock):
    """
    COMMENT ? Test prévisions météo
    """
    forecast_data = {
        "forecast": [
            {"date": "2024-01-01", "temp": 20, "condition": "Sunny"},
            {"date": "2024-01-02", "temp": 18, "condition": "Cloudy"},
            {"date": "2024-01-03", "temp": 22, "condition": "Sunny"},
        ]
    }
    
    respx_mock.get(
        "https://api.weather.com/forecast"
    ).mock(
        return_value=httpx.Response(200, json=forecast_data)
    )
    
    forecast = await weather_service.get_forecast("Paris", days=3)
    
    assert len(forecast) == 3
    assert forecast[0]["date"] == "2024-01-01"
    assert forecast[0]["temp"] == 20

@pytest.mark.asyncio
async def test_get_forecast_invalid_days(weather_service):
    """
    COMMENT ? Test validation nombre de jours
    POURQUOI ? Règle métier (1-14 jours)
    """
    with pytest.raises(ValueError, match="Days must be between 1 and 14"):
        await weather_service.get_forecast("Paris", days=0)
    
    with pytest.raises(ValueError, match="Days must be between 1 and 14"):
        await weather_service.get_forecast("Paris", days=15)

# ══════════════════════════════════════════════════════════════
# TESTS : get_multiple_cities (CONCURRENT)
# ══════════════════════════════════════════════════════════════

@pytest.mark.asyncio
async def test_get_multiple_cities_success(weather_service, respx_mock):
    """
    COMMENT ? Test récupération multiple EN PARALLÈLE
    POURQUOI ? Vérifier concurrence
    """
    # Mock pour Paris
    respx_mock.get(
        "https://api.weather.com/current",
        params={"city": "Paris", "key": "test_key_123"}
    ).mock(
        return_value=httpx.Response(
            200,
            json={"temp": 22, "condition": "Sunny", "humidity": 60}
        )
    )
    
    # Mock pour London
    respx_mock.get(
        "https://api.weather.com/current",
        params={"city": "London", "key": "test_key_123"}
    ).mock(
        return_value=httpx.Response(
            200,
            json={"temp": 18, "condition": "Rainy", "humidity": 80}
        )
    )
    
    # Mock pour Tokyo
    respx_mock.get(
        "https://api.weather.com/current",
        params={"city": "Tokyo", "key": "test_key_123"}
    ).mock(
        return_value=httpx.Response(
            200,
            json={"temp": 25, "condition": "Clear", "humidity": 55}
        )
    )
    
    # Tester
    import time
    start = time.time()
    
    results = await weather_service.get_multiple_cities(
        ["Paris", "London", "Tokyo"]
    )
    
    duration = time.time() - start
    
    # Vérifier résultats
    assert len(results) == 3
    assert results["Paris"]["temperature"] == 22
    assert results["London"]["temperature"] == 18
    assert results["Tokyo"]["temperature"] == 25
    
    # Vérifier que c'était concurrent (pas 3× plus lent)
    # En réalité très rapide car mock, mais démontre le principe
    assert "Paris" in results
    assert "London" in results
    assert "Tokyo" in results

@pytest.mark.asyncio
async def test_get_multiple_cities_partial_failure(weather_service, respx_mock):
    """
    COMMENT ? Test avec erreur sur une ville
    POURQUOI ? Vérifier resilience
    QUAND ? Une API échoue mais pas les autres
    """
    # Mock Paris : succès
    respx_mock.get(
        "https://api.weather.com/current",
        params={"city": "Paris", "key": "test_key_123"}
    ).mock(
        return_value=httpx.Response(
            200,
            json={"temp": 22, "condition": "Sunny", "humidity": 60}
        )
    )
    
    # Mock InvalidCity : erreur 404
    respx_mock.get(
        "https://api.weather.com/current",
        params={"city": "InvalidCity", "key": "test_key_123"}
    ).mock(
        return_value=httpx.Response(404)
    )
    
    # Mock Tokyo : succès
    respx_mock.get(
        "https://api.weather.com/current",
        params={"city": "Tokyo", "key": "test_key_123"}
    ).mock(
        return_value=httpx.Response(
            200,
            json={"temp": 25, "condition": "Clear", "humidity": 55}
        )
    )
    
    # Tester
    results = await weather_service.get_multiple_cities(
        ["Paris", "InvalidCity", "Tokyo"]
    )
    
    # Vérifier
    assert "Paris" in results
    assert results["Paris"]["temperature"] == 22
    
    assert "InvalidCity" in results
    assert "error" in results["InvalidCity"]
    
    assert "Tokyo" in results
    assert results["Tokyo"]["temperature"] == 25

# ══════════════════════════════════════════════════════════════
# TESTS : get_weather_alerts
# ══════════════════════════════════════════════════════════════

@pytest.mark.asyncio
async def test_get_weather_alerts_with_alerts(weather_service, respx_mock):
    """
    COMMENT ? Test avec alertes actives
    """
    respx_mock.get(
        "https://api.weather.com/alerts"
    ).mock(
        return_value=httpx.Response(
            200,
            json={
                "alerts": [
                    {
                        "type": "storm",
                        "severity": "high",
                        "message": "Severe storm warning"
                    }
                ]
            }
        )
    )
    
    alerts = await weather_service.get_weather_alerts("Paris")
    
    assert len(alerts) == 1
    assert alerts[0]["type"] == "storm"
    assert alerts[0]["severity"] == "high"

@pytest.mark.asyncio
async def test_get_weather_alerts_no_alerts(weather_service, respx_mock):
    """
    COMMENT ? Test sans alertes
    """
    respx_mock.get(
        "https://api.weather.com/alerts"
    ).mock(
        return_value=httpx.Response(
            200,
            json={"alerts": []}
        )
    )
    
    alerts = await weather_service.get_weather_alerts("Paris")
    
    assert len(alerts) == 0

# ══════════════════════════════════════════════════════════════
# TESTS : Context Manager
# ══════════════════════════════════════════════════════════════

@pytest.mark.asyncio
async def test_context_manager_lifecycle(respx_mock):
    """
    COMMENT ? Test lifecycle du context manager
    POURQUOI ? Vérifier ouverture/fermeture client
    """
    respx_mock.get(
        "https://api.weather.com/current"
    ).mock(
        return_value=httpx.Response(
            200,
            json={"temp": 22, "condition": "Sunny", "humidity": 60}
        )
    )
    
    # Utiliser context manager
    async with WeatherService(api_key="test_key") as service:
        assert service.client is not None
        
        weather = await service.get_current_weather("Paris")
        assert weather["temperature"] == 22
    
    # Après sortie du context, client devrait être fermé
    # (Dans un vrai test, on vérifierait que client.aclose() a été appelé)

"""
ÉTAPE 3 : EXÉCUTION

# Tous les tests
pytest tests/test_weather_service.py -v

# Avec coverage
pytest tests/test_weather_service.py --cov=weather_service

# Tests asynchrones seulement
pytest tests/test_weather_service.py -v -m asyncio

Output attendu :
tests/test_weather_service.py::test_get_current_weather_success PASSED
tests/test_weather_service.py::test_get_current_weather_city_not_found PASSED
tests/test_weather_service.py::test_get_current_weather_invalid_api_key PASSED
tests/test_weather_service.py::test_get_current_weather_network_error PASSED
tests/test_weather_service.py::test_get_forecast_success PASSED
tests/test_weather_service.py::test_get_forecast_invalid_days PASSED
tests/test_weather_service.py::test_get_multiple_cities_success PASSED
tests/test_weather_service.py::test_get_multiple_cities_partial_failure PASSED
tests/test_weather_service.py::test_get_weather_alerts_with_alerts PASSED
tests/test_weather_service.py::test_get_weather_alerts_no_alerts PASSED
tests/test_weather_service.py::test_context_manager_lifecycle PASSED

11 passed in 0.25s

Coverage : ~95% !
"""


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 11
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Programmation asynchrone (async/await)
[OK] pytest-asyncio pour tests async
[OK] Fixtures asynchrones
[OK] AsyncMock pour mocker code async
[OK] Tester APIs async (aiohttp, httpx)
[OK] Bases de données asynchrones
[OK] Tests concurrents (asyncio.gather)
[OK] Patterns avancés async


[CLE] POINTS CLÉS

1. async def = Fonction asynchrone (coroutine)
2. await = Attendre résultat async
3. @pytest.mark.asyncio = Marquer test async
4. AsyncMock = Mock pour fonctions async
5. assert_awaited_* = Vérifier appels async
6. Fixtures async = async def avec @pytest.fixture


[OBJECTIF] FRAMEWORK COMMENT/POURQUOI/QUAND

COMMENT ?
- pytest-asyncio pour tests
- async def pour fonctions/fixtures
- await pour appeler code async
- AsyncMock pour mocker

POURQUOI ?
- Code async = non-bloquant
- Tests doivent gérer event loop
- Performances (concurrence)
- Code moderne (FastAPI, etc.)

QUAND ?
- APIs asynchrones
- Bases de données async
- WebSockets
- Services concurrents
- I/O intensif


[GRAPHIQUE] PATTERNS ESSENTIELS

# Test async basique
@pytest.mark.asyncio
async def test_async_function():
    result = await async_function()
    assert result == expected

# Fixture async
@pytest.fixture
async def async_resource():
    resource = await create_resource()
    yield resource
    await resource.cleanup()

# Mock async
@pytest.mark.asyncio
async def test_with_async_mock(mocker):
    mock = mocker.AsyncMock()
    mock.return_value = "value"
    result = await mock()
    mock.assert_awaited_once()

# Tests concurrents
@pytest.mark.asyncio
async def test_concurrent():
    results = await asyncio.gather(
        async_func1(),
        async_func2(),
        async_func3()
    )
    assert len(results) == 3


[ATTENTION] PIÈGES À ÉVITER

1. [X] Oublier await
   [OK] Toujours await les coroutines

2. [X] Utiliser Mock au lieu d'AsyncMock
   [OK] AsyncMock pour fonctions async

3. [X] Fixtures sync pour code async
   [OK] Fixtures async (async def)

4. [X] Oublier @pytest.mark.asyncio
   [OK] Marquer tous les tests async

5. [X] Event loop conflicts
   [OK] Utiliser pytest-asyncio


[IDEE] BONNES PRATIQUES

1. asyncio_mode = auto dans pytest.ini
2. Fixtures async pour resources async
3. AsyncMock pour code async
4. aioresponses/respx pour HTTP mocking
5. DB en mémoire pour tests DB async
6. pytest-asyncio toujours à jour
7. Tester concurrence avec gather


[DOCS] BIBLIOTHÈQUES UTILES

pytest-asyncio  : Tests async
aioresponses    : Mock aiohttp
respx           : Mock httpx
aiohttp         : HTTP client/server async
httpx           : HTTP client moderne
asyncpg         : PostgreSQL async
aiosqlite       : SQLite async
motor           : MongoDB async


-> PROCHAINE ÉTAPE : Chapitre 12 - Plugins et Extensions

Vous allez apprendre :
- Créer plugins pytest personnalisés
- Hooks pytest
- Extensions populaires
- Configuration avancée
- Intégration CI/CD
- Rapports et métriques

Le dernier chapitre de la Partie 3 ! [RAPIDE]
"""

Excellent ! Le Chapitre 11 sur les Tests Asynchrones est terminé avec le framework COMMENT/POURQUOI/QUAND ultra-détaillé.

Voulez-vous que je continue avec le **Chapitre 12 : Plugins et Extensions** pour terminer la Partie 3 ? Ce sera le dernier chapitre couvrant les plugins pytest, hooks, et configuration avancée ! [OBJECTIF]

Je continue avec le Chapitre 12 sur les Plugins et Extensions - le dernier chapitre de la Partie 3, ultra-détaillé avec COMMENT/POURQUOI/QUAND !

# ============================================================================
# [GUIDE] CHAPITRE 12 : PLUGINS ET EXTENSIONS PYTEST
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Utiliser plugins pytest populaires
[OK] Créer plugins pytest personnalisés
[OK] Comprendre système de hooks pytest
[OK] Configurer pytest avancé
[OK] Générer rapports professionnels
[OK] Intégrer pytest dans CI/CD
[OK] Optimiser suite de tests
[OK] Extensions avancées
"""


# ----------------------------------------------------------------------------
# [MODULE] ÉCOSYSTÈME PLUGINS PYTEST
# ----------------------------------------------------------------------------

"""
CONCEPT : ARCHITECTURE EXTENSIBLE

COMMENT ? pytest = système de plugins

POURQUOI ? Extensibilité sans modifier core

QUAND ? Besoins spécifiques au projet


[IDEE] DÉFINITION

Plugin pytest = Code qui étend fonctionnalités pytest
- Fixtures additionnelles
- Markers personnalisés
- Hooks pour modifier comportement
- Rapports personnalisés
- Intégrations


TYPES DE PLUGINS

1. PLUGINS INSTALLABLES (PyPI)
   - pytest-cov, pytest-django, etc.
   - Installation : pip install
   
2. PLUGINS LOCAUX (conftest.py)
   - Spécifiques au projet
   - Fixtures, hooks locaux
   
3. PLUGINS INLINE (fichier test)
   - Très spécifiques
   - Rarement utilisés


DÉCOUVERTE AUTOMATIQUE

pytest découvre plugins :
1. Installés via pip
2. Dans conftest.py
3. Via -p option CLI
"""


# ----------------------------------------------------------------------------
# [PACKAGE] PLUGINS ESSENTIELS
# ----------------------------------------------------------------------------

"""
CATÉGORIES DE PLUGINS POPULAIRES

1. Code Coverage
2. Mocking
3. Frameworks (Django, Flask)
4. Bases de données
5. Parallélisation
6. Rapports
7. Helpers


# ══════════════════════════════════════════════════════════════
# 1. CODE COVERAGE : pytest-cov
# ══════════════════════════════════════════════════════════════

COMMENT ? Mesurer couverture de code

POURQUOI ? Identifier code non testé

QUAND ? Tous les projets
"""

pip install pytest-cov

"""
UTILISATION BASIQUE
"""

# Exécuter avec coverage
pytest --cov=myapp tests/

"""
Output :
---------- coverage: platform linux, python 3.11 -----------
Name                 Stmts   Miss  Cover
----------------------------------------
myapp/__init__.py        2      0   100%
myapp/users.py          45      5    89%
myapp/api.py            67     12    82%
----------------------------------------
TOTAL                  114     17    85%


COMMENT ? Options coverage

--cov=<path>              : Mesurer coverage de <path>
--cov-report=term         : Rapport terminal (défaut)
--cov-report=html         : Rapport HTML
--cov-report=xml          : Rapport XML (pour CI)
--cov-report=term-missing : Afficher lignes manquantes
--cov-fail-under=<min>    : Échouer si coverage < min


EXEMPLES PRATIQUES
"""

# Coverage avec lignes manquantes
pytest --cov=myapp --cov-report=term-missing tests/

"""
Output :
Name                 Stmts   Miss  Cover   Missing
--------------------------------------------------
myapp/users.py          45      5    89%   23-27
"""

# Rapport HTML (navigable dans navigateur)
pytest --cov=myapp --cov-report=html tests/

"""
Crée htmlcov/index.html
-> Ouvrir dans navigateur pour rapport interactif


COMMENT ? Configuration dans pytest.ini
"""

# pytest.ini
"""
[pytest]
addopts = 
    --cov=myapp
    --cov-report=term-missing
    --cov-report=html
    --cov-fail-under=80
"""

"""
[IDEE] POURQUOI --cov-fail-under ?

Garantit minimum de coverage
CI échoue si < 80%
Force équipe à maintenir qualité


# ══════════════════════════════════════════════════════════════
# 2. MOCKING : pytest-mock
# ══════════════════════════════════════════════════════════════

COMMENT ? Simplifier mocking

POURQUOI ? Intégration pytest + unittest.mock

QUAND ? Déjà vu au Chapitre 10 !
"""

pip install pytest-mock

"""
RAPPEL RAPIDE
"""

def test_with_mocker(mocker):
    """
    COMMENT ? mocker fixture
    POURQUOI ? Syntaxe plus simple
    """
    mock_func = mocker.patch('module.function')
    mock_func.return_value = 42
    
    # Test...

"""
# ══════════════════════════════════════════════════════════════
# 3. FRAMEWORKS WEB
# ══════════════════════════════════════════════════════════════

DJANGO : pytest-django
"""

pip install pytest-django

"""
COMMENT ? Configuration pytest-django
"""

# pytest.ini
"""
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings
python_files = tests.py test_*.py *_tests.py
"""

"""
FIXTURES DJANGO
"""

import pytest

@pytest.mark.django_db
def test_user_creation(client):
    """
    COMMENT ? @pytest.mark.django_db pour accès DB
    POURQUOI ? Active transactions de test
    QUAND ? Tests avec base de données Django
    
    Args:
        client: Fixture Django test client
    """
    response = client.post('/users/', {
        'username': 'alice',
        'email': 'alice@example.com'
    })
    
    assert response.status_code == 201

@pytest.mark.django_db
def test_user_model(django_user_model):
    """
    COMMENT ? django_user_model fixture
    POURQUOI ? Accès au modèle User
    """
    User = django_user_model
    user = User.objects.create(username='bob')
    
    assert user.username == 'bob'

"""
[IDEE] FIXTURES DJANGO UTILES

client          : Django test client (HTTP requests)
admin_client    : Client authentifié comme admin
django_user_model : Modèle User
db              : Accès DB (comme @pytest.mark.django_db)
transactional_db : DB avec transactions réelles
settings        : Modifier settings Django


FLASK : pytest-flask
"""

pip install pytest-flask

"""
COMMENT ? Configuration pytest-flask
"""

# conftest.py
import pytest
from myapp import create_app

@pytest.fixture
def app():
    """
    COMMENT ? Fixture Flask app
    POURQUOI ? Créer app pour tests
    """
    app = create_app({'TESTING': True})
    return app

@pytest.fixture
def client(app):
    """
    COMMENT ? Test client Flask
    POURQUOI ? Faire requêtes HTTP
    """
    return app.test_client()

def test_home_page(client):
    """
    COMMENT ? Tester route Flask
    """
    response = client.get('/')
    assert response.status_code == 200

"""
# ══════════════════════════════════════════════════════════════
# 4. BASES DE DONNÉES
# ══════════════════════════════════════════════════════════════

pytest-postgresql
"""

pip install pytest-postgresql

"""
COMMENT ? PostgreSQL pour tests
"""

import pytest

postgresql_my_proc = factories.postgresql_proc(port=None)
postgresql_my = factories.postgresql('postgresql_my_proc')

def test_with_postgresql(postgresql):
    """
    COMMENT ? Base PostgreSQL temporaire
    POURQUOI ? Tests d'intégration DB réelle
    """
    cursor = postgresql.cursor()
    cursor.execute('CREATE TABLE users (id serial, name text)')
    cursor.execute('INSERT INTO users (name) VALUES (%s)', ('Alice',))
    postgresql.commit()
    
    cursor.execute('SELECT * FROM users')
    result = cursor.fetchone()
    assert result[1] == 'Alice'

"""
pytest-mongodb
"""

pip install pytest-mongodb

"""
COMMENT ? MongoDB pour tests
"""

def test_with_mongodb(mongodb):
    """
    COMMENT ? MongoDB temporaire
    """
    collection = mongodb['testdb']['users']
    collection.insert_one({'name': 'Alice', 'age': 30})
    
    user = collection.find_one({'name': 'Alice'})
    assert user['age'] == 30

"""
# ══════════════════════════════════════════════════════════════
# 5. PARALLÉLISATION : pytest-xdist
# ══════════════════════════════════════════════════════════════

COMMENT ? Exécuter tests en parallèle

POURQUOI ? Accélérer suite de tests

QUAND ? Beaucoup de tests (>100)
"""

pip install pytest-xdist

"""
UTILISATION
"""

# Auto (détecte nombre de CPUs)
pytest -n auto

# Nombre spécifique de workers
pytest -n 4

# Avec distribution optimale
pytest -n auto --dist loadscope

"""
Output :
gw0 [100] / gw1 [100] / gw2 [100] / gw3 [100]

100 tests exécutés sur 4 workers


[IDEE] OPTIONS DISTRIBUTION

--dist loadscope : Grouper par scope (classe, module)
--dist loadfile  : Grouper par fichier
--dist load      : Distribution équitable (défaut)


PROBLÈME : Tests non-isolés

Tests doivent être INDÉPENDANTS :
- Pas de dépendances entre tests
- Pas d'état partagé global
- Fixtures bien isolées


EXEMPLE : Test adapté pour xdist
"""

import pytest

@pytest.fixture
def unique_temp_file(tmp_path):
    """
    COMMENT ? Fichier unique par test
    POURQUOI ? Éviter conflits en parallèle
    """
    # tmp_path est unique par test
    file = tmp_path / "data.txt"
    file.write_text("test data")
    return file

def test_file_operation(unique_temp_file):
    """
    COMMENT ? Test isolé
    POURQUOI ? Fonctionne en parallèle
    """
    content = unique_temp_file.read_text()
    assert content == "test data"

"""
# ══════════════════════════════════════════════════════════════
# 6. RAPPORTS : pytest-html
# ══════════════════════════════════════════════════════════════

COMMENT ? Rapports HTML professionnels

POURQUOI ? Partager résultats avec équipe

QUAND ? CI/CD, revues de code
"""

pip install pytest-html

"""
UTILISATION
"""

pytest --html=report.html --self-contained-html

"""
Crée report.html avec :
- Résumé des tests
- Détails par test
- Logs
- Screenshots (si configuré)


COMMENT ? Customiser rapport
"""

# conftest.py
import pytest
from datetime import datetime

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """
    COMMENT ? Hook pour enrichir rapport
    POURQUOI ? Ajouter infos personnalisées
    """
    outcome = yield
    report = outcome.get_result()
    
    # Ajouter extra info
    if report.when == 'call':
        report.extra = getattr(report, 'extra', [])
        
        # Ajouter timestamp
        report.extra.append({
            'name': 'Timestamp',
            'value': datetime.now().isoformat()
        })

"""
pytest-json-report : Rapports JSON
"""

pip install pytest-json-report

"""
COMMENT ? Générer rapport JSON
"""

pytest --json-report --json-report-file=report.json

"""
Utile pour :
- Parsing automatique
- Intégrations custom
- Dashboards


# ══════════════════════════════════════════════════════════════
# 7. HELPERS : PLUGINS UTILES
# ══════════════════════════════════════════════════════════════

pytest-timeout : Timeout automatique
"""

pip install pytest-timeout

"""
COMMENT ? Timeout sur tests
"""

@pytest.mark.timeout(5)
def test_with_timeout():
    """
    COMMENT ? Test doit finir en 5 secondes
    POURQUOI ? Éviter tests bloqués
    QUAND ? Tests réseau, DB, etc.
    """
    # Si > 5s -> TimeoutError
    import time
    time.sleep(3)  # OK
    assert True

# Configuration globale
# pytest.ini
"""
[pytest]
timeout = 10
"""

"""
pytest-randomly : Ordre aléatoire
"""

pip install pytest-randomly

"""
COMMENT ? Randomiser ordre des tests

POURQUOI ? Détecter dépendances cachées

QUAND ? Tests devraient être indépendants
"""

# Automatique après installation
pytest

# Avec seed fixe (reproductible)
pytest --randomly-seed=12345

"""
pytest-sugar : Output amélioré
"""

pip install pytest-sugar

"""
COMMENT ? Barre de progression + emojis

POURQUOI ? Output plus lisible

Installation = activation automatique


Output :
 tests/test_users.py [OK][OK][OK][OK][OK][OK]                                    42% ████▎     
 tests/test_api.py [OK][OK][OK][OK][OK]                                       78% ███████▊  
 tests/test_db.py [OK][OK][OK]                                         100% ██████████

Results (2.34s):
      15 passed
"""

"""
pytest-bdd : Behavior-Driven Development
"""

pip install pytest-bdd

"""
COMMENT ? Tests en langage naturel (Gherkin)

POURQUOI ? Collaboration non-techniques

QUAND ? BDD, Agile
"""

# features/login.feature
"""
Feature: User Login
  
  Scenario: Successful login
    Given a user with username "alice" and password "secret123"
    When the user attempts to login
    Then the user should be logged in
    And the session should be active
"""

# tests/test_login.py
from pytest_bdd import scenario, given, when, then

@scenario('features/login.feature', 'Successful login')
def test_successful_login():
    pass

@given('a user with username "alice" and password "secret123"')
def user(db):
    return User.objects.create(username='alice', password='secret123')

@when('the user attempts to login')
def login(user, client):
    return client.post('/login', {
        'username': user.username,
        'password': 'secret123'
    })

@then('the user should be logged in')
def check_logged_in(login):
    assert login.status_code == 200


# ----------------------------------------------------------------------------
# [OUTIL] CRÉER PLUGIN PERSONNALISÉ
# ----------------------------------------------------------------------------

"""
POURQUOI CRÉER UN PLUGIN ?

[OK] Fixtures réutilisables entre projets
[OK] Hooks personnalisés
[OK] Markers spécifiques
[OK] Rapports custom
[OK] Intégrations internes


STRUCTURE PLUGIN

my_plugin/
├── setup.py
├── pytest_myplugin.py    # Code du plugin
└── tests/
    └── test_plugin.py


RÈGLE : Nom doit commencer par pytest_


# ══════════════════════════════════════════════════════════════
# EXEMPLE 1 : PLUGIN SIMPLE (FIXTURES)
# ══════════════════════════════════════════════════════════════

COMMENT ? Plugin avec fixtures custom
"""

# pytest_company.py
"""
Plugin pytest pour Company Inc.
Fournit fixtures communes à tous les projets
"""

import pytest
import os
from datetime import datetime

@pytest.fixture
def company_config():
    """
    COMMENT ? Configuration entreprise
    POURQUOI ? Réutilisable entre projets
    QUAND ? Tous les tests internes
    """
    return {
        'company_name': 'Company Inc.',
        'api_base_url': os.getenv('COMPANY_API_URL', 'https://api.company.com'),
        'environment': os.getenv('ENV', 'test'),
        'timeout': 30
    }

@pytest.fixture
def company_api_client(company_config):
    """
    COMMENT ? Client API entreprise
    POURQUOI ? Standardiser accès API
    """
    import httpx
    
    client = httpx.Client(
        base_url=company_config['api_base_url'],
        timeout=company_config['timeout'],
        headers={'X-Company-Client': 'pytest'}
    )
    
    yield client
    
    client.close()

@pytest.fixture
def test_timestamp():
    """
    COMMENT ? Timestamp du test
    POURQUOI ? Traçabilité
    """
    return datetime.now()

# Hook pour ajouter info à tous les tests
def pytest_configure(config):
    """
    COMMENT ? Hook de configuration
    POURQUOI ? Setup au démarrage pytest
    """
    config.addinivalue_line(
        "markers",
        "company: mark test as Company Inc. specific"
    )

def pytest_collection_modifyitems(config, items):
    """
    COMMENT ? Modifier collection de tests
    POURQUOI ? Ajouter markers automatiquement
    """
    for item in items:
        # Ajouter marker 'company' à tous les tests
        item.add_marker(pytest.mark.company)

"""
INSTALLATION DU PLUGIN
"""

# setup.py
from setuptools import setup

setup(
    name='pytest-company',
    version='1.0.0',
    description='Company Inc. pytest plugin',
    py_modules=['pytest_company'],
    install_requires=['pytest>=7.0'],
    entry_points={
        'pytest11': [
            'company = pytest_company',
        ]
    }
)

"""
COMMENT ? Installer
"""

# En mode développement
pip install -e .

# Ou publier sur PyPI
pip install pytest-company

"""
UTILISATION
"""

# tests/test_api.py
def test_api_call(company_api_client):
    """
    COMMENT ? Utiliser fixture du plugin
    POURQUOI ? Automatiquement disponible
    """
    response = company_api_client.get('/users/1')
    assert response.status_code == 200

def test_config(company_config):
    """
    COMMENT ? Accéder config
    """
    assert company_config['company_name'] == 'Company Inc.'

"""
# ══════════════════════════════════════════════════════════════
# EXEMPLE 2 : PLUGIN AVEC HOOKS AVANCÉS
# ══════════════════════════════════════════════════════════════

COMMENT ? Plugin de monitoring
"""

# pytest_monitor.py
"""
Plugin de monitoring pytest
Enregistre durée et résultats dans DB
"""

import pytest
import time
from datetime import datetime
import json

class TestMonitor:
    """
    COMMENT ? Classe pour stocker résultats
    POURQUOI ? Analyser performances dans le temps
    """
    
    def __init__(self):
        self.results = []
    
    def record_test(self, nodeid, duration, outcome):
        """Enregistre résultat d'un test"""
        self.results.append({
            'test': nodeid,
            'duration': duration,
            'outcome': outcome,
            'timestamp': datetime.now().isoformat()
        })
    
    def save_results(self, filepath='test_results.json'):
        """Sauvegarde résultats en JSON"""
        with open(filepath, 'w') as f:
            json.dump(self.results, f, indent=2)

@pytest.fixture(scope='session')
def test_monitor():
    """
    COMMENT ? Monitor session
    POURQUOI ? Collecter stats globales
    """
    monitor = TestMonitor()
    yield monitor
    monitor.save_results()

# Hook : Début de session
def pytest_sessionstart(session):
    """
    COMMENT ? Au début de la session
    POURQUOI ? Initialisation
    """
    print("\n[RAPIDE] Starting test monitoring...")
    session.monitor_start_time = time.time()

# Hook : Fin de session
def pytest_sessionfinish(session, exitstatus):
    """
    COMMENT ? À la fin de la session
    POURQUOI ? Rapport final
    """
    duration = time.time() - session.monitor_start_time
    print(f"\n[OK] Test session completed in {duration:.2f}s")
    print(f"Exit status: {exitstatus}")

# Hook : Résultat de chaque test
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """
    COMMENT ? Après exécution de chaque test
    POURQUOI ? Enregistrer résultat
    """
    outcome = yield
    report = outcome.get_result()
    
    if report.when == 'call':
        # Enregistrer dans monitor si disponible
        monitor = item.config._test_monitor
        if monitor:
            monitor.record_test(
                nodeid=item.nodeid,
                duration=report.duration,
                outcome=report.outcome
            )

# Hook : Configuration
def pytest_configure(config):
    """
    COMMENT ? Configuration du plugin
    POURQUOI ? Setup initial
    """
    config._test_monitor = TestMonitor()
    
    # Ajouter marker
    config.addinivalue_line(
        "markers",
        "slow: mark test as slow (duration > 1s)"
    )

# Hook : Collection terminée
def pytest_collection_modifyitems(config, items):
    """
    COMMENT ? Modifier items collectés
    POURQUOI ? Ajouter markers automatiques
    """
    for item in items:
        # Marquer tests lents automatiquement
        # (nécessite exécution préalable pour connaître durée)
        pass

"""
UTILISATION
"""

pytest --tb=short

"""
Output :
[RAPIDE] Starting test monitoring...

tests/test_api.py::test_get_user PASSED
tests/test_api.py::test_create_user PASSED
tests/test_db.py::test_query PASSED

[OK] Test session completed in 2.34s
Exit status: 0

Fichier test_results.json créé :
[
  {
    "test": "tests/test_api.py::test_get_user",
    "duration": 0.15,
    "outcome": "passed",
    "timestamp": "2024-01-15T10:30:00"
  },
  ...
]
"""

"""
# ══════════════════════════════════════════════════════════════
# EXEMPLE 3 : PLUGIN DE VALIDATION
# ══════════════════════════════════════════════════════════════

COMMENT ? Plugin pour règles de qualité
"""

# pytest_quality.py
"""
Plugin de qualité de code pour tests
Applique règles strictes
"""

import pytest
import inspect

def pytest_configure(config):
    """Enregistrer markers"""
    config.addinivalue_line(
        "markers",
        "requires_docstring: test must have docstring"
    )

def pytest_collection_modifyitems(config, items):
    """
    COMMENT ? Valider qualité des tests
    POURQUOI ? Enforcer standards
    """
    for item in items:
        # Vérifier docstring
        if not item.obj.__doc__:
            item.add_marker(
                pytest.mark.skip(reason="Test must have docstring")
            )
        
        # Vérifier longueur nom de fonction
        if len(item.name) < 10:
            item.add_marker(
                pytest.mark.skip(reason="Test name too short (min 10 chars)")
            )
        
        # Vérifier préfixe 'test_'
        if not item.name.startswith('test_'):
            item.add_marker(
                pytest.mark.skip(reason="Test must start with 'test_'")
            )

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_protocol(item, nextitem):
    """
    COMMENT ? Avant exécution test
    POURQUOI ? Validations pré-exécution
    """
    # Vérifier que le test utilise au moins une assertion
    source = inspect.getsource(item.obj)
    if 'assert' not in source:
        pytest.skip("Test must contain at least one assertion")
    
    yield

"""
[X] TEST INVALIDE (sera skippé)
"""

def test_bad():  # [X] Nom trop court
    result = 1 + 1

"""
[X] TEST INVALIDE (sera skippé)
"""

def test_without_docstring_example():  # [X] Pas de docstring
    assert True

"""
[OK] TEST VALIDE
"""

def test_proper_test_with_good_name():
    """
    COMMENT ? Test conforme aux standards
    POURQUOI ? Docstring + nom long + assertion
    """
    assert 1 + 1 == 2


# ----------------------------------------------------------------------------
# [HOOK] SYSTÈME DE HOOKS PYTEST
# ----------------------------------------------------------------------------

"""
CONCEPT : HOOKS = POINTS D'EXTENSION

COMMENT ? Fonctions appelées par pytest à moments clés

POURQUOI ? Modifier comportement pytest

QUAND ? Plugins, customisations avancées


CATÉGORIES DE HOOKS

1. Bootstrapping (setup/teardown)
2. Collection (découverte tests)
3. Exécution (running tests)
4. Rapports (reporting)
5. Debugging


# ══════════════════════════════════════════════════════════════
# HOOKS DE BOOTSTRAPPING
# ══════════════════════════════════════════════════════════════

pytest_configure(config)
    QUAND : Au démarrage pytest
    POURQUOI : Setup initial

pytest_unconfigure(config)
    QUAND : À la fin pytest
    POURQUOI : Cleanup

pytest_sessionstart(session)
    QUAND : Début session de tests
    POURQUOI : Setup de session

pytest_sessionfinish(session, exitstatus)
    QUAND : Fin session
    POURQUOI : Cleanup session


EXEMPLE : Hook de configuration
"""

# conftest.py
import pytest

def pytest_configure(config):
    """
    COMMENT ? Hook de configuration
    POURQUOI ? Setup au démarrage
    QUAND : Initialisation globale
    """
    # Enregistrer markers personnalisés
    config.addinivalue_line(
        "markers",
        "integration: mark test as integration test"
    )
    config.addinivalue_line(
        "markers",
        "unit: mark test as unit test"
    )
    
    # Initialiser ressource globale
    config.shared_resource = initialize_resource()

def pytest_unconfigure(config):
    """
    COMMENT ? Hook de dé-configuration
    POURQUOI ? Cleanup global
    """
    if hasattr(config, 'shared_resource'):
        cleanup_resource(config.shared_resource)

"""
# ══════════════════════════════════════════════════════════════
# HOOKS DE COLLECTION
# ══════════════════════════════════════════════════════════════

pytest_collection_modifyitems(config, items)
    QUAND : Après collection des tests
    POURQUOI : Modifier/filtrer tests

pytest_ignore_collect(path, config)
    QUAND : Pendant découverte fichiers
    POURQUOI : Ignorer certains fichiers

pytest_collect_file(path, parent)
    QUAND : Pour chaque fichier découvert
    POURQUOI : Custom collection


EXEMPLE : Modifier items collectés
"""

def pytest_collection_modifyitems(config, items):
    """
    COMMENT ? Modifier tests après collection
    POURQUOI ? Ajouter markers, réordonner, filtrer
    """
    # Séparer tests lents et rapides
    slow_tests = []
    fast_tests = []
    
    for item in items:
        if "slow" in item.keywords:
            slow_tests.append(item)
        else:
            fast_tests.append(item)
    
    # Exécuter rapides d'abord
    items[:] = fast_tests + slow_tests
    
    # Ajouter marker automatique aux tests DB
    for item in items:
        if "db" in item.nodeid.lower():
            item.add_marker(pytest.mark.database)

"""
# ══════════════════════════════════════════════════════════════
# HOOKS D'EXÉCUTION
# ══════════════════════════════════════════════════════════════

pytest_runtest_setup(item)
    QUAND : Avant setup du test
    POURQUOI : Pre-setup logic

pytest_runtest_call(item)
    QUAND : Exécution du test
    POURQUOI : Custom test execution

pytest_runtest_teardown(item, nextitem)
    QUAND : Après teardown
    POURQUOI : Post-teardown logic


EXEMPLE : Hook d'exécution
"""

import time

def pytest_runtest_setup(item):
    """
    COMMENT ? Avant setup de chaque test
    POURQUOI ? Préparer environnement
    """
    # Logger début du test
    print(f"\n[BLACK_RIGHT-POINTING_TRIANGLE]  Starting: {item.nodeid}")
    item._start_time = time.time()

def pytest_runtest_teardown(item, nextitem):
    """
    COMMENT ? Après teardown
    POURQUOI ? Cleanup + stats
    """
    duration = time.time() - item._start_time
    print(f"[OK] Completed: {item.nodeid} ({duration:.2f}s)")

"""
# ══════════════════════════════════════════════════════════════
# HOOKS DE RAPPORTS
# ══════════════════════════════════════════════════════════════

pytest_runtest_makereport(item, call)
    QUAND : Après chaque phase de test
    POURQUOI : Créer/modifier rapport

pytest_report_teststatus(report, config)
    QUAND : Pour déterminer status
    POURQUOI : Custom status display

pytest_terminal_summary(terminalreporter, exitstatus, config)
    QUAND : Fin de session
    POURQUOI : Ajouter au résumé terminal


EXEMPLE : Rapport personnalisé
"""

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """
    COMMENT ? Hook wrapper pour rapport
    POURQUOI ? Enrichir rapport de test
    QUAND ? Ajouter métadonnées custom
    """
    outcome = yield
    report = outcome.get_result()
    
    # Ajouter infos custom
    if report.when == 'call':
        # Ajouter durée formatée
        report.user_properties.append(
            ('duration_formatted', f"{report.duration:.3f}s")
        )
        
        # Ajouter metadata du test
        if hasattr(item, 'obj'):
            report.user_properties.append(
                ('docstring', item.obj.__doc__ or 'No description')
            )

def pytest_terminal_summary(terminalreporter, exitstatus, config):
    """
    COMMENT ? Ajouter au résumé terminal
    POURQUOI ? Statistiques custom
    QUAND ? Fin de tous les tests
    """
    # Compter tests par marker
    passed = len(terminalreporter.stats.get('passed', []))
    failed = len(terminalreporter.stats.get('failed', []))
    skipped = len(terminalreporter.stats.get('skipped', []))
    
    # Afficher résumé custom
    terminalreporter.write_sep("=", "CUSTOM SUMMARY")
    terminalreporter.write_line(f"[OK] Passed: {passed}")
    terminalreporter.write_line(f"[X] Failed: {failed}")
    terminalreporter.write_line(f"[BLACK_RIGHT-POINTING_DOUBLE_TRIANGLE_WITH_VERTICAL_BAR]  Skipped: {skipped}")
    
    # Total duration
    if hasattr(terminalreporter, '_sessionstarttime'):
        duration = time.time() - terminalreporter._sessionstarttime
        terminalreporter.write_line(f"[TEMPS]  Total duration: {duration:.2f}s")


# ----------------------------------------------------------------------------
# [GRAPHIQUE] RAPPORTS AVANCÉS
# ----------------------------------------------------------------------------

"""
GÉNÉRER RAPPORTS PROFESSIONNELS

COMMENT ? Combiner plusieurs formats

POURQUOI ? Différents audiences

QUAND ? CI/CD, documentation, équipe


# ══════════════════════════════════════════════════════════════
# RAPPORT MULTI-FORMAT
# ══════════════════════════════════════════════════════════════
"""

pytest \
    --cov=myapp \
    --cov-report=html \
    --cov-report=xml \
    --html=report.html \
    --json-report \
    --json-report-file=report.json \
    -v

"""
Génère :
- htmlcov/index.html  : Coverage HTML
- coverage.xml        : Coverage XML (SonarQube, etc.)
- report.html         : Rapport tests HTML
- report.json         : Rapport tests JSON


# ══════════════════════════════════════════════════════════════
# ALLURE REPORTS : RAPPORTS PROFESSIONNELS
# ══════════════════════════════════════════════════════════════

COMMENT ? Framework de reporting avancé

POURQUOI ? Rapports très riches et interactifs

QUAND ? Projets d'entreprise
"""

pip install allure-pytest

"""
UTILISATION
"""

# Générer données Allure
pytest --alluredir=allure-results

# Générer rapport HTML
allure serve allure-results

"""
ANNOTATIONS ALLURE
"""

import allure

@allure.feature('User Management')
@allure.story('User Registration')
@allure.severity(allure.severity_level.CRITICAL)
def test_user_registration():
    """
    COMMENT ? Annotations Allure
    POURQUOI ? Organiser tests dans rapport
    """
    with allure.step('Enter user details'):
        user = {'name': 'Alice', 'email': 'alice@test.com'}
    
    with allure.step('Submit registration'):
        result = register_user(user)
    
    with allure.step('Verify user created'):
        assert result.status == 'success'
    
    # Attacher données au rapport
    allure.attach(
        json.dumps(user),
        name='User Data',
        attachment_type=allure.attachment_type.JSON
    )

"""
FEATURES ALLURE

- Catégorisation (features, stories)
- Severities (blocker, critical, normal, minor, trivial)
- Steps détaillés
- Attachments (screenshots, logs, JSON)
- Historique des runs
- Graphiques tendances
- Flaky tests tracking


# ══════════════════════════════════════════════════════════════
# RAPPORT CUSTOM AVEC HOOK
# ══════════════════════════════════════════════════════════════
"""

# conftest.py
import pytest
import json
from pathlib import Path

class CustomReporter:
    """
    COMMENT ? Reporter personnalisé
    POURQUOI ? Format spécifique entreprise
    """
    
    def __init__(self):
        self.tests = []
        self.stats = {
            'passed': 0,
            'failed': 0,
            'skipped': 0,
            'errors': 0
        }
    
    def record_test(self, nodeid, outcome, duration):
        """Enregistre résultat test"""
        self.tests.append({
            'id': nodeid,
            'outcome': outcome,
            'duration': duration
        })
        
        if outcome == 'passed':
            self.stats['passed'] += 1
        elif outcome == 'failed':
            self.stats['failed'] += 1
        elif outcome == 'skipped':
            self.stats['skipped'] += 1
    
    def generate_report(self, filepath='custom_report.json'):
        """Génère rapport JSON"""
        report = {
            'summary': self.stats,
            'tests': self.tests,
            'total': len(self.tests)
        }
        
        Path(filepath).write_text(json.dumps(report, indent=2))

@pytest.fixture(scope='session')
def custom_reporter():
    """Reporter pour session"""
    reporter = CustomReporter()
    yield reporter
    reporter.generate_report()

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """Enregistrer dans reporter custom"""
    outcome = yield
    report = outcome.get_result()
    
    if report.when == 'call':
        reporter = item.config._custom_reporter
        if reporter:
            reporter.record_test(
                item.nodeid,
                report.outcome,
                report.duration
            )

def pytest_configure(config):
    """Initialiser reporter"""
    config._custom_reporter = CustomReporter()

def pytest_unconfigure(config):
    """Générer rapport final"""
    if hasattr(config, '_custom_reporter'):
        config._custom_reporter.generate_report()


# ----------------------------------------------------------------------------
# [RAPIDE] INTÉGRATION CI/CD
# ----------------------------------------------------------------------------

"""
PYTEST DANS PIPELINES

COMMENT ? Configurer pytest pour CI/CD

POURQUOI ? Automatiser tests

QUAND ? Chaque commit, PR, déploiement


# ══════════════════════════════════════════════════════════════
# GITHUB ACTIONS
# ══════════════════════════════════════════════════════════════
"""

# .github/workflows/tests.yml
"""
name: Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        python-version: ['3.9', '3.10', '3.11', '3.12']
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt
    
    - name: Run tests with pytest
      run: |
        pytest \
          --cov=myapp \
          --cov-report=xml \
          --cov-report=term-missing \
          --cov-fail-under=80 \
          --html=report.html \
          --self-contained-html \
          -v
    
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml
    
    - name: Upload test report
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: test-report-${{ matrix.python-version }}
        path: report.html
"""

"""
# ══════════════════════════════════════════════════════════════
# GITLAB CI
# ══════════════════════════════════════════════════════════════
"""

# .gitlab-ci.yml
"""
image: python:3.11

stages:
  - test
  - report

before_script:
  - pip install -r requirements.txt
  - pip install -r requirements-dev.txt

test:
  stage: test
  script:
    - pytest \
        --cov=myapp \
        --cov-report=xml \
        --cov-report=term \
        --junit-xml=report.xml \
        -v
  coverage: '/TOTAL.*\s+(\d+%)$/'
  artifacts:
    reports:
      junit: report.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

pages:
  stage: report
  script:
    - pytest --cov=myapp --cov-report=html:public
  artifacts:
    paths:
      - public
  only:
    - main
"""

"""
# ══════════════════════════════════════════════════════════════
# JENKINS
# ══════════════════════════════════════════════════════════════
"""

# Jenkinsfile
"""
pipeline {
    agent any
    
    stages {
        stage('Setup') {
            steps {
                sh 'python -m venv venv'
                sh '. venv/bin/activate && pip install -r requirements.txt'
            }
        }
        
        stage('Test') {
            steps {
                sh '''
                    . venv/bin/activate
                    pytest \
                        --cov=myapp \
                        --cov-report=xml \
                        --cov-report=html \
                        --junit-xml=junit.xml \
                        -v
                '''
            }
        }
        
        stage('Report') {
            steps {
                junit 'junit.xml'
                publishHTML([
                    reportDir: 'htmlcov',
                    reportFiles: 'index.html',
                    reportName: 'Coverage Report'
                ])
            }
        }
    }
    
    post {
        always {
            cleanWs()
        }
    }
}
"""

"""
# ══════════════════════════════════════════════════════════════
# CONFIGURATION PYTEST POUR CI
# ══════════════════════════════════════════════════════════════
"""

# pytest.ini
"""
[pytest]
minversion = 7.0
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

# Markers
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    integration: marks tests as integration tests
    unit: marks tests as unit tests
    smoke: marks tests as smoke tests

# Options par défaut
addopts =
    --strict-markers
    --tb=short
    --disable-warnings
    -ra
    --cov=myapp
    --cov-report=term-missing
    --cov-fail-under=80

# Timeouts
timeout = 300
timeout_method = thread

# Logging
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(message)s
log_cli_date_format = %Y-%m-%d %H:%M:%S

# Coverage
[coverage:run]
source = myapp
omit = 
    */tests/*
    */migrations/*
    */__pycache__/*

[coverage:report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod
"""

"""
# ══════════════════════════════════════════════════════════════
# SCRIPTS CI HELPERS
# ══════════════════════════════════════════════════════════════
"""

# scripts/run_tests_ci.sh
"""
#!/bin/bash
set -e

echo "[TEST] Running test suite..."

# Activer environnement virtuel si existe
if [ -d "venv" ]; then
    source venv/bin/activate
fi

# Exécuter tests avec options CI
pytest \
    --cov=myapp \
    --cov-report=xml \
    --cov-report=html \
    --cov-report=term-missing \
    --cov-fail-under=80 \
    --junit-xml=junit.xml \
    --html=report.html \
    --self-contained-html \
    -n auto \
    -v \
    "$@"

echo "[OK] Tests completed successfully!"
"""

"""
UTILISATION
"""

chmod +x scripts/run_tests_ci.sh
./scripts/run_tests_ci.sh


# ----------------------------------------------------------------------------
# [RAPIDE] OPTIMISATION SUITE DE TESTS
# ----------------------------------------------------------------------------

"""
STRATÉGIES D'OPTIMISATION

COMMENT ? Accélérer exécution tests

POURQUOI ? Feedback plus rapide

QUAND ? Suite de tests lente (>5 min)


# ══════════════════════════════════════════════════════════════
# 1. PARALLÉLISATION
# ══════════════════════════════════════════════════════════════
"""

# Utiliser tous les CPUs
pytest -n auto

# Optimal pour CI (4-8 workers généralement)
pytest -n 4

"""
# ══════════════════════════════════════════════════════════════
# 2. SÉLECTION INTELLIGENTE
# ══════════════════════════════════════════════════════════════

COMMENT ? Exécuter seulement tests nécessaires

POURQUOI ? Éviter tests non pertinents

QUAND ? Développement, pre-commit
"""

# Tests qui ont échoué la dernière fois
pytest --lf

# Tests qui ont échoué + nouveaux
pytest --ff

# Tests modifiés (avec plugin pytest-picked)
pip install pytest-picked
pytest --picked

# Tests pour fichiers modifiés (Git)
pytest --picked=first

"""
# ══════════════════════════════════════════════════════════════
# 3. SCOPES DE FIXTURES OPTIMAUX
# ══════════════════════════════════════════════════════════════

COMMENT ? Utiliser scopes appropriés

POURQUOI ? Éviter setup répété
"""

# [X] LENT : Scope function (défaut)
@pytest.fixture
def database():
    """Créée pour CHAQUE test"""
    db = create_database()
    yield db
    db.teardown()

# [OK] RAPIDE : Scope module
@pytest.fixture(scope="module")
def database():
    """Créée UNE FOIS par module"""
    db = create_database()
    yield db
    db.teardown()

# [OK] TRÈS RAPIDE : Scope session
@pytest.fixture(scope="session")
def database():
    """Créée UNE FOIS pour toute la session"""
    db = create_database()
    yield db
    db.teardown()

"""
[IDEE] RÈGLE D'OR

Plus le scope est large, plus c'est rapide
MAIS moins c'est isolé

Choisir selon besoin d'isolation


# ══════════════════════════════════════════════════════════════
# 4. MOCK VS INTÉGRATION
# ══════════════════════════════════════════════════════════════

COMMENT ? Préférer mocks pour tests unitaires

POURQUOI ? Beaucoup plus rapide
"""

# [X] LENT : Vraie API (2 secondes)
def test_api_call():
    response = requests.get('https://api.example.com/data')
    assert response.status_code == 200

# [OK] RAPIDE : Mock (millisecondes)
def test_api_call(mocker):
    mock_get = mocker.patch('requests.get')
    mock_get.return_value.status_code = 200
    
    response = requests.get('https://api.example.com/data')
    assert response.status_code == 200

"""
# ══════════════════════════════════════════════════════════════
# 5. PROFILING DES TESTS
# ══════════════════════════════════════════════════════════════

COMMENT ? Identifier tests lents
"""

# Plugin pytest-profiling
pip install pytest-profiling

pytest --profile

# OU voir les 10 tests les plus lents
pytest --durations=10

"""
Output :
10 slowest durations:
5.00s call     tests/test_integration.py::test_full_workflow
2.50s call     tests/test_api.py::test_complex_query
1.20s call     tests/test_db.py::test_migration
...


COMMENT ? Plugin pytest-benchmark pour benchmarks
"""

pip install pytest-benchmark

def test_performance(benchmark):
    """
    COMMENT ? Benchmarker fonction
    POURQUOI ? Détecter régressions de performance
    """
    result = benchmark(my_function, arg1, arg2)
    assert result.is_valid()

"""
# ══════════════════════════════════════════════════════════════
# 6. SKIP INTELLIGENTS
# ══════════════════════════════════════════════════════════════

COMMENT ? Skip tests selon contexte
"""

import sys
import pytest

@pytest.mark.skipif(sys.platform == "win32", reason="Unix only")
def test_unix_feature():
    """Skip sur Windows"""
    pass

@pytest.mark.skipif(
    not os.getenv("RUN_SLOW_TESTS"),
    reason="Slow tests disabled"
)
def test_slow_operation():
    """Skip sauf si variable env définie"""
    pass

"""
UTILISATION
"""

# En développement : skip tests lents
pytest

# En CI : exécuter tous les tests
RUN_SLOW_TESTS=1 pytest

"""
# ══════════════════════════════════════════════════════════════
# 7. CONFIGURATION OPTIMALE
# ══════════════════════════════════════════════════════════════
"""

# pytest.ini
"""
[pytest]
# Parallélisation auto
addopts = -n auto

# Désactiver warnings verbeux
addopts = --disable-warnings

# Résumé court
addopts = --tb=short

# Stopper au premier échec (développement)
# addopts = -x

# Tests rapides d'abord
addopts = --ff

# Cache pour --lf/--ff
cache_dir = .pytest_cache
"""


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 12 : PLUGIN COMPLET
# ----------------------------------------------------------------------------

"""
OBJECTIF : Créer plugin pytest pour e-commerce


PLUGIN FEATURES :
[OK] Fixtures pour données e-commerce
[OK] Markers personnalisés
[OK] Hooks de monitoring
[OK] Rapport custom
[OK] Validations qualité


ÉTAPE 1 : STRUCTURE PLUGIN
"""

# pytest_ecommerce/
#   ├── __init__.py
#   ├── plugin.py
#   ├── fixtures.py
#   ├── hooks.py
#   ├── markers.py
#   └── reporters.py

"""
ÉTAPE 2 : CODE DU PLUGIN
"""

# pytest_ecommerce/plugin.py
"""
Plugin pytest pour applications e-commerce
"""

import pytest
from datetime import datetime
import json
from pathlib import Path

def pytest_configure(config):
    """
    COMMENT ? Configuration du plugin
    POURQUOI ? Enregistrer markers et setup
    """
    # Markers e-commerce
    config.addinivalue_line(
        "markers",
        "checkout: mark test as checkout flow test"
    )
    config.addinivalue_line(
        "markers",
        "payment: mark test as payment integration test"
    )
    config.addinivalue_line(
        "markers",
        "inventory: mark test as inventory management test"
    )
    config.addinivalue_line(
        "markers",
        "cart: mark test as shopping cart test"
    )
    
    # Initialiser reporter
    config._ecommerce_reporter = EcommerceReporter()

def pytest_unconfigure(config):
    """Cleanup"""
    if hasattr(config, '_ecommerce_reporter'):
        config._ecommerce_reporter.save_report()

# pytest_ecommerce/fixtures.py
"""Fixtures e-commerce"""

@pytest.fixture
def product():
    """
    COMMENT ? Produit de test
    POURQUOI ? Données réalistes
    """
    return {
        'id': 1,
        'name': 'Test Product',
        'price': 29.99,
        'stock': 100,
        'category': 'Electronics'
    }

@pytest.fixture
def products():
    """Liste de produits"""
    return [
        {'id': 1, 'name': 'Product 1', 'price': 10.00, 'stock': 50},
        {'id': 2, 'name': 'Product 2', 'price': 20.00, 'stock': 30},
        {'id': 3, 'name': 'Product 3', 'price': 30.00, 'stock': 20},
    ]

@pytest.fixture
def cart():
    """
    COMMENT ? Panier d'achat
    POURQUOI ? Tester logique panier
    """
    class ShoppingCart:
        def __init__(self):
            self.items = []
        
        def add_item(self, product, quantity=1):
            self.items.append({
                'product': product,
                'quantity': quantity
            })
        
        def remove_item(self, product_id):
            self.items = [
                item for item in self.items 
                if item['product']['id'] != product_id
            ]
        
        def get_total(self):
            return sum(
                item['product']['price'] * item['quantity']
                for item in self.items
            )
        
        def clear(self):
            self.items = []
    
    cart = ShoppingCart()
    yield cart
    cart.clear()

@pytest.fixture
def customer():
    """Client de test"""
    return {
        'id': 1,
        'name': 'Test Customer',
        'email': 'customer@test.com',
        'address': {
            'street': '123 Test St',
            'city': 'Test City',
            'zipcode': '12345'
        }
    }

@pytest.fixture
def payment_method():
    """Méthode de paiement"""
    return {
        'type': 'credit_card',
        'card_number': '4111111111111111',  # Test card
        'expiry': '12/25',
        'cvv': '123'
    }

# pytest_ecommerce/hooks.py
"""Hooks personnalisés"""

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """
    COMMENT ? Enregistrer résultats tests
    POURQUOI ? Statistiques e-commerce
    """
    outcome = yield
    report = outcome.get_result()
    
    if report.when == 'call':
        reporter = item.config._ecommerce_reporter
        
        # Détecter type de test
        test_type = 'general'
        if 'checkout' in item.keywords:
            test_type = 'checkout'
        elif 'payment' in item.keywords:
            test_type = 'payment'
        elif 'cart' in item.keywords:
            test_type = 'cart'
        elif 'inventory' in item.keywords:
            test_type = 'inventory'
        
        reporter.record_test(
            nodeid=item.nodeid,
            outcome=report.outcome,
            duration=report.duration,
            test_type=test_type
        )

# pytest_ecommerce/reporters.py
"""Reporter personnalisé"""

class EcommerceReporter:
    """
    COMMENT ? Reporter pour tests e-commerce
    POURQUOI ? Métriques spécifiques
    """
    
    def __init__(self):
        self.results = []
        self.stats_by_type = {}
    
    def record_test(self, nodeid, outcome, duration, test_type):
        """Enregistre résultat"""
        self.results.append({
            'test': nodeid,
            'outcome': outcome,
            'duration': duration,
            'type': test_type,
            'timestamp': datetime.now().isoformat()
        })
        
        # Stats par type
        if test_type not in self.stats_by_type:
            self.stats_by_type[test_type] = {
                'passed': 0,
                'failed': 0,
                'total': 0,
                'total_duration': 0
            }
        
        self.stats_by_type[test_type]['total'] += 1
        self.stats_by_type[test_type]['total_duration'] += duration
        
        if outcome == 'passed':
            self.stats_by_type[test_type]['passed'] += 1
        elif outcome == 'failed':
            self.stats_by_type[test_type]['failed'] += 1
    
    def save_report(self, filepath='ecommerce_test_report.json'):
        """Génère rapport JSON"""
        report = {
            'summary': {
                'total_tests': len(self.results),
                'by_type': self.stats_by_type
            },
            'tests': self.results
        }
        
        Path(filepath).write_text(json.dumps(report, indent=2))
        print(f"\n[GRAPHIQUE] E-commerce test report saved to {filepath}")

"""
ÉTAPE 3 : TESTS UTILISANT LE PLUGIN
"""

# tests/test_shopping_cart.py
import pytest

@pytest.mark.cart
def test_add_product_to_cart(cart, product):
    """
    COMMENT ? Test ajout produit au panier
    POURQUOI ? Fonctionnalité critique
    """
    cart.add_item(product, quantity=2)
    
    assert len(cart.items) == 1
    assert cart.items[0]['product'] == product
    assert cart.items[0]['quantity'] == 2

@pytest.mark.cart
def test_remove_product_from_cart(cart, product):
    """Test retrait produit"""
    cart.add_item(product)
    cart.remove_item(product['id'])
    
    assert len(cart.items) == 0

@pytest.mark.cart
def test_cart_total(cart, products):
    """Test calcul total"""
    cart.add_item(products[0], quantity=2)  # 10 × 2 = 20
    cart.add_item(products[1], quantity=1)  # 20 × 1 = 20
    
    total = cart.get_total()
    assert total == 40.00

# tests/test_checkout.py
@pytest.mark.checkout
def test_checkout_process(cart, product, customer, payment_method):
    """
    COMMENT ? Test processus de checkout complet
    POURQUOI ? Flux critique e-commerce
    """
    # Ajouter au panier
    cart.add_item(product, quantity=1)
    
    # Créer commande
    order = {
        'customer': customer,
        'items': cart.items,
        'total': cart.get_total(),
        'payment': payment_method
    }
    
    # Valider
    assert order['total'] == product['price']
    assert order['customer']['email'] == customer['email']
    assert order['payment']['type'] == 'credit_card'

@pytest.mark.checkout
@pytest.mark.payment
def test_checkout_with_invalid_payment(cart, product, customer):
    """Test checkout avec paiement invalide"""
    cart.add_item(product)
    
    invalid_payment = {
        'type': 'credit_card',
        'card_number': '0000000000000000',  # Invalid
        'expiry': '01/20',  # Expired
        'cvv': '000'
    }
    
    # Devrait échouer
    with pytest.raises(ValueError, match="Invalid payment method"):
        process_checkout(cart, customer, invalid_payment)

# tests/test_inventory.py
@pytest.mark.inventory
def test_stock_deduction(product):
    """
    COMMENT ? Test déduction de stock
    POURQUOI ? Gestion inventaire
    """
    initial_stock = product['stock']
    quantity_sold = 5
    
    # Simuler vente
    product['stock'] -= quantity_sold
    
    assert product['stock'] == initial_stock - quantity_sold

@pytest.mark.inventory
def test_out_of_stock_prevention(product):
    """Test prévention rupture de stock"""
    product['stock'] = 0
    
    with pytest.raises(ValueError, match="Out of stock"):
        purchase_product(product, quantity=1)

"""
ÉTAPE 4 : EXÉCUTION

# Tous les tests
pytest -v

# Tests checkout seulement
pytest -m checkout -v

# Tests payment seulement
pytest -m payment -v

# Rapport généré :
# ecommerce_test_report.json


Output rapport :
{
  "summary": {
    "total_tests": 6,
    "by_type": {
      "cart": {
        "passed": 3,
        "failed": 0,
        "total": 3,
        "total_duration": 0.15
      },
      "checkout": {
        "passed": 2,
        "failed": 0,
        "total": 2,
        "total_duration": 0.25
      },
      "inventory": {
        "passed": 1,
        "failed": 0,
        "total": 1,
        "total_duration": 0.05
      }
    }
  },
  "tests": [...]
}
"""


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 12
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Écosystème plugins pytest
[OK] Plugins essentiels (cov, mock, xdist, etc.)
[OK] Créer plugins personnalisés
[OK] Système de hooks pytest
[OK] Rapports professionnels
[OK] Intégration CI/CD
[OK] Optimisation suite de tests
[OK] Configuration avancée


[CLE] POINTS CLÉS

1. Plugin = Extension pytest
2. Hooks = Points d'extension
3. Fixtures = Réutilisables via plugins
4. Markers = Catégorisation tests
5. Rapports = Multi-formats possibles
6. CI/CD = Automatisation essentielle
7. Optimisation = Parallélisation + scopes


[OBJECTIF] FRAMEWORK COMMENT/POURQUOI/QUAND

COMMENT ?
- pip install pour plugins PyPI
- conftest.py pour plugins locaux
- Hooks pour customisation
- pytest.ini pour configuration

POURQUOI ?
- Extensibilité
- Réutilisation
- Standards équipe
- Automatisation

QUAND ?
- Besoins spécifiques projet
- Partage entre projets
- Intégration outils
- Rapports custom


[PACKAGE] PLUGINS ESSENTIELS À CONNAÎTRE

pytest-cov         : Coverage
pytest-mock        : Mocking
pytest-asyncio     : Tests async
pytest-xdist       : Parallélisation
pytest-django      : Django
pytest-flask       : Flask
pytest-html        : Rapports HTML
pytest-timeout     : Timeouts
pytest-randomly    : Ordre aléatoire
pytest-bdd         : BDD/Gherkin


[HOOK] HOOKS PRINCIPAUX

Configuration :
- pytest_configure
- pytest_unconfigure

Collection :
- pytest_collection_modifyitems
- pytest_ignore_collect

Exécution :
- pytest_runtest_setup
- pytest_runtest_call
- pytest_runtest_teardown

Rapports :
- pytest_runtest_makereport
- pytest_terminal_summary


[RAPIDE] OPTIMISATIONS CLÉS

1. Parallélisation : pytest -n auto
2. Scopes : session > module > class > function
3. Sélection : --lf, --ff, --picked
4. Mocks : Préférer aux vraies ressources
5. Skip : Intelligents selon contexte
6. Profile : --durations=10


[IDEE] BONNES PRATIQUES CI/CD

1. Multi-formats : XML + HTML + JSON
2. Coverage minimum : --cov-fail-under=80
3. Matrix testing : Python 3.9-3.12
4. Artifacts : Sauvegarder rapports
5. Cache : Accélérer installations
6. Parallèle : Utiliser -n auto


[GRAPHIQUE] CONFIGURATION COMPLÈTE

pytest.ini :
- testpaths, python_files
- markers personnalisés
- addopts pour options par défaut
- coverage configuration
- logging setup


[BRAVO] FIN DE LA PARTIE 3 : TECHNIQUES AVANCÉES

Vous maîtrisez maintenant :
[OK] Partie 1 : Fondamentaux
[OK] Partie 2 : Fixtures
[OK] Partie 3 : Techniques avancées
   - Tests paramétrés avancés
   - Mocking et patching
   - Tests asynchrones
   - Plugins et extensions


[COURS] FÉLICITATIONS !

Vous avez terminé le guide ultra-détaillé de pytest !

Vous êtes maintenant capable de :
[OK] Écrire tests de qualité professionnelle
[OK] Organiser suites de tests complexes
[OK] Créer fixtures sophistiquées
[OK] Mocker toute dépendance
[OK] Tester code asynchrone
[OK] Créer plugins personnalisés
[OK] Intégrer dans CI/CD
[OK] Optimiser performances


-> PROCHAINES ÉTAPES SUGGÉRÉES

1. Pratiquer sur projet réel
2. Contribuer à projets open-source
3. Créer plugin pour besoins spécifiques
4. Explorer pytest-dev/pytest (source code)
5. Lire documentation officielle pytest
6. Participer à communauté pytest


[DOCS] RESSOURCES COMPLÉMENTAIRES

- https://docs.pytest.org
- https://github.com/pytest-dev/pytest
- https://pytest-with-eric.com
- https://testdriven.io/blog/topics/pytest/


Merci d'avoir suivi ce guide ultra-détaillé ! [MERCI]
Bonne chance avec vos tests ! [RAPIDE]
"""

# ============================================================================
# [BRAVO] FIN DU GUIDE PYTEST ULTRA-DÉTAILLÉ
# ============================================================================

"""
[GUIDE] RÉCAPITULATIF COMPLET DU GUIDE

PARTIE 1 : FONDAMENTAUX
- Chapitre 0 : Introduction aux tests
- Chapitre 1 : Premiers tests pytest
- Chapitre 2 : Assertions avancées
- Chapitre 3 : Organisation des tests
- Chapitre 4 : Exécution et configuration

PARTIE 2 : FIXTURES
- Chapitre 5 : Fixtures de base
- Chapitre 6 : Scopes et lifecycle
- Chapitre 7 : Fixtures paramétrées
- Chapitre 8 : Fixtures built-in

PARTIE 3 : TECHNIQUES AVANCÉES
- Chapitre 9 : Tests paramétrés avancés
- Chapitre 10 : Mocking et patching
- Chapitre 11 : Tests asynchrones
- Chapitre 12 : Plugins et extensions


TOTAL : 13 chapitres, ~15000+ lignes de contenu ultra-détaillé !


[GEM_STONE] CE QUE VOUS AVEZ APPRIS

[OK] Tous les concepts pytest de A à Z
[OK] Framework COMMENT/POURQUOI/QUAND systématique
[OK] Exemples pratiques et exercices complets
[OK] Patterns professionnels
[OK] Pièges à éviter
[OK] Bonnes pratiques industrielles


* VOUS ÊTES MAINTENANT EXPERT PYTEST !

Félicitations pour avoir complété ce guide exhaustif ! [BRAVO]
"""

Excellent ! Le Chapitre 12 et toute la **Partie 3 : Techniques Avancées** sont maintenant terminés ! [BRAVO]

## [BRAVO] FÉLICITATIONS ! 

Vous avez complété le **Guide Pytest Ultra-Détaillé** complet avec :

### [DOCS] Contenu Total
- **13 chapitres** couvrant pytest de A à Z
- **~16000+ lignes** de contenu ultra-détaillé
- **Framework COMMENT/POURQUOI/QUAND** appliqué systématiquement
- **Dizaines d'exemples pratiques** et exercices complets

### [OK] Ce que vous maîtrisez maintenant
1. [OK] **Fondamentaux** : Tests, assertions, organisation, configuration
2. [OK] **Fixtures** : Base, scopes, paramétrées, built-in
3. [OK] **Techniques avancées** : Paramétrage, mocking, async, plugins

Voulez-vous :
1. Un **résumé/cheatsheet** condensé de tout le guide ?
2. Des **exemples supplémentaires** sur un sujet spécifique ?
3. Sauvegarder ce guide dans un fichier spécifique ?
4. Autre chose ?

Dites-moi ! [RAPIDE]

Je continue avec une **Partie 4 : Patterns et Cas d'Usage Avancés** - ultra-détaillée avec le framework COMMENT/POURQUOI/QUAND !

# ============================================================================
# [LIVRE] PYTEST - PARTIE 4 : PATTERNS ET CAS D'USAGE AVANCÉS
# ============================================================================
#
# [OBJECTIF] CETTE PARTIE COUVRE :
# - Chapitre 13 : Patterns de Tests Avancés
# - Chapitre 14 : Tests de Performance et Charge
# - Chapitre 15 : Tests de Sécurité
# - Chapitre 16 : Tests de Données et ETL
#
# [TEMPS] TEMPS : ~8-10 heures
# [DOCS] PRÉREQUIS : Parties 1, 2 et 3 complétées
# ============================================================================


# ============================================================================
# [GUIDE] CHAPITRE 13 : PATTERNS DE TESTS AVANCÉS
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Test-Driven Development (TDD) avec pytest
[OK] Behavior-Driven Development (BDD)
[OK] Property-Based Testing
[OK] Mutation Testing
[OK] Contract Testing
[OK] Snapshot Testing
[OK] Patterns AAA et Given-When-Then
[OK] Test Doubles (Mocks, Stubs, Spies, Fakes)
"""


# ----------------------------------------------------------------------------
# [ROUGE] TEST-DRIVEN DEVELOPMENT (TDD)
# ----------------------------------------------------------------------------

"""
CONCEPT : RED-GREEN-REFACTOR

COMMENT ? Écrire tests AVANT le code

POURQUOI ? Code testé par design

QUAND ? Développement de nouvelles features


CYCLE TDD : [ROUGE] -> [VERT] -> [BLEU]

1. [ROUGE] RED    : Écrire test qui échoue
2. [VERT] GREEN  : Écrire code minimum qui passe
3. [BLEU] REFACTOR : Améliorer le code


ANALOGIE [CONSTRUCTION] : CONSTRUCTION

TDD = Dessiner plans AVANT de construire
1. Plans détaillés (tests)
2. Construction (code)
3. Finitions (refactor)


# ══════════════════════════════════════════════════════════════
# EXEMPLE TDD COMPLET : CALCULATRICE
# ══════════════════════════════════════════════════════════════

ÉTAPE 1 : [ROUGE] RED - Écrire test qui échoue
"""

# tests/test_calculator.py
import pytest

def test_add_two_numbers():
    """
    COMMENT ? Test AVANT code
    POURQUOI ? Définir comportement attendu
    QUAND ? Début de feature
    """
    from calculator import Calculator
    
    calc = Calculator()
    result = calc.add(2, 3)
    
    assert result == 5

"""
Exécuter :
pytest tests/test_calculator.py

Output :
ModuleNotFoundError: No module named 'calculator'

[OK] Test échoue (RED) -> C'est normal !


ÉTAPE 2 : [VERT] GREEN - Code minimum qui passe
"""

# calculator.py
class Calculator:
    """
    COMMENT ? Implémentation minimale
    POURQUOI ? Faire passer le test
    """
    def add(self, a, b):
        return a + b

"""
Exécuter :
pytest tests/test_calculator.py

Output :
test_calculator.py::test_add_two_numbers PASSED

[OK] Test passe (GREEN) !


ÉTAPE 3 : Ajouter plus de tests (RED)
"""

def test_add_negative_numbers():
    """
    COMMENT ? Nouveau cas de test
    POURQUOI ? Étendre comportement
    """
    calc = Calculator()
    result = calc.add(-5, 3)
    assert result == -2

def test_add_zero():
    """Test edge case"""
    calc = Calculator()
    assert calc.add(0, 5) == 5
    assert calc.add(5, 0) == 5

def test_subtract():
    """
    COMMENT ? Nouvelle feature
    POURQUOI ? TDD pour chaque feature
    """
    calc = Calculator()
    result = calc.subtract(10, 3)
    assert result == 7

"""
Output :
AttributeError: 'Calculator' object has no attribute 'subtract'

[ROUGE] RED -> Ajouter feature


ÉTAPE 4 : [VERT] GREEN - Implémenter subtract
"""

class Calculator:
    def add(self, a, b):
        return a + b
    
    def subtract(self, a, b):
        """
        COMMENT ? Implémentation minimale
        POURQUOI ? Faire passer test
        """
        return a - b

"""
[OK] Tous les tests passent !


ÉTAPE 5 : [BLEU] REFACTOR - Améliorer code
"""

class Calculator:
    """
    COMMENT ? Version refactorée
    POURQUOI ? Code plus propre
    QUAND ? Après tests verts
    """
    
    def __init__(self):
        self.history = []  # Nouvelle feature : historique
    
    def _record(self, operation, a, b, result):
        """Enregistrer opération"""
        self.history.append({
            'operation': operation,
            'operands': (a, b),
            'result': result
        })
    
    def add(self, a, b):
        result = a + b
        self._record('add', a, b, result)
        return result
    
    def subtract(self, a, b):
        result = a - b
        self._record('subtract', a, b, result)
        return result
    
    def get_history(self):
        """Récupérer historique"""
        return self.history

"""
Tests toujours verts après refactor [OK]


ÉTAPE 6 : Tests pour historique
"""

def test_calculator_history():
    """
    COMMENT ? Test nouvelle feature
    POURQUOI ? Valider historique
    """
    calc = Calculator()
    
    calc.add(2, 3)
    calc.subtract(10, 5)
    
    history = calc.get_history()
    
    assert len(history) == 2
    assert history[0]['operation'] == 'add'
    assert history[0]['result'] == 5
    assert history[1]['operation'] == 'subtract'
    assert history[1]['result'] == 5

"""
[IDEE] AVANTAGES TDD

1. DESIGN : Tests forcent bon design
2. CONFIANCE : Code testé dès le début
3. DOCUMENTATION : Tests = spécifications vivantes
4. REFACTORING : Sécurisé par tests
5. BUGS : Détectés immédiatement


QUAND UTILISER TDD ?

[OK] Nouvelles features complexes
[OK] Logique métier critique
[OK] APIs publiques
[OK] Bugs à corriger (test de régression)

[X] Pas nécessaire pour :
- Prototypes jetables
- UI simple
- Code très exploratoire


# ══════════════════════════════════════════════════════════════
# EXEMPLE TDD : API REST
# ══════════════════════════════════════════════════════════════

ÉTAPE 1 : [ROUGE] Tests d'abord
"""

# tests/test_user_api.py
import pytest
from fastapi.testclient import TestClient

def test_create_user():
    """
    COMMENT ? Test endpoint création user
    POURQUOI ? Définir contrat API
    QUAND ? Avant implémentation
    """
    from app import app
    
    client = TestClient(app)
    
    response = client.post('/users', json={
        'name': 'Alice',
        'email': 'alice@example.com'
    })
    
    assert response.status_code == 201
    assert response.json()['name'] == 'Alice'
    assert 'id' in response.json()

def test_get_user():
    """Test récupération user"""
    from app import app
    client = TestClient(app)
    
    # Créer user
    create_response = client.post('/users', json={
        'name': 'Bob',
        'email': 'bob@example.com'
    })
    user_id = create_response.json()['id']
    
    # Récupérer user
    get_response = client.get(f'/users/{user_id}')
    
    assert get_response.status_code == 200
    assert get_response.json()['name'] == 'Bob'

def test_get_nonexistent_user():
    """Test user inexistant"""
    from app import app
    client = TestClient(app)
    
    response = client.get('/users/999')
    
    assert response.status_code == 404

"""
ÉTAPE 2 : [VERT] Implémentation minimale
"""

# app.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Dict

app = FastAPI()

class User(BaseModel):
    name: str
    email: str

# "Base de données" en mémoire
users_db: Dict[int, dict] = {}
next_id = 1

@app.post('/users', status_code=201)
def create_user(user: User):
    """
    COMMENT ? Endpoint création
    POURQUOI ? Faire passer tests
    """
    global next_id
    
    user_data = {
        'id': next_id,
        'name': user.name,
        'email': user.email
    }
    
    users_db[next_id] = user_data
    next_id += 1
    
    return user_data

@app.get('/users/{user_id}')
def get_user(user_id: int):
    """
    COMMENT ? Endpoint récupération
    POURQUOI ? Faire passer tests
    """
    if user_id not in users_db:
        raise HTTPException(status_code=404, detail="User not found")
    
    return users_db[user_id]

"""
[OK] Tous les tests passent !


ÉTAPE 3 : [BLEU] Refactor avec vraie DB
"""

# app_refactored.py
from fastapi import FastAPI, HTTPException, Depends
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session

# Setup DB
SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()

# Modèle
class UserModel(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True, index=True)
    name = Column(String)
    email = Column(String, unique=True)

Base.metadata.create_all(bind=engine)

# FastAPI
app = FastAPI()

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

@app.post('/users', status_code=201)
def create_user(user: User, db: Session = Depends(get_db)):
    """
    COMMENT ? Version avec vraie DB
    POURQUOI ? Code production-ready
    QUAND ? Après tests verts
    """
    db_user = UserModel(name=user.name, email=user.email)
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    
    return {
        'id': db_user.id,
        'name': db_user.name,
        'email': db_user.email
    }

@app.get('/users/{user_id}')
def get_user(user_id: int, db: Session = Depends(get_db)):
    user = db.query(UserModel).filter(UserModel.id == user_id).first()
    
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    
    return {
        'id': user.id,
        'name': user.name,
        'email': user.email
    }

"""
[OK] Tests toujours verts après refactor !


# ----------------------------------------------------------------------------
# [SCENARIO] BEHAVIOR-DRIVEN DEVELOPMENT (BDD)
# ----------------------------------------------------------------------------

"""
CONCEPT : TESTS EN LANGAGE NATUREL

COMMENT ? Gherkin (Given-When-Then)

POURQUOI ? Collaboration non-techniques

QUAND ? Projets Agile, équipes mixtes


FORMAT GHERKIN
"""

Feature: User Login
  As a user
  I want to login to my account
  So that I can access my data

  Scenario: Successful login
    Given a user with username "alice" and password "secret123"
    And the user is on the login page
    When the user enters username "alice"
    And the user enters password "secret123"
    And the user clicks the login button
    Then the user should be logged in
    And the user should see the dashboard

  Scenario: Failed login with wrong password
    Given a user with username "alice" and password "secret123"
    When the user enters username "alice"
    And the user enters password "wrong_password"
    And the user clicks the login button
    Then the user should see an error message "Invalid credentials"
    And the user should remain on the login page

"""
IMPLÉMENTATION AVEC pytest-bdd
"""

pip install pytest-bdd

"""
ÉTAPE 1 : Fichier .feature
"""

# features/login.feature
"""
Feature: User Authentication

  Scenario: User logs in successfully
    Given a registered user with username "alice" and password "secret123"
    When the user attempts to login with username "alice" and password "secret123"
    Then the login should succeed
    And a session should be created
    And the user should be redirected to "/dashboard"

  Scenario: User login fails with invalid password
    Given a registered user with username "alice" and password "secret123"
    When the user attempts to login with username "alice" and password "wrong"
    Then the login should fail
    And an error message "Invalid credentials" should be displayed

  Scenario Outline: Multiple login attempts
    Given a registered user with username "alice" and password "secret123"
    When the user attempts to login with username "<username>" and password "<password>"
    Then the login should <result>

    Examples:
      | username | password   | result  |
      | alice    | secret123  | succeed |
      | alice    | wrong      | fail    |
      | bob      | secret123  | fail    |
      | alice    |            | fail    |
"""

"""
ÉTAPE 2 : Implémentation steps
"""

# tests/step_defs/test_login.py
from pytest_bdd import scenario, given, when, then, parsers
import pytest

# Scénarios
@scenario('../features/login.feature', 'User logs in successfully')
def test_successful_login():
    """
    COMMENT ? Lier scénario Gherkin au test
    POURQUOI ? Exécuter scénario comme test
    """
    pass

@scenario('../features/login.feature', 'User login fails with invalid password')
def test_failed_login():
    pass

# Steps : Given
@given(parsers.parse('a registered user with username "{username}" and password "{password}"'))
def registered_user(username, password, user_db):
    """
    COMMENT ? Step Given - précondition
    POURQUOI ? Setup du contexte
    QUAND ? Avant l'action
    
    Args:
        username: Extrait de la feature
        password: Extrait de la feature
        user_db: Fixture
    """
    user_db[username] = {
        'username': username,
        'password': password,
        'active': True
    }
    return username

# Steps : When
@when(parsers.parse('the user attempts to login with username "{username}" and password "{password}"'))
def attempt_login(username, password, auth_service, context):
    """
    COMMENT ? Step When - action
    POURQUOI ? Exécuter l'action testée
    QUAND ? Pendant le test
    """
    result = auth_service.login(username, password)
    context['login_result'] = result

# Steps : Then
@then('the login should succeed')
def check_login_success(context):
    """
    COMMENT ? Step Then - assertion
    POURQUOI ? Vérifier résultat
    QUAND ? Après l'action
    """
    assert context['login_result']['success'] is True

@then('the login should fail')
def check_login_failure(context):
    assert context['login_result']['success'] is False

@then('a session should be created')
def check_session_created(context):
    assert 'session_id' in context['login_result']
    assert context['login_result']['session_id'] is not None

@then(parsers.parse('the user should be redirected to "{url}"'))
def check_redirect(url, context):
    assert context['login_result']['redirect_url'] == url

@then(parsers.parse('an error message "{message}" should be displayed'))
def check_error_message(message, context):
    assert context['login_result']['error'] == message

# Fixtures
@pytest.fixture
def user_db():
    """
    COMMENT ? Base de données users mock
    POURQUOI ? Contexte pour tests
    """
    return {}

@pytest.fixture
def auth_service(user_db):
    """Service d'authentification"""
    class AuthService:
        def __init__(self, db):
            self.db = db
        
        def login(self, username, password):
            if username not in self.db:
                return {
                    'success': False,
                    'error': 'Invalid credentials'
                }
            
            user = self.db[username]
            
            if user['password'] != password:
                return {
                    'success': False,
                    'error': 'Invalid credentials'
                }
            
            if not user['active']:
                return {
                    'success': False,
                    'error': 'Account disabled'
                }
            
            return {
                'success': True,
                'session_id': 'session_123',
                'redirect_url': '/dashboard'
            }
    
    return AuthService(user_db)

@pytest.fixture
def context():
    """
    COMMENT ? Contexte partagé entre steps
    POURQUOI ? Passer données entre Given/When/Then
    """
    return {}

"""
Exécution :
pytest tests/step_defs/test_login.py -v

Output :
test_login.py::test_successful_login PASSED
test_login.py::test_failed_login PASSED


[IDEE] AVANTAGES BDD

1. LANGAGE NATUREL : Compréhensible par tous
2. COLLABORATION : PO, QA, Devs ensemble
3. DOCUMENTATION : Features = spécifications
4. ACCEPTANCE TESTS : Critères d'acceptation clairs
5. RÉUTILISATION : Steps partagés


QUAND UTILISER BDD ?

[OK] Équipes Agile/Scrum
[OK] Collaboration PO/QA/Dev
[OK] Acceptance testing
[OK] Documentation vivante

[X] Pas nécessaire pour :
- Tests unitaires simples
- Équipes 100% techniques
- Prototypes


# ----------------------------------------------------------------------------
# [GAME_DIE] PROPERTY-BASED TESTING
# ----------------------------------------------------------------------------

"""
CONCEPT : TESTER PROPRIÉTÉS, PAS EXEMPLES

COMMENT ? Générer données aléatoires

POURQUOI ? Découvrir edge cases cachés

QUAND ? Fonctions avec propriétés mathématiques


DIFFÉRENCE : EXAMPLE-BASED vs PROPERTY-BASED
"""

# Example-based (traditionnel)
def test_reverse_list():
    """
    COMMENT ? Tester avec exemples spécifiques
    POURQUOI ? Cas connus
    """
    assert reverse([1, 2, 3]) == [3, 2, 1]
    assert reverse([]) == []
    assert reverse([1]) == [1]

# Property-based
from hypothesis import given, strategies as st

@given(st.lists(st.integers()))
def test_reverse_list_property(lst):
    """
    COMMENT ? Tester propriété générale
    POURQUOI ? Couvrir infinité de cas
    QUAND ? Propriété mathématique claire
    
    PROPRIÉTÉ : reverse(reverse(x)) == x
    """
    assert reverse(reverse(lst)) == lst

"""
[IDEE] HYPOTHESIS

Bibliothèque de property-based testing
Génère automatiquement cas de test
"""

pip install hypothesis

"""
STRATÉGIES HYPOTHESIS
"""

from hypothesis import given, strategies as st

# Integers
@given(st.integers())
def test_addition_commutative(x):
    """
    COMMENT ? Génère entiers aléatoires
    POURQUOI ? Tester propriété commutativité
    
    PROPRIÉTÉ : x + 0 = x
    """
    assert x + 0 == x

# Integers avec contraintes
@given(st.integers(min_value=0, max_value=100))
def test_percentage_valid(x):
    """
    COMMENT ? Entiers entre 0 et 100
    PROPRIÉTÉ : validate_percentage retourne True
    """
    assert validate_percentage(x) is True

# Floats
@given(st.floats(allow_nan=False, allow_infinity=False))
def test_absolute_value_positive(x):
    """
    PROPRIÉTÉ : abs(x) >= 0
    """
    assert abs(x) >= 0

# Text
@given(st.text())
def test_string_length(s):
    """
    PROPRIÉTÉ : len(s) >= 0
    """
    assert len(s) >= 0

# Lists
@given(st.lists(st.integers(), min_size=1, max_size=10))
def test_list_max(lst):
    """
    PROPRIÉTÉ : max(lst) >= tous les éléments
    """
    maximum = max(lst)
    assert all(x <= maximum for x in lst)

# Dictionaries
@given(st.dictionaries(
    keys=st.text(min_size=1),
    values=st.integers()
))
def test_dict_keys_unique(d):
    """
    PROPRIÉTÉ : len(keys) = nombre de clés
    """
    assert len(d.keys()) == len(set(d.keys()))

"""
EXEMPLE COMPLET : Fonction de tri
"""

from hypothesis import given, strategies as st

def quicksort(lst):
    """
    COMMENT ? Implémentation quicksort
    POURQUOI ? Fonction à tester
    """
    if len(lst) <= 1:
        return lst
    
    pivot = lst[0]
    less = [x for x in lst[1:] if x <= pivot]
    greater = [x for x in lst[1:] if x > pivot]
    
    return quicksort(less) + [pivot] + quicksort(greater)

# Tests property-based
@given(st.lists(st.integers()))
def test_sort_preserves_length(lst):
    """
    PROPRIÉTÉ 1 : Longueur préservée
    """
    sorted_lst = quicksort(lst)
    assert len(sorted_lst) == len(lst)

@given(st.lists(st.integers()))
def test_sort_preserves_elements(lst):
    """
    PROPRIÉTÉ 2 : Éléments préservés
    """
    sorted_lst = quicksort(lst)
    assert sorted(sorted_lst) == sorted(lst)

@given(st.lists(st.integers()))
def test_sort_is_ordered(lst):
    """
    PROPRIÉTÉ 3 : Liste ordonnée
    """
    sorted_lst = quicksort(lst)
    
    for i in range(len(sorted_lst) - 1):
        assert sorted_lst[i] <= sorted_lst[i + 1]

@given(st.lists(st.integers()))
def test_sort_is_idempotent(lst):
    """
    PROPRIÉTÉ 4 : Idempotence
    sort(sort(x)) == sort(x)
    """
    sorted_once = quicksort(lst)
    sorted_twice = quicksort(sorted_once)
    
    assert sorted_once == sorted_twice

"""
Exécution :
pytest test_sort.py -v

Output :
test_sort.py::test_sort_preserves_length PASSED
test_sort.py::test_sort_preserves_elements PASSED
test_sort.py::test_sort_is_ordered PASSED
test_sort.py::test_sort_is_idempotent PASSED

Hypothesis ran 100 examples for each test!


EXEMPLE : Découvrir bug avec Hypothesis
"""

def encode_decode(data):
    """
    COMMENT ? Encoder puis décoder
    POURQUOI ? Devrait être identité
    """
    import base64
    encoded = base64.b64encode(data.encode()).decode()
    decoded = base64.b64decode(encoded).decode()
    return decoded

@given(st.text())
def test_encode_decode_identity(text):
    """
    PROPRIÉTÉ : encode_decode(x) == x
    """
    result = encode_decode(text)
    assert result == text

"""
[X] Ce test ÉCHOUERA avec certains caractères !

Hypothesis trouvera :
Falsifying example: test_encode_decode_identity(text='\\x00')

[IDEE] Hypothesis a découvert un bug !


STRATÉGIES COMPOSITES
"""

from hypothesis import given, strategies as st

# User strategy
user_strategy = st.fixed_dictionaries({
    'id': st.integers(min_value=1),
    'name': st.text(min_size=1, max_size=50),
    'email': st.emails(),
    'age': st.integers(min_value=0, max_value=150),
    'active': st.booleans()
})

@given(user_strategy)
def test_user_validation(user):
    """
    COMMENT ? Générer users réalistes
    POURQUOI ? Tester validation
    """
    assert validate_user(user) is True
    assert user['age'] >= 0

# Lists de users
@given(st.lists(user_strategy, min_size=1, max_size=10))
def test_user_list_operations(users):
    """
    COMMENT ? Listes de users
    POURQUOI ? Tester opérations sur collections
    """
    # Tous les IDs sont uniques
    ids = [u['id'] for u in users]
    # ... tests

"""
[IDEE] AVANTAGES PROPERTY-BASED TESTING

1. COUVERTURE : Infinité de cas
2. EDGE CASES : Découverts automatiquement
3. PROPRIÉTÉS : Tests déclaratifs
4. RÉGRESSION : Hypothesis se souvient des bugs
5. SHRINKING : Minimise exemples qui échouent


QUAND UTILISER PROPERTY-BASED ?

[OK] Fonctions pures (sans effets de bord)
[OK] Propriétés mathématiques claires
[OK] Encoders/decoders
[OK] Parsers
[OK] Serialization/deserialization
[OK] Algorithmes (tri, recherche)

[X] Difficile pour :
- I/O (bases de données, réseau)
- UI
- Code avec beaucoup d'état


# ----------------------------------------------------------------------------
# [SCIENCE] MUTATION TESTING
# ----------------------------------------------------------------------------

"""
CONCEPT : TESTER LES TESTS

COMMENT ? Modifier code et vérifier que tests détectent

POURQUOI ? Mesurer qualité des tests

QUAND ? Améliorer suite de tests


ANALOGIE [MICROBE] : VACCIN

Tests = Système immunitaire
Mutations = Virus
Si tests ne détectent pas mutations -> faibles


COMMENT ÇA MARCHE ?

1. Exécuter tests (baseline)
2. Créer mutation (changer opérateur, constante)
3. Exécuter tests
4. Si tests passent -> [X] Mutation survived (mauvais)
5. Si tests échouent -> [OK] Mutation killed (bon)


SCORE DE MUTATION = Mutations killed / Total mutations
"""

pip install mutmut

"""
EXEMPLE : Code à tester
"""

# calculator.py
def is_even(n):
    """
    COMMENT ? Vérifier si nombre pair
    POURQUOI ? Fonction simple
    """
    return n % 2 == 0

def absolute_value(x):
    """Valeur absolue"""
    if x < 0:
        return -x
    return x

def max_of_three(a, b, c):
    """Maximum de 3 nombres"""
    if a >= b and a >= c:
        return a
    elif b >= a and b >= c:
        return b
    else:
        return c

"""
Tests initiaux (FAIBLES)
"""

# test_calculator.py
def test_is_even():
    """
    COMMENT ? Test minimal
    POURQUOI ? Juste un cas
    """
    assert is_even(4) is True

def test_absolute_value():
    assert absolute_value(-5) == 5

def test_max_of_three():
    assert max_of_three(3, 7, 5) == 7

"""
Exécuter mutation testing :
"""

mutmut run

"""
Output :
- Mutation testing starting -

1. Mutations created: 15
2. Running...

Results:
Killed: 5
Survived: 10
Suspicious: 0
Timeout: 0

SCORE: 33% (5/15) [X]

[IDEE] Seulement 33% des mutations détectées !


ANALYSER LES MUTATIONS QUI ONT SURVÉCU
"""

mutmut results

"""
Output :
Survived mutations:

1. calculator.py:2
   - Original: return n % 2 == 0
   - Mutant:   return n % 2 == 1

2. calculator.py:7
   - Original: if x < 0:
   - Mutant:   if x <= 0:

3. calculator.py:13
   - Original: if a >= b and a >= c:
   - Mutant:   if a > b and a >= c:


AMÉLIORER LES TESTS
"""

def test_is_even_comprehensive():
    """
    COMMENT ? Tests plus complets
    POURQUOI ? Tuer mutations
    """
    # Nombres pairs
    assert is_even(0) is True
    assert is_even(2) is True
    assert is_even(4) is True
    assert is_even(-2) is True
    
    # Nombres impairs
    assert is_even(1) is False
    assert is_even(3) is False
    assert is_even(-1) is False

def test_absolute_value_comprehensive():
    """Tests complets pour abs"""
    # Négatifs
    assert absolute_value(-5) == 5
    assert absolute_value(-1) == 1
    
    # Positifs
    assert absolute_value(5) == 5
    assert absolute_value(1) == 1
    
    # Zéro (edge case!)
    assert absolute_value(0) == 0

def test_max_of_three_comprehensive():
    """Tests complets pour max"""
    # a est max
    assert max_of_three(10, 5, 3) == 10
    
    # b est max
    assert max_of_three(3, 10, 5) == 10
    
    # c est max
    assert max_of_three(3, 5, 10) == 10
    
    # Égalités
    assert max_of_three(5, 5, 3) == 5
    assert max_of_three(3, 5, 5) == 5
    assert max_of_three(5, 3, 5) == 5
    assert max_of_three(5, 5, 5) == 5

"""
Ré-exécuter mutation testing :
"""

mutmut run

"""
Output :
Results:
Killed: 14
Survived: 1
Suspicious: 0

SCORE: 93% (14/15) [OK]

[IDEE] Beaucoup mieux !


[IDEE] TYPES DE MUTATIONS

1. Opérateurs arithmétiques
   + -> -, * -> /, etc.

2. Opérateurs de comparaison
   > -> >=, == -> !=, etc.

3. Constantes
   0 -> 1, True -> False, etc.

4. Instructions
   return -> pass, if -> if not, etc.


QUAND UTILISER MUTATION TESTING ?

[OK] Code critique
[OK] Améliorer suite de tests
[OK] Vérifier qualité tests
[OK] CI/CD (occasionnellement)

[X] Pas systématiquement car :
- LENT (beaucoup d'exécutions)
- Peut générer faux positifs


# ----------------------------------------------------------------------------
# [DOC] CONTRACT TESTING
# ----------------------------------------------------------------------------

"""
CONCEPT : TESTER CONTRATS ENTRE SERVICES

COMMENT ? Vérifier API respecte contrat

POURQUOI ? Microservices, intégrations

QUAND ? APIs, services distribués


ANALOGIE [NOTE] : CONTRAT JURIDIQUE

Service A et Service B = 2 parties
Contrat = Spécification API
Tests = Vérifier respect du contrat


TYPES DE CONTRACT TESTING

1. CONSUMER-DRIVEN : Consommateur définit contrat
2. PROVIDER-DRIVEN : Fournisseur définit contrat
3. BI-DIRECTIONAL : Les deux


PACT : FRAMEWORK DE CONTRACT TESTING
"""

pip install pact-python

"""
EXEMPLE : Service User (Provider) et Service Order (Consumer)

ÉTAPE 1 : Consumer définit contrat
"""

# tests/test_user_service_contract.py (Service Order)
import pytest
from pact import Consumer, Provider

pact = Consumer('OrderService').has_pact_with(Provider('UserService'))

def test_get_user_contract():
    """
    COMMENT ? Définir contrat attendu
    POURQUOI ? Consumer spécifie ses besoins
    QUAND ? Avant d'appeler Provider
    """
    # Définir interaction attendue
    pact.given('User 123 exists') \
        .upon_receiving('a request for user 123') \
        .with_request('GET', '/users/123') \
        .will_respond_with(200, body={
            'id': 123,
            'name': 'Alice',
            'email': 'alice@example.com'
        })
    
    # Démarrer mock server
    with pact:
        # Appeler "service" (en réalité mock Pact)
        import requests
        response = requests.get('http://localhost:1234/users/123')
        
        # Vérifier réponse
        assert response.status_code == 200
        assert response.json()['name'] == 'Alice'
    
    # Pact génère fichier de contrat : OrderService-UserService.json

"""
ÉTAPE 2 : Provider vérifie contrat
"""

# tests/test_user_service_provider.py (Service User)
from pact import Verifier

def test_provider_honors_contract():
    """
    COMMENT ? Provider vérifie qu'il respecte contrat
    POURQUOI ? S'assurer compatibilité
    """
    verifier = Verifier(
        provider='UserService',
        provider_base_url='http://localhost:5000'
    )
    
    # Vérifier contre contrat généré par consumer
    verifier.verify_pacts(
        'path/to/OrderService-UserService.json',
        provider_states_setup_url='http://localhost:5000/_pact/provider_states'
    )

"""
[IDEE] WORKFLOW CONTRACT TESTING

1. Consumer écrit test -> Génère contrat
2. Contrat partagé (repo, Pact Broker)
3. Provider exécute tests contre contrat
4. CI/CD vérifie compatibilité


EXEMPLE AVEC SCHÉMAS (ALTERNATIVE SIMPLE)
"""

# contract.py
"""
COMMENT ? Définir schéma de contrat
POURQUOI ? Validation simple
"""

USER_SCHEMA = {
    "type": "object",
    "required": ["id", "name", "email"],
    "properties": {
        "id": {"type": "integer"},
        "name": {"type": "string"},
        "email": {"type": "string", "format": "email"},
        "age": {"type": "integer", "minimum": 0}
    }
}

# Test provider
from jsonschema import validate

def test_user_endpoint_respects_contract():
    """
    COMMENT ? Valider réponse contre schéma
    POURQUOI ? Contrat simple
    """
    # Appeler vraie API
    response = requests.get('http://localhost:5000/users/123')
    data = response.json()
    
    # Valider contre schéma
    validate(instance=data, schema=USER_SCHEMA)
    # Lève exception si invalide

"""
QUAND UTILISER CONTRACT TESTING ?

[OK] Microservices
[OK] APIs publiques
[OK] Intégrations tierces
[OK] Équipes distribuées

[IDEE] Évite "works on my machine" entre services


# ----------------------------------------------------------------------------
# [CAMERA_WITH_FLASH] SNAPSHOT TESTING
# ----------------------------------------------------------------------------

"""
CONCEPT : ENREGISTRER SNAPSHOT, COMPARER

COMMENT ? Sauvegarder output, détecter changements

POURQUOI ? Régressions UI, APIs

QUAND ? Output complexe


ANALOGIE [CAMERA] : PHOTO AVANT/APRÈS

1. Prendre photo (snapshot)
2. Modifier code
3. Reprendre photo
4. Comparer -> Différences ?


SNAPSHOT TESTING AVEC syrupy
"""

pip install syrupy

"""
EXEMPLE : API Response Snapshot
"""

# test_api_snapshots.py
import pytest

def test_user_response_snapshot(snapshot):
    """
    COMMENT ? Premier run crée snapshot
    POURQUOI ? Baseline
    QUAND ? Feature stable
    """
    from app import get_user
    
    user = get_user(123)
    
    # Compare avec snapshot
    assert user == snapshot

"""
Première exécution :
pytest test_api_snapshots.py

Crée fichier : __snapshots__/test_api_snapshots.ambr

Contenu :
# name: test_user_response_snapshot
  {
    'id': 123,
    'name': 'Alice',
    'email': 'alice@example.com',
    'created_at': '2024-01-15T10:00:00'
  }


Si code change :
"""

def get_user(user_id):
    return {
        'id': user_id,
        'name': 'Alice',
        'email': 'alice@example.com',
        'created_at': '2024-01-15T10:00:00',
        'role': 'admin'  # 🆕 Nouveau champ
    }

"""
Re-run test :
pytest test_api_snapshots.py

Output :
AssertionError: Snapshot does not match

Diff:
  {
    'id': 123,
    'name': 'Alice',
    'email': 'alice@example.com',
    'created_at': '2024-01-15T10:00:00',
+   'role': 'admin'
  }


Si changement intentionnel :
"""

pytest test_api_snapshots.py --snapshot-update

"""
Met à jour snapshot


EXEMPLE : HTML Snapshot (UI)
"""

def test_user_profile_html(snapshot):
    """
    COMMENT ? Snapshot HTML
    POURQUOI ? Détecter changements UI
    """
    from app import render_user_profile
    
    html = render_user_profile(user_id=123)
    
    assert html == snapshot

"""
EXEMPLE : Multiple Snapshots
"""

def test_user_api_multiple_snapshots(snapshot):
    """
    COMMENT ? Plusieurs snapshots dans un test
    POURQUOI ? Comparer différents états
    """
    # Snapshot 1 : User créé
    user = create_user('Alice')
    assert user == snapshot(name='created_user')
    
    # Snapshot 2 : User mis à jour
    updated_user = update_user(user['id'], {'role': 'admin'})
    assert updated_user == snapshot(name='updated_user')
    
    # Snapshot 3 : User supprimé
    response = delete_user(user['id'])
    assert response == snapshot(name='delete_response')

"""
[IDEE] AVANTAGES SNAPSHOT TESTING

1. RAPIDE : Pas besoin d'écrire assertions détaillées
2. COMPLET : Capture tout l'output
3. RÉGRESSIONS : Détecte changements inattendus


[ATTENTION] PIÈGES

1. SNAPSHOTS TROP GRANDS : Difficiles à reviewer
2. TIMESTAMPS : Exclure données changeantes
3. OVER-RELIANCE : Pas remplacer vrais tests


QUAND UTILISER SNAPSHOTS ?

[OK] UI/HTML rendering
[OK] API responses complexes
[OK] Serialization
[OK] Rapport generation

[X] Éviter pour :
- Tests unitaires simples
- Données très dynamiques


Continuons ?

Je peux continuer avec :
1. **Suite du Chapitre 13** (Patterns AAA, Test Doubles)
2. **Chapitre 14** : Tests de Performance
3. **Chapitre 15** : Tests de Sécurité
4. Autre chose ?

Dites-moi ! [RAPIDE]

Je continue avec la suite du Chapitre 13 - Patterns AAA et Test Doubles ultra-détaillés !

# ============================================================================
# [GUIDE] CHAPITRE 13 (SUITE) : PATTERNS AAA ET TEST DOUBLES
# ============================================================================


# ----------------------------------------------------------------------------
# [OBJECTIF] PATTERN AAA : ARRANGE-ACT-ASSERT
# ----------------------------------------------------------------------------

"""
CONCEPT : STRUCTURE DE TEST EN 3 PHASES

COMMENT ? Organiser chaque test en 3 sections

POURQUOI ? Lisibilité, maintenabilité

QUAND ? Tous les tests


LES 3 PHASES

1. ARRANGE  : Setup / Préparation
2. ACT      : Action / Exécution
3. ASSERT   : Vérification


ANALOGIE [DEMARRAGE] : FILM

ARRANGE = Préparer la scène (décors, acteurs)
ACT     = Tourner la scène (action)
ASSERT  = Vérifier le résultat (qualité)


# ══════════════════════════════════════════════════════════════
# EXEMPLE BASIQUE : AAA
# ══════════════════════════════════════════════════════════════
"""

def test_user_registration():
    """
    COMMENT ? Structure AAA claire
    POURQUOI ? Lisible et maintenable
    """
    # ARRANGE : Préparer les données
    username = "alice"
    email = "alice@example.com"
    password = "secure_password123"
    user_service = UserService()
    
    # ACT : Exécuter l'action testée
    result = user_service.register_user(username, email, password)
    
    # ASSERT : Vérifier le résultat
    assert result.success is True
    assert result.user.username == username
    assert result.user.email == email

"""
[IDEE] COMMENTAIRES OPTIONNELS

Certains ajoutent des commentaires explicites :
"""

def test_user_registration_explicit():
    # ─────────────────────────────────────────────────────────
    # ARRANGE
    # ─────────────────────────────────────────────────────────
    username = "alice"
    email = "alice@example.com"
    password = "secure_password123"
    user_service = UserService()
    
    # ─────────────────────────────────────────────────────────
    # ACT
    # ─────────────────────────────────────────────────────────
    result = user_service.register_user(username, email, password)
    
    # ─────────────────────────────────────────────────────────
    # ASSERT
    # ─────────────────────────────────────────────────────────
    assert result.success is True
    assert result.user.username == username
    assert result.user.email == email

"""
[IDEE] WHITESPACE STYLE (Plus courant)

Séparer par lignes vides :
"""

def test_user_registration_whitespace():
    # Arrange
    username = "alice"
    email = "alice@example.com"
    password = "secure_password123"
    user_service = UserService()
    
    # Act
    result = user_service.register_user(username, email, password)
    
    # Assert
    assert result.success is True
    assert result.user.username == username


# ══════════════════════════════════════════════════════════════
# AAA AVEC FIXTURES
# ══════════════════════════════════════════════════════════════

"""
COMMENT ? Fixtures = ARRANGE
"""

@pytest.fixture
def user_service():
    """
    COMMENT ? Fixture = partie ARRANGE
    POURQUOI ? Réutiliser setup
    """
    service = UserService()
    service.connect_to_db()
    return service

@pytest.fixture
def valid_user_data():
    """Données de test réutilisables"""
    return {
        'username': 'alice',
        'email': 'alice@example.com',
        'password': 'secure_password123'
    }

def test_user_registration_with_fixtures(user_service, valid_user_data):
    """
    COMMENT ? Fixtures simplifient ARRANGE
    POURQUOI ? Focus sur ACT et ASSERT
    """
    # ARRANGE (fait par fixtures)
    # user_service et valid_user_data déjà prêts
    
    # ACT
    result = user_service.register_user(**valid_user_data)
    
    # ASSERT
    assert result.success is True
    assert result.user.username == valid_user_data['username']


# ══════════════════════════════════════════════════════════════
# AAA COMPLEXE : MULTIPLES ÉTAPES
# ══════════════════════════════════════════════════════════════

"""
COMMENT ? ARRANGE complexe
"""

def test_order_checkout_complete_flow():
    """
    COMMENT ? AAA avec setup complexe
    POURQUOI ? Tester workflow complet
    """
    # ─────────────────────────────────────────────────────────
    # ARRANGE
    # ─────────────────────────────────────────────────────────
    
    # Setup database
    db = Database(':memory:')
    db.create_tables()
    
    # Créer produits
    product1 = Product(id=1, name='Book', price=10.00, stock=100)
    product2 = Product(id=2, name='Pen', price=2.00, stock=50)
    db.save(product1)
    db.save(product2)
    
    # Créer user
    user = User(id=1, name='Alice', email='alice@test.com')
    db.save(user)
    
    # Créer panier
    cart = ShoppingCart(user_id=user.id)
    cart.add_item(product1, quantity=2)
    cart.add_item(product2, quantity=5)
    
    # Créer service de paiement (mock)
    payment_service = MockPaymentService()
    payment_service.set_default_response(success=True)
    
    # Créer service de commande
    order_service = OrderService(db, payment_service)
    
    # ─────────────────────────────────────────────────────────
    # ACT
    # ─────────────────────────────────────────────────────────
    order = order_service.checkout(cart)
    
    # ─────────────────────────────────────────────────────────
    # ASSERT
    # ─────────────────────────────────────────────────────────
    assert order.status == 'completed'
    assert order.total == 30.00  # (10 × 2) + (2 × 5)
    assert len(order.items) == 2
    
    # Vérifier stock déduit
    updated_product1 = db.get_product(1)
    assert updated_product1.stock == 98  # 100 - 2
    
    updated_product2 = db.get_product(2)
    assert updated_product2.stock == 45  # 50 - 5

"""
[IDEE] PROBLÈME : ARRANGE trop long

Solution : Extraire en fixtures ou helpers


REFACTORING AVEC FIXTURES
"""

@pytest.fixture
def database():
    """ARRANGE : Database"""
    db = Database(':memory:')
    db.create_tables()
    yield db
    db.close()

@pytest.fixture
def products(database):
    """ARRANGE : Products avec stock"""
    products = [
        Product(id=1, name='Book', price=10.00, stock=100),
        Product(id=2, name='Pen', price=2.00, stock=50)
    ]
    for product in products:
        database.save(product)
    return products

@pytest.fixture
def user(database):
    """ARRANGE : User"""
    user = User(id=1, name='Alice', email='alice@test.com')
    database.save(user)
    return user

@pytest.fixture
def cart_with_items(user, products):
    """ARRANGE : Cart rempli"""
    cart = ShoppingCart(user_id=user.id)
    cart.add_item(products[0], quantity=2)
    cart.add_item(products[1], quantity=5)
    return cart

@pytest.fixture
def order_service(database):
    """ARRANGE : Service"""
    payment_service = MockPaymentService()
    payment_service.set_default_response(success=True)
    return OrderService(database, payment_service)

def test_order_checkout_refactored(order_service, cart_with_items, database):
    """
    COMMENT ? ARRANGE simplifié par fixtures
    POURQUOI ? Focus sur logique du test
    """
    # ARRANGE (fait par fixtures [OK])
    
    # ACT
    order = order_service.checkout(cart_with_items)
    
    # ASSERT
    assert order.status == 'completed'
    assert order.total == 30.00
    assert len(order.items) == 2
    
    # Vérifier effets de bord
    updated_product = database.get_product(1)
    assert updated_product.stock == 98

"""
[OK] Beaucoup plus lisible !


# ══════════════════════════════════════════════════════════════
# AAA vs GIVEN-WHEN-THEN (BDD)
# ══════════════════════════════════════════════════════════════

"""
ÉQUIVALENCE AAA <-> GWT

ARRANGE = GIVEN  (Contexte)
ACT     = WHEN   (Action)
ASSERT  = THEN   (Résultat)


COMPARAISON CÔTE À CÔTE
"""

# Style AAA (TDD)
def test_user_can_login_aaa():
    # Arrange
    user = create_user('alice', 'password123')
    auth_service = AuthService()
    
    # Act
    result = auth_service.login('alice', 'password123')
    
    # Assert
    assert result.success is True
    assert result.session is not None

# Style Given-When-Then (BDD)
def test_user_can_login_gwt():
    # Given a registered user
    user = create_user('alice', 'password123')
    auth_service = AuthService()
    
    # When user attempts to login
    result = auth_service.login('alice', 'password123')
    
    # Then login succeeds
    assert result.success is True
    assert result.session is not None

"""
[IDEE] CHOISIR SON STYLE

AAA : Tests techniques, TDD
GWT : Tests métier, BDD, collaboration


# ══════════════════════════════════════════════════════════════
# ANTI-PATTERNS AAA
# ══════════════════════════════════════════════════════════════

[X] ANTI-PATTERN 1 : ACT multiple
"""

def test_multiple_actions():  # [X] MAUVAIS
    # Arrange
    calculator = Calculator()
    
    # Act - MULTIPLE !
    result1 = calculator.add(2, 3)
    result2 = calculator.subtract(10, 5)
    result3 = calculator.multiply(4, 5)
    
    # Assert
    assert result1 == 5
    assert result2 == 5
    assert result3 == 20

"""
PROBLÈME : Teste 3 choses -> 3 tests séparés

[OK] SOLUTION : Un test = Une action
"""

def test_add():  # [OK] BON
    # Arrange
    calculator = Calculator()
    
    # Act
    result = calculator.add(2, 3)
    
    # Assert
    assert result == 5

def test_subtract():  # [OK] BON
    calculator = Calculator()
    result = calculator.subtract(10, 5)
    assert result == 5

"""
[X] ANTI-PATTERN 2 : ARRANGE dans ASSERT
"""

def test_user_email():  # [X] MAUVAIS
    user_service = UserService()
    result = user_service.create_user('alice@test.com')
    
    # [X] ARRANGE dans ASSERT
    expected_domain = 'test.com'
    assert result.email.split('@')[1] == expected_domain

"""
[OK] SOLUTION : ARRANGE avant ACT
"""

def test_user_email():  # [OK] BON
    # Arrange
    expected_domain = 'test.com'
    user_service = UserService()
    
    # Act
    result = user_service.create_user('alice@test.com')
    
    # Assert
    assert result.email.split('@')[1] == expected_domain

"""
[X] ANTI-PATTERN 3 : ASSERT dans ARRANGE
"""

def test_order_total():  # [X] MAUVAIS
    # Arrange
    order = Order()
    order.add_item(Product('Book', 10.00), quantity=2)
    
    # [X] ASSERT dans ARRANGE
    assert len(order.items) == 1  # Assertion prématurée
    
    # Act
    total = order.calculate_total()
    
    # Assert
    assert total == 20.00

"""
PROBLÈME : Teste setup, pas action

[OK] SOLUTION : ASSERT seulement après ACT
"""

def test_order_total():  # [OK] BON
    # Arrange
    order = Order()
    order.add_item(Product('Book', 10.00), quantity=2)
    
    # Act
    total = order.calculate_total()
    
    # Assert
    assert total == 20.00
    assert len(order.items) == 1  # Si pertinent


# ----------------------------------------------------------------------------
# [SCENARIO] TEST DOUBLES : TYPES ET USAGE
# ----------------------------------------------------------------------------

"""
CONCEPT : REMPLAÇANTS POUR DÉPENDANCES

COMMENT ? 5 types de test doubles

POURQUOI ? Isolation, contrôle, rapidité

QUAND ? Tests unitaires


5 TYPES DE TEST DOUBLES

1. DUMMY    : Objet passé mais jamais utilisé
2. STUB     : Retourne valeurs prédéfinies
3. SPY      : Enregistre appels
4. MOCK     : Vérifie comportement
5. FAKE     : Implémentation simplifiée


ANALOGIE [DEMARRAGE] : CINÉMA

DUMMY  = Figurant (juste présent)
STUB   = Acteur avec texte fixe
SPY    = Caméra cachée (enregistre)
MOCK   = Réalisateur strict (vérifie performance)
FAKE   = Décor (ressemble au vrai mais simplifié)


# ══════════════════════════════════════════════════════════════
# 1. DUMMY : OBJET PLACEHOLDER
# ══════════════════════════════════════════════════════════════

COMMENT ? Objet requis mais non utilisé

POURQUOI ? Satisfaire signature

QUAND ? Paramètre obligatoire mais non pertinent
"""

class EmailService:
    """
    COMMENT ? Service requis par UserService
    POURQUOI ? Mais pas utilisé dans ce test
    """
    def send_email(self, to, subject, body):
        # Implémentation réelle
        pass

class UserService:
    def __init__(self, email_service):
        self.email_service = email_service
    
    def get_user_count(self):
        """
        COMMENT ? Méthode qui n'utilise PAS email_service
        POURQUOI ? email_service juste dans constructeur
        """
        return len(self.users)

# Test avec DUMMY
def test_user_count():
    """
    COMMENT ? Dummy email_service
    POURQUOI ? Requis mais non utilisé
    """
    # Dummy : objet simple qui ne fait rien
    dummy_email_service = None  # Ou object()
    
    user_service = UserService(dummy_email_service)
    
    count = user_service.get_user_count()
    assert count == 0

"""
[IDEE] DUMMY = Simplest possible

Peut être None, objet vide, ou Mock minimal


# ══════════════════════════════════════════════════════════════
# 2. STUB : RÉPONSES PRÉDÉFINIES
# ══════════════════════════════════════════════════════════════

COMMENT ? Retourne toujours même valeur

POURQUOI ? Contrôler input du code testé

QUAND ? Dépendances avec valeurs de retour
"""

class WeatherAPI:
    """API externe"""
    def get_temperature(self, city):
        # Vraie requête HTTP
        pass

class WeatherStub:
    """
    COMMENT ? Stub de WeatherAPI
    POURQUOI ? Retourner valeur fixe
    QUAND ? Tester sans vraie API
    """
    def get_temperature(self, city):
        # Toujours 20°C
        return 20.0

class WeatherService:
    def __init__(self, weather_api):
        self.weather_api = weather_api
    
    def is_hot(self, city):
        """Chaud si > 25°C"""
        temp = self.weather_api.get_temperature(city)
        return temp > 25

def test_is_hot_with_stub():
    """
    COMMENT ? Utiliser stub
    POURQUOI ? Tester logique is_hot
    """
    # Stub retourne 30°C
    stub = WeatherStub()
    stub.get_temperature = lambda city: 30.0
    
    service = WeatherService(stub)
    
    result = service.is_hot('Paris')
    assert result is True

"""
STUB AVEC PYTEST
"""

def test_is_hot_with_mocker_stub(mocker):
    """
    COMMENT ? Stub avec mocker
    POURQUOI ? Syntaxe pytest
    """
    # Stub
    stub_api = mocker.Mock()
    stub_api.get_temperature.return_value = 30.0
    
    service = WeatherService(stub_api)
    result = service.is_hot('Paris')
    
    assert result is True

"""
[IDEE] STUB = State verification

Focus sur RÉSULTAT, pas appels


# ══════════════════════════════════════════════════════════════
# 3. SPY : ENREGISTREUR D'APPELS
# ══════════════════════════════════════════════════════════════

COMMENT ? Comportement réel + enregistrement

POURQUOI ? Vérifier qu'une méthode est appelée

QUAND ? Besoin du vrai comportement + vérification
"""

class Logger:
    """Logger réel"""
    def log(self, message):
        print(f"LOG: {message}")
        # Écrire dans fichier, etc.

class LoggerSpy:
    """
    COMMENT ? Spy sur Logger
    POURQUOI ? Enregistrer + exécuter vraie logique
    """
    def __init__(self):
        self.calls = []
        self.real_logger = Logger()
    
    def log(self, message):
        # Enregistrer l'appel
        self.calls.append(message)
        
        # Exécuter vraie logique
        self.real_logger.log(message)

class UserService:
    def __init__(self, logger):
        self.logger = logger
    
    def create_user(self, name):
        self.logger.log(f"Creating user: {name}")
        # Logique création
        return User(name)

def test_user_creation_logs():
    """
    COMMENT ? Spy vérifie appels
    POURQUOI ? S'assurer que log est appelé
    """
    spy_logger = LoggerSpy()
    service = UserService(spy_logger)
    
    service.create_user('Alice')
    
    # Vérifier spy
    assert len(spy_logger.calls) == 1
    assert "Creating user: Alice" in spy_logger.calls[0]

"""
SPY AVEC PYTEST-MOCK
"""

def test_user_creation_logs_mocker(mocker):
    """
    COMMENT ? mocker.spy
    POURQUOI ? Garde comportement réel
    """
    logger = Logger()
    spy = mocker.spy(logger, 'log')
    
    service = UserService(logger)
    service.create_user('Alice')
    
    # Vérifier appel
    spy.assert_called_once_with("Creating user: Alice")
    
    # Logger a vraiment fonctionné (console output)

"""
[IDEE] SPY = Comportement réel + vérification


# ══════════════════════════════════════════════════════════════
# 4. MOCK : VÉRIFICATION DE COMPORTEMENT
# ══════════════════════════════════════════════════════════════

COMMENT ? Définir attentes, vérifier appels

POURQUOI ? Behavior verification

QUAND ? Vérifier interactions, pas résultats
"""

class EmailService:
    """Service email réel"""
    def send_welcome_email(self, email, name):
        # Envoyer email réel
        pass

class UserService:
    def __init__(self, email_service):
        self.email_service = email_service
    
    def register_user(self, name, email):
        # Créer user
        user = User(name, email)
        
        # Envoyer email bienvenue
        self.email_service.send_welcome_email(email, name)
        
        return user

def test_registration_sends_email():
    """
    COMMENT ? Mock vérifie comportement
    POURQUOI ? S'assurer que email est envoyé
    QUAND ? Interaction critique
    """
    # Mock
    mock_email_service = Mock()
    
    service = UserService(mock_email_service)
    service.register_user('Alice', 'alice@test.com')
    
    # Vérifier appel (COMPORTEMENT)
    mock_email_service.send_welcome_email.assert_called_once_with(
        'alice@test.com',
        'Alice'
    )

"""
MOCK AVEC VÉRIFICATIONS DÉTAILLÉES
"""

def test_registration_email_detailed():
    """
    COMMENT ? Vérifications multiples
    POURQUOI ? Contrat précis
    """
    mock_email = Mock()
    service = UserService(mock_email)
    
    service.register_user('Alice', 'alice@test.com')
    
    # Vérifications
    assert mock_email.send_welcome_email.called is True
    assert mock_email.send_welcome_email.call_count == 1
    
    # Args de l'appel
    call_args = mock_email.send_welcome_email.call_args
    assert call_args[0][0] == 'alice@test.com'  # Premier arg
    assert call_args[0][1] == 'Alice'            # Deuxième arg

"""
[IDEE] MOCK vs STUB

STUB : Fournit données -> State verification
MOCK : Vérifie appels -> Behavior verification


# ══════════════════════════════════════════════════════════════
# 5. FAKE : IMPLÉMENTATION SIMPLIFIÉE
# ══════════════════════════════════════════════════════════════

COMMENT ? Vrai code fonctionnel mais simplifié

POURQUOI ? Alternative légère à vraie dépendance

QUAND ? Tests d'intégration, complexité modérée
"""

class ProductionDatabase:
    """
    COMMENT ? Vraie DB (PostgreSQL)
    POURQUOI ? Production
    """
    def __init__(self, connection_string):
        self.connection = connect_to_postgres(connection_string)
    
    def save(self, entity):
        # Vraie requête SQL
        self.connection.execute(...)
    
    def find_by_id(self, entity_id):
        # Vraie requête SQL
        return self.connection.query(...)

class FakeDatabase:
    """
    COMMENT ? Fake DB (in-memory dict)
    POURQUOI ? Tests rapides
    QUAND ? Alternative à vraie DB
    """
    def __init__(self):
        self.data = {}  # Dictionnaire au lieu de PostgreSQL
        self.next_id = 1
    
    def save(self, entity):
        """
        COMMENT ? Même interface que vraie DB
        POURQUOI ? Drop-in replacement
        """
        entity.id = self.next_id
        self.data[self.next_id] = entity
        self.next_id += 1
        return entity
    
    def find_by_id(self, entity_id):
        """Même interface"""
        return self.data.get(entity_id)

# Repository utilise interface Database
class UserRepository:
    def __init__(self, database):
        self.db = database
    
    def create_user(self, name, email):
        user = User(name, email)
        return self.db.save(user)
    
    def get_user(self, user_id):
        return self.db.find_by_id(user_id)

# Test avec Fake
def test_user_repository_with_fake():
    """
    COMMENT ? Fake DB pour tests
    POURQUOI ? Rapide, pas de setup DB
    """
    fake_db = FakeDatabase()
    repo = UserRepository(fake_db)
    
    # Créer user
    user = repo.create_user('Alice', 'alice@test.com')
    assert user.id is not None
    
    # Récupérer user
    retrieved = repo.get_user(user.id)
    assert retrieved.name == 'Alice'

"""
EXEMPLE : Fake File System
"""

class ProductionFileSystem:
    """Vrai système de fichiers"""
    def read_file(self, path):
        with open(path, 'r') as f:
            return f.read()
    
    def write_file(self, path, content):
        with open(path, 'w') as f:
            f.write(content)

class FakeFileSystem:
    """
    COMMENT ? Fake file system
    POURQUOI ? Pas besoin de vrais fichiers
    """
    def __init__(self):
        self.files = {}
    
    def read_file(self, path):
        if path not in self.files:
            raise FileNotFoundError(path)
        return self.files[path]
    
    def write_file(self, path, content):
        self.files[path] = content

class ConfigLoader:
    def __init__(self, file_system):
        self.fs = file_system
    
    def load_config(self, path):
        content = self.fs.read_file(path)
        return json.loads(content)

def test_config_loader_with_fake_fs():
    """
    COMMENT ? Test avec fake FS
    POURQUOI ? Pas de fichiers réels
    """
    # Fake FS
    fake_fs = FakeFileSystem()
    fake_fs.write_file('config.json', '{"debug": true}')
    
    loader = ConfigLoader(fake_fs)
    config = loader.load_config('config.json')
    
    assert config['debug'] is True

"""
[IDEE] FAKE vs MOCK

FAKE : Implémentation fonctionnelle simplifiée
MOCK : Objet de vérification (pas de vraie logique)


QUAND UTILISER FAKE ?

[OK] Tests d'intégration
[OK] Alternative à dépendance lourde (DB, FS, réseau)
[OK] Comportement complexe nécessaire

[X] Éviter si :
- Fake devient trop complexe
- Mock/Stub suffisant


# ══════════════════════════════════════════════════════════════
# COMPARAISON DES 5 TEST DOUBLES
# ══════════════════════════════════════════════════════════════

TABLEAU RÉCAPITULATIF
"""

"""
┌────────┬──────────────────────┬────────────────────┬─────────────────┐
│ Type   │ But                  │ Comportement       │ Vérification    │
├────────┼──────────────────────┼────────────────────┼─────────────────┤
│ DUMMY  │ Satisfaire signature │ Aucun              │ Aucune          │
│ STUB   │ Fournir données      │ Retours fixes      │ State           │
│ SPY    │ Enregistrer appels   │ Réel + recording   │ Appels          │
│ MOCK   │ Vérifier appels      │ Aucun/Minimal      │ Behavior        │
│ FAKE   │ Implémentation légère│ Fonctionnel simple │ State           │
└────────┴──────────────────────┴────────────────────┴─────────────────┘


EXEMPLE COMPLET : TOUS LES TYPES
"""

# Système à tester
class OrderProcessor:
    def __init__(self, inventory, payment_gateway, email_service, logger):
        self.inventory = inventory
        self.payment = payment_gateway
        self.email = email_service
        self.logger = logger
    
    def process_order(self, order):
        # Log
        self.logger.log(f"Processing order {order.id}")
        
        # Vérifier stock
        if not self.inventory.check_stock(order.product_id, order.quantity):
            return {'success': False, 'error': 'Out of stock'}
        
        # Paiement
        payment_result = self.payment.charge(order.total)
        if not payment_result.success:
            return {'success': False, 'error': 'Payment failed'}
        
        # Déduire stock
        self.inventory.deduct_stock(order.product_id, order.quantity)
        
        # Email confirmation
        self.email.send_confirmation(order.customer_email, order.id)
        
        return {'success': True, 'order_id': order.id}

# Tests avec différents doubles
def test_order_processing_with_all_doubles(mocker):
    """
    COMMENT ? Utiliser 5 types de doubles
    POURQUOI ? Démonstration complète
    """
    # 1. FAKE : Inventory (implémentation simplifiée)
    fake_inventory = FakeInventory()
    fake_inventory.add_product(product_id=1, stock=10)
    
    # 2. STUB : Payment (retour fixe)
    stub_payment = mocker.Mock()
    stub_payment.charge.return_value = PaymentResult(success=True)
    
    # 3. MOCK : Email (vérifier appel)
    mock_email = mocker.Mock()
    
    # 4. SPY : Logger (enregistrer + exécuter)
    real_logger = Logger()
    spy_logger = mocker.spy(real_logger, 'log')
    
    # Créer processor
    processor = OrderProcessor(
        inventory=fake_inventory,
        payment_gateway=stub_payment,
        email_service=mock_email,
        logger=real_logger
    )
    
    # Créer order
    order = Order(
        id=123,
        product_id=1,
        quantity=2,
        total=20.00,
        customer_email='alice@test.com'
    )
    
    # ACT
    result = processor.process_order(order)
    
    # ASSERT
    
    # Résultat
    assert result['success'] is True
    
    # FAKE : Vérifier stock déduit
    assert fake_inventory.get_stock(1) == 8  # 10 - 2
    
    # STUB : Payment a été appelé (minimal check)
    assert stub_payment.charge.called
    
    # MOCK : Email vérifié précisément
    mock_email.send_confirmation.assert_called_once_with(
        'alice@test.com',
        123
    )
    
    # SPY : Logger a été appelé
    spy_logger.assert_called_with("Processing order 123")

"""
FakeInventory implementation :
"""

class FakeInventory:
    """
    COMMENT ? Fake inventory
    POURQUOI ? Alternative à vraie DB
    """
    def __init__(self):
        self.stock = {}
    
    def add_product(self, product_id, stock):
        self.stock[product_id] = stock
    
    def check_stock(self, product_id, quantity):
        return self.stock.get(product_id, 0) >= quantity
    
    def deduct_stock(self, product_id, quantity):
        if product_id in self.stock:
            self.stock[product_id] -= quantity
    
    def get_stock(self, product_id):
        return self.stock.get(product_id, 0)


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF PATTERNS DE TESTS
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Test-Driven Development (TDD)
[OK] Behavior-Driven Development (BDD)
[OK] Property-Based Testing (Hypothesis)
[OK] Mutation Testing
[OK] Contract Testing
[OK] Snapshot Testing
[OK] Pattern AAA (Arrange-Act-Assert)
[OK] 5 types de Test Doubles


[CLE] POINTS CLÉS

TDD
- RED -> GREEN -> REFACTOR
- Tests d'abord, code ensuite
- Design par tests

BDD
- Gherkin (Given-When-Then)
- Langage naturel
- Collaboration équipe

Property-Based
- Tester propriétés, pas exemples
- Hypothesis génère cas
- Découvre edge cases

AAA
- Arrange : Setup
- Act : Action
- Assert : Vérification

Test Doubles
- Dummy : Placeholder
- Stub : Données fixes
- Spy : Enregistre + exécute
- Mock : Vérifie appels
- Fake : Implémentation simple


[OBJECTIF] QUAND UTILISER CHAQUE PATTERN

TDD : Nouvelles features, logique complexe
BDD : Collaboration, acceptance tests
Property-Based : Algorithmes, propriétés math
Mutation : Améliorer suite de tests
Contract : Microservices, APIs
Snapshot : UI, outputs complexes
AAA : TOUS les tests
Doubles : Isolation, rapidité


[IDEE] BONNES PRATIQUES

1. UN PATTERN À LA FOIS
   - Ne pas tout mélanger
   - Choisir selon contexte

2. SIMPLICITÉ
   - Test double le plus simple possible
   - AAA clair et lisible

3. INTENTION CLAIRE
   - Noms descriptifs
   - Structure évidente

4. ISOLATION
   - Tests indépendants
   - Pas d'effets de bord


-> PROCHAINE ÉTAPE : Chapitre 14 - Tests de Performance

Vous allez apprendre :
- Mesurer performances
- Tests de charge
- Profiling
- Benchmarking
- Optimisation


Voulez-vous continuer avec le Chapitre 14 ? [RAPIDE]
"""

Excellent ! La suite du Chapitre 13 (Patterns AAA et Test Doubles) est maintenant complète avec :

[OK] **Pattern AAA détaillé** (Arrange-Act-Assert)
[OK] **5 types de Test Doubles** (Dummy, Stub, Spy, Mock, Fake)
[OK] **Exemples ultra-détaillés** pour chaque concept
[OK] **Comparaisons et anti-patterns**
[OK] **Framework COMMENT/POURQUOI/QUAND** appliqué partout

Voulez-vous que je continue avec le **Chapitre 14 : Tests de Performance et Charge** ? Ce sera ultra-détaillé avec :
- Benchmarking avec pytest-benchmark
- Tests de charge
- Profiling
- Optimisation basée sur tests

Dites-moi ! [RAPIDE]

Je continue avec le Chapitre 14 sur les Tests de Performance et Charge - ultra-détaillé !

# ============================================================================
# [GUIDE] CHAPITRE 14 : TESTS DE PERFORMANCE ET CHARGE
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Benchmarking avec pytest-benchmark
[OK] Tests de performance unitaires
[OK] Tests de charge (load testing)
[OK] Tests de stress (stress testing)
[OK] Profiling et optimisation
[OK] Métriques de performance
[OK] Détection de régressions
[OK] Tests de scalabilité
"""


# ----------------------------------------------------------------------------
# [RUNNER] INTRODUCTION AUX TESTS DE PERFORMANCE
# ----------------------------------------------------------------------------

"""
CONCEPT : MESURER ET GARANTIR PERFORMANCES

COMMENT ? Chronométrer, mesurer, comparer

POURQUOI ? Éviter régressions de performance

QUAND ? Code critique, APIs, algorithmes


TYPES DE TESTS DE PERFORMANCE

1. BENCHMARK : Mesure temps d'exécution
2. LOAD TEST : Comportement sous charge normale
3. STRESS TEST : Comportement sous charge extrême
4. SPIKE TEST : Pics soudains de charge
5. ENDURANCE : Performance dans la durée
6. SCALABILITY : Croissance avec ressources


ANALOGIE [RACING_CAR] : VOITURE DE COURSE

BENCHMARK  : Vitesse de pointe (temps au tour)
LOAD       : Conduite normale (autoroute)
STRESS     : Limite du moteur (plein régime)
SPIKE      : Accélération brutale (0-100 km/h)
ENDURANCE  : Course de 24h (tient-elle ?)
SCALABILITY: Ajouter turbo (plus rapide ?)


MÉTRIQUES CLÉS

1. LATENCY (Latence)
   - Temps de réponse
   - Percentiles (p50, p95, p99)

2. THROUGHPUT (Débit)
   - Requêtes par seconde (RPS)
   - Opérations par seconde

3. RESOURCE USAGE
   - CPU
   - Mémoire
   - I/O disque
   - Réseau

4. CONCURRENCY
   - Nombre d'utilisateurs simultanés
   - Connexions parallèles


# ----------------------------------------------------------------------------
# [GRAPHIQUE] BENCHMARKING AVEC pytest-benchmark
# ----------------------------------------------------------------------------

"""
BIBLIOTHÈQUE : pytest-benchmark

COMMENT ? Mesurer temps d'exécution précis

POURQUOI ? Détecter régressions

QUAND ? Fonctions critiques, algorithmes
"""

pip install pytest-benchmark

"""
# ══════════════════════════════════════════════════════════════
# PREMIER BENCHMARK
# ══════════════════════════════════════════════════════════════

COMMENT ? Fixture benchmark
"""

def test_my_function_benchmark(benchmark):
    """
    COMMENT ? benchmark fixture
    POURQUOI ? Mesurer performances
    QUAND ? Code critique
    
    Args:
        benchmark: Fixture pytest-benchmark
    """
    # Fonction à benchmarker
    def my_function():
        return sum(range(10000))
    
    # Exécuter benchmark
    result = benchmark(my_function)
    
    # Vérifier résultat (optionnel)
    assert result == 49995000

"""
Exécution :
pytest test_benchmark.py -v

Output :
-------------------------------- benchmark: 1 tests --------------------------------
Name (time in us)              Min        Max       Mean     StdDev    Median
--------------------------------------------------------------------------------
test_my_function_benchmark   156.20    298.40    168.34     12.45    165.30
--------------------------------------------------------------------------------


[IDEE] DÉCRYPTAGE OUTPUT

Min    : Temps minimum observé
Max    : Temps maximum observé
Mean   : Temps moyen
StdDev : Écart-type (stabilité)
Median : Médiane (valeur centrale)
Rounds : Nombre d'exécutions

Unités : us (microsecondes), ms (millisecondes), s (secondes)


# ══════════════════════════════════════════════════════════════
# BENCHMARK AVEC ARGUMENTS
# ══════════════════════════════════════════════════════════════

COMMENT ? Passer arguments à la fonction
"""

def fibonacci(n):
    """
    COMMENT ? Calcul Fibonacci
    POURQUOI ? Fonction à benchmarker
    """
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

def test_fibonacci_benchmark(benchmark):
    """
    COMMENT ? Benchmark avec argument
    POURQUOI ? Tester performance selon input
    """
    # Benchmarker fibonacci(20)
    result = benchmark(fibonacci, 20)
    
    assert result == 6765

"""
MULTIPLE BENCHMARKS : Comparer
"""

def fibonacci_iterative(n):
    """
    COMMENT ? Fibonacci itératif
    POURQUOI ? Alternative plus rapide
    """
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(n - 1):
        a, b = b, a + b
    return b

def test_fibonacci_recursive(benchmark):
    """Benchmark version récursive"""
    result = benchmark(fibonacci, 20)
    assert result == 6765

def test_fibonacci_iterative(benchmark):
    """Benchmark version itérative"""
    result = benchmark(fibonacci_iterative, 20)
    assert result == 6765

"""
Exécution :
pytest test_fibonacci.py -v

Output :
--------------------------- benchmark: 2 tests ---------------------------
Name (time in ms)                    Min      Max     Mean   Median
----------------------------------------------------------------------
test_fibonacci_iterative           0.0012   0.0024   0.0015   0.0014
test_fibonacci_recursive           3.45     4.12     3.68     3.62
----------------------------------------------------------------------

[OK] Itératif est ~2400× plus rapide !


# ══════════════════════════════════════════════════════════════
# BENCHMARK AVEC SETUP
# ══════════════════════════════════════════════════════════════

COMMENT ? Setup avant chaque round
"""

def test_list_operations_benchmark(benchmark):
    """
    COMMENT ? Benchmark avec setup
    POURQUOI ? Préparer données avant mesure
    """
    # Setup (non chronométré)
    def setup():
        return list(range(10000)), []
    
    # Fonction à benchmarker
    def run(data, result):
        source, target = data
        target.extend(source)
    
    # Benchmark avec setup
    benchmark.pedantic(run, setup=setup, rounds=100)

"""
[IDEE] benchmark.pedantic

Contrôle précis du benchmark
- setup : Préparation (non chronométrée)
- rounds : Nombre d'exécutions
- iterations : Itérations par round
- warmup_rounds : Rounds de chauffe


# ══════════════════════════════════════════════════════════════
# GROUPES DE BENCHMARKS
# ══════════════════════════════════════════════════════════════

COMMENT ? Comparer groupes
"""

import pytest

# Algorithmes de tri
def bubble_sort(arr):
    """Tri à bulles"""
    arr = arr.copy()
    n = len(arr)
    for i in range(n):
        for j in range(0, n - i - 1):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
    return arr

def quick_sort(arr):
    """Tri rapide"""
    if len(arr) <= 1:
        return arr
    pivot = arr[len(arr) // 2]
    left = [x for x in arr if x < pivot]
    middle = [x for x in arr if x == pivot]
    right = [x for x in arr if x > pivot]
    return quick_sort(left) + middle + quick_sort(right)

def python_sort(arr):
    """Tri Python natif"""
    return sorted(arr)

# Fixture pour données
@pytest.fixture
def random_data():
    """Données aléatoires"""
    import random
    return [random.randint(1, 1000) for _ in range(100)]

# Benchmarks groupés
@pytest.mark.benchmark(group="sorting")
def test_bubble_sort(benchmark, random_data):
    """
    COMMENT ? Groupe "sorting"
    POURQUOI ? Comparer algorithmes de tri
    """
    benchmark(bubble_sort, random_data)

@pytest.mark.benchmark(group="sorting")
def test_quick_sort(benchmark, random_data):
    benchmark(quick_sort, random_data)

@pytest.mark.benchmark(group="sorting")
def test_python_sort(benchmark, random_data):
    benchmark(python_sort, random_data)

"""
Exécution :
pytest test_sorting.py -v --benchmark-group-by=group

Output :
------------------ benchmark 'sorting': 3 tests ------------------
Name (time in us)          Min       Max      Mean    Median
---------------------------------------------------------------
test_python_sort          8.50     12.30     9.20     8.90
test_quick_sort         125.40    156.20   135.80   132.10
test_bubble_sort       2456.70   2891.30  2623.45  2598.20
---------------------------------------------------------------

[OK] Python sort est le plus rapide !


# ══════════════════════════════════════════════════════════════
# SAUVEGARDER ET COMPARER RÉSULTATS
# ══════════════════════════════════════════════════════════════

COMMENT ? Détecter régressions
"""

# Sauvegarder baseline
pytest test_benchmark.py --benchmark-save=baseline

# Plus tard : Comparer
pytest test_benchmark.py --benchmark-compare=baseline

"""
Output si régression :
------- benchmark 'group': 2 tests -------
Name                      Mean       
--------------------------------------
test_function (NOW)      156.2us   
test_function (baseline) 145.3us   
^ 7.5% slower [ATTENTION]


# ══════════════════════════════════════════════════════════════
# CONFIGURATION AVANCÉE
# ══════════════════════════════════════════════════════════════

COMMENT ? pytest.ini configuration
"""

# pytest.ini
"""
[pytest]
addopts = 
    --benchmark-only
    --benchmark-autosave
    --benchmark-min-rounds=100
    --benchmark-warmup=on
    --benchmark-disable-gc
"""

"""
OPTIONS UTILES

--benchmark-only              : Seulement benchmarks
--benchmark-skip              : Skip benchmarks
--benchmark-autosave          : Sauvegarder auto
--benchmark-min-rounds=N      : Minimum N rounds
--benchmark-warmup=on         : Chauffe avant mesure
--benchmark-disable-gc        : Désactiver GC
--benchmark-sort=mean         : Trier par moyenne
--benchmark-columns=mean,min  : Colonnes affichées
--benchmark-histogram=output  : Générer histogramme


# ══════════════════════════════════════════════════════════════
# EXEMPLE COMPLET : API PERFORMANCE
# ══════════════════════════════════════════════════════════════
"""

# api.py
import time
import random

class UserAPI:
    """
    COMMENT ? API utilisateur
    POURQUOI ? Tester performances
    """
    
    def __init__(self, cache_enabled=False):
        self.cache = {} if cache_enabled else None
        self.users_db = self._init_db()
    
    def _init_db(self):
        """Simuler base de données"""
        return {
            i: {'id': i, 'name': f'User {i}', 'email': f'user{i}@test.com'}
            for i in range(1, 10001)
        }
    
    def get_user(self, user_id):
        """
        COMMENT ? Récupérer user
        POURQUOI ? Avec ou sans cache
        """
        # Check cache
        if self.cache is not None and user_id in self.cache:
            return self.cache[user_id]
        
        # Simuler latence DB
        time.sleep(0.001)
        
        # Récupérer de DB
        user = self.users_db.get(user_id)
        
        # Mettre en cache
        if self.cache is not None and user:
            self.cache[user_id] = user
        
        return user
    
    def search_users(self, name_query):
        """Rechercher users"""
        time.sleep(0.005)  # Simuler query complexe
        
        results = [
            user for user in self.users_db.values()
            if name_query.lower() in user['name'].lower()
        ]
        return results[:10]  # Limit 10

# tests/test_api_performance.py
import pytest
from api import UserAPI

@pytest.fixture
def api_no_cache():
    """API sans cache"""
    return UserAPI(cache_enabled=False)

@pytest.fixture
def api_with_cache():
    """API avec cache"""
    return UserAPI(cache_enabled=True)

# Benchmark : get_user sans cache
@pytest.mark.benchmark(group="get_user")
def test_get_user_no_cache(benchmark, api_no_cache):
    """
    COMMENT ? Benchmark sans cache
    POURQUOI ? Baseline performance
    """
    benchmark(api_no_cache.get_user, 5000)

# Benchmark : get_user avec cache (premier appel)
@pytest.mark.benchmark(group="get_user")
def test_get_user_cache_miss(benchmark, api_with_cache):
    """
    COMMENT ? Cache miss (premier appel)
    POURQUOI ? Comparer avec no cache
    """
    benchmark(api_with_cache.get_user, 5000)

# Benchmark : get_user avec cache (cache hit)
@pytest.mark.benchmark(group="get_user")
def test_get_user_cache_hit(benchmark, api_with_cache):
    """
    COMMENT ? Cache hit (appel répété)
    POURQUOI ? Mesurer bénéfice cache
    """
    # Primer le cache
    api_with_cache.get_user(5000)
    
    # Benchmarker cache hit
    benchmark(api_with_cache.get_user, 5000)

# Benchmark : search_users
def test_search_users_benchmark(benchmark, api_no_cache):
    """Benchmark recherche"""
    benchmark(api_no_cache.search_users, "User 1")

"""
Exécution :
pytest tests/test_api_performance.py -v --benchmark-group-by=group

Output :
-------------- benchmark 'get_user': 3 tests --------------
Name (time in ms)              Min      Max     Mean   
-------------------------------------------------------
test_get_user_cache_hit       0.001   0.002   0.0012  [OK] Plus rapide
test_get_user_cache_miss      1.02    1.15    1.08    
test_get_user_no_cache        1.01    1.14    1.07    
-------------------------------------------------------

[OK] Cache hit est ~900× plus rapide !


# ----------------------------------------------------------------------------
# [HOT] TESTS DE CHARGE (LOAD TESTING)
# ----------------------------------------------------------------------------

"""
CONCEPT : TESTER SOUS CHARGE RÉALISTE

COMMENT ? Simuler utilisateurs multiples

POURQUOI ? Vérifier comportement production

QUAND ? APIs, web apps, services


OUTILS : Locust
"""

pip install locust

"""
# ══════════════════════════════════════════════════════════════
# LOCUST : FRAMEWORK DE LOAD TESTING
# ══════════════════════════════════════════════════════════════

COMMENT ? Définir comportement utilisateur
"""

# locustfile.py
from locust import HttpUser, task, between

class WebsiteUser(HttpUser):
    """
    COMMENT ? Simuler utilisateur
    POURQUOI ? Load testing
    
    Attributes:
        wait_time: Temps d'attente entre requêtes
    """
    wait_time = between(1, 3)  # 1-3 secondes entre actions
    
    @task(3)  # Poids 3 (exécuté 3× plus souvent)
    def view_home(self):
        """
        COMMENT ? Visiter page d'accueil
        POURQUOI ? Action fréquente
        """
        self.client.get("/")
    
    @task(2)  # Poids 2
    def view_user_profile(self):
        """Visiter profil utilisateur"""
        user_id = random.randint(1, 1000)
        self.client.get(f"/users/{user_id}")
    
    @task(1)  # Poids 1 (moins fréquent)
    def create_user(self):
        """Créer utilisateur"""
        self.client.post("/users", json={
            "name": "Test User",
            "email": "test@example.com"
        })
    
    def on_start(self):
        """
        COMMENT ? Au démarrage de l'utilisateur
        POURQUOI ? Login, setup
        """
        # Simuler login
        self.client.post("/login", json={
            "username": "testuser",
            "password": "testpass"
        })

"""
Exécution :
locust -f locustfile.py --host=http://localhost:5000

Interface web : http://localhost:8089

Configuration :
- Number of users: 100
- Spawn rate: 10 users/second
- Host: http://localhost:5000

Start swarming!


RÉSULTATS LOCUST

Type    Name              # Requests   # Fails   Median   Average   Min   Max
------------------------------------------------------------------------
GET     /                     1000        0       45 ms    52 ms    12    450
GET     /users/:id             667        0       65 ms    78 ms    23    567
POST    /users                 333        2      123 ms   145 ms    45    890
POST    /login                 100        0       89 ms    95 ms    34    234
------------------------------------------------------------------------
Total                         2100        2       58 ms    72 ms    12    890

RPS (Requests per second): 35.2


# ══════════════════════════════════════════════════════════════
# LOCUST AVEC PYTEST
# ══════════════════════════════════════════════════════════════

COMMENT ? Intégrer Locust dans pytest
"""

# tests/test_load.py
import pytest
from locust import HttpUser, task
from locust.env import Environment
from locust.stats import stats_printer, stats_history
import gevent

class QuickstartUser(HttpUser):
    """User pour load test"""
    
    @task
    def index(self):
        self.client.get("/")

def test_load_test():
    """
    COMMENT ? Load test dans pytest
    POURQUOI ? Automatiser dans CI
    """
    # Setup Locust environment
    env = Environment(user_classes=[QuickstartUser])
    env.create_local_runner()
    
    # Start test
    env.runner.start(100, spawn_rate=10)
    
    # Exécuter pendant 30 secondes
    gevent.spawn(stats_printer(env.stats))
    gevent.spawn(stats_history, env.runner)
    
    gevent.sleep(30)
    
    # Stop
    env.runner.quit()
    
    # Assertions sur résultats
    stats = env.stats.total
    
    assert stats.num_failures == 0, "Load test had failures"
    assert stats.avg_response_time < 200, "Average response time too high"
    assert stats.total_rps > 10, "RPS too low"

"""
# ══════════════════════════════════════════════════════════════
# TESTS DE CHARGE AVANCÉS
# ══════════════════════════════════════════════════════════════

SCÉNARIOS RÉALISTES
"""

from locust import HttpUser, task, between, SequentialTaskSet

class UserBehavior(SequentialTaskSet):
    """
    COMMENT ? Scénario séquentiel
    POURQUOI ? Simuler parcours utilisateur réel
    """
    
    @task
    def step1_browse_homepage(self):
        """Étape 1 : Page d'accueil"""
        self.client.get("/")
    
    @task
    def step2_view_products(self):
        """Étape 2 : Liste produits"""
        self.client.get("/products")
    
    @task
    def step3_view_product_detail(self):
        """Étape 3 : Détail produit"""
        product_id = random.randint(1, 100)
        self.client.get(f"/products/{product_id}")
    
    @task
    def step4_add_to_cart(self):
        """Étape 4 : Ajouter au panier"""
        self.client.post("/cart/add", json={
            "product_id": random.randint(1, 100),
            "quantity": 1
        })
    
    @task
    def step5_checkout(self):
        """Étape 5 : Checkout"""
        self.client.post("/checkout", json={
            "payment_method": "credit_card"
        })

class EcommerceUser(HttpUser):
    """User e-commerce"""
    tasks = [UserBehavior]
    wait_time = between(2, 5)

"""
# ══════════════════════════════════════════════════════════════
# MÉTRIQUES ET ASSERTIONS
# ══════════════════════════════════════════════════════════════

COMMENT ? Définir SLAs (Service Level Agreements)
"""

def test_api_performance_sla():
    """
    COMMENT ? Vérifier SLAs
    POURQUOI ? Garantir QoS (Quality of Service)
    """
    from locust.env import Environment
    from locust.stats import calculate_response_time_percentile
    
    # ... run load test ...
    
    stats = env.stats.total
    
    # SLA 1 : 99% des requêtes < 500ms
    p99 = calculate_response_time_percentile(stats, 0.99)
    assert p99 < 500, f"P99 latency {p99}ms exceeds 500ms SLA"
    
    # SLA 2 : Taux d'erreur < 1%
    error_rate = (stats.num_failures / stats.num_requests) * 100
    assert error_rate < 1.0, f"Error rate {error_rate}% exceeds 1% SLA"
    
    # SLA 3 : Throughput > 100 RPS
    assert stats.total_rps > 100, f"RPS {stats.total_rps} below 100 SLA"

"""
# ----------------------------------------------------------------------------
# [FORCE] TESTS DE STRESS
# ----------------------------------------------------------------------------

"""
CONCEPT : POUSSER JUSQU'À LA LIMITE

COMMENT ? Augmenter charge progressivement

POURQUOI ? Trouver point de rupture

QUAND ? Planifier capacité, identifier limites


PATTERN : SPIKE TEST
"""

# locustfile_stress.py
from locust import HttpUser, task, between, events
import time

class StressTestUser(HttpUser):
    """
    COMMENT ? User pour stress test
    POURQUOI ? Tester limites
    """
    wait_time = between(0.1, 0.5)  # Très rapide
    
    @task
    def stress_endpoint(self):
        self.client.get("/api/heavy-operation")

@events.test_start.add_listener
def on_test_start(environment, **kwargs):
    """
    COMMENT ? Hook de démarrage
    POURQUOI ? Configuration stress test
    """
    print("Starting stress test...")
    print("Will increase load every 30 seconds")

# Script pour spike test
"""
# stress_test.sh
#!/bin/bash

echo "Starting spike test..."

# Phase 1 : 10 users (baseline)
locust -f locustfile_stress.py --headless -u 10 -r 10 -t 30s --host=http://localhost:5000

# Phase 2 : 50 users
locust -f locustfile_stress.py --headless -u 50 -r 50 -t 30s --host=http://localhost:5000

# Phase 3 : 100 users
locust -f locustfile_stress.py --headless -u 100 -r 100 -t 30s --host=http://localhost:5000

# Phase 4 : 500 users (stress)
locust -f locustfile_stress.py --headless -u 500 -r 100 -t 30s --host=http://localhost:5000

# Phase 5 : 1000 users (breaking point?)
locust -f locustfile_stress.py --headless -u 1000 -r 200 -t 30s --host=http://localhost:5000

echo "Stress test completed"
"""

"""
# ══════════════════════════════════════════════════════════════
# SOAK TEST (ENDURANCE)
# ══════════════════════════════════════════════════════════════

COMMENT ? Charge constante longue durée

POURQUOI ? Détecter fuites mémoire, dégradation

QUAND ? Avant mise en production
"""

# Soak test : 8 heures
locust -f locustfile.py --headless \
    -u 50 \
    -r 10 \
    -t 8h \
    --host=http://localhost:5000 \
    --csv=soak_test_results

"""
Analyser résultats :
- Latence stable ou croissante ?
- Utilisation mémoire constante ?
- Taux d'erreur stable ?


# ----------------------------------------------------------------------------
# [RECHERCHE] PROFILING ET OPTIMISATION
# ----------------------------------------------------------------------------

"""
CONCEPT : IDENTIFIER GOULOTS D'ÉTRANGLEMENT

COMMENT ? Profiler code

POURQUOI ? Optimiser ce qui compte

QUAND ? Après détection de lenteur


OUTILS DE PROFILING

1. cProfile : Profiler standard Python
2. line_profiler : Profiling ligne par ligne
3. memory_profiler : Profiling mémoire
4. py-spy : Profiler sans instrumentation


# ══════════════════════════════════════════════════════════════
# cProfile : PROFILING STANDARD
# ══════════════════════════════════════════════════════════════
"""

import cProfile
import pstats

def slow_function():
    """
    COMMENT ? Fonction lente à profiler
    POURQUOI ? Identifier hotspots
    """
    total = 0
    for i in range(1000000):
        total += i
    return total

def test_profile_function():
    """
    COMMENT ? Profiler dans test
    POURQUOI ? Analyser performances
    """
    profiler = cProfile.Profile()
    
    # Start profiling
    profiler.enable()
    
    # Code à profiler
    result = slow_function()
    
    # Stop profiling
    profiler.disable()
    
    # Afficher résultats
    stats = pstats.Stats(profiler)
    stats.sort_stats('cumulative')
    stats.print_stats(10)  # Top 10
    
    assert result == 499999500000

"""
Output :
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.042    0.042    0.042    0.042 test.py:5(slow_function)
  1000000    0.035    0.000    0.035    0.000 {built-in method builtins.sum}


# ══════════════════════════════════════════════════════════════
# line_profiler : PROFILING LIGNE PAR LIGNE
# ══════════════════════════════════════════════════════════════
"""

pip install line_profiler

"""
UTILISATION
"""

# app.py
@profile  # Décorateur line_profiler
def process_data(data):
    """
    COMMENT ? Fonction à profiler
    POURQUOI ? Identifier lignes lentes
    """
    # Ligne 1
    filtered = [x for x in data if x > 0]
    
    # Ligne 2
    squared = [x ** 2 for x in filtered]
    
    # Ligne 3
    total = sum(squared)
    
    return total

"""
Exécution :
kernprof -l -v app.py

Output :
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     5         1        125.0    125.0      5.2      filtered = [x for x in data if x > 0]
     8         1       1234.0   1234.0     51.5      squared = [x ** 2 for x in filtered]
    11         1       1040.0   1040.0     43.3      total = sum(squared)

[OK] Ligne 8 (squared) prend 51.5% du temps !


# ══════════════════════════════════════════════════════════════
# memory_profiler : PROFILING MÉMOIRE
# ══════════════════════════════════════════════════════════════
"""

pip install memory_profiler

"""
UTILISATION
"""

from memory_profiler import profile

@profile
def memory_intensive_function():
    """
    COMMENT ? Fonction consommant mémoire
    POURQUOI ? Détecter fuites
    """
    # Créer grosse liste
    big_list = [i for i in range(1000000)]
    
    # Dupliquer
    another_list = big_list.copy()
    
    # Traiter
    result = sum(another_list)
    
    return result

"""
Exécution :
python -m memory_profiler app.py

Output :
Line #    Mem usage    Increment   Line Contents
================================================
     3     38.1 MiB     38.1 MiB   @profile
     4                             def memory_intensive_function():
     6     76.3 MiB     38.2 MiB       big_list = [i for i in range(1000000)]
     9    114.5 MiB     38.2 MiB       another_list = big_list.copy()
    12    114.5 MiB      0.0 MiB       result = sum(another_list)

[OK] Chaque liste consomme ~38 MiB


# ══════════════════════════════════════════════════════════════
# PYTEST-PROFILING : INTÉGRATION PYTEST
# ══════════════════════════════════════════════════════════════
"""

pip install pytest-profiling

"""
UTILISATION
"""

pytest tests/ --profile

"""
Génère :
- prof/combined.prof : Profiling combiné
- Rapports HTML

Visualiser :
snakeviz prof/combined.prof


# ══════════════════════════════════════════════════════════════
# OPTIMISATION BASÉE SUR PROFILING
# ══════════════════════════════════════════════════════════════

EXEMPLE : Optimiser fonction lente
"""

# Version 1 : LENTE
def find_duplicates_slow(items):
    """
    COMMENT ? Trouver doublons (O(n²))
    POURQUOI ? Algorithme naïf
    """
    duplicates = []
    for i, item in enumerate(items):
        for j, other in enumerate(items[i + 1:]):
            if item == other and item not in duplicates:
                duplicates.append(item)
    return duplicates

# Version 2 : OPTIMISÉE
def find_duplicates_fast(items):
    """
    COMMENT ? Trouver doublons (O(n))
    POURQUOI ? Utiliser set
    """
    seen = set()
    duplicates = set()
    
    for item in items:
        if item in seen:
            duplicates.add(item)
        else:
            seen.add(item)
    
    return list(duplicates)

# Benchmark comparaison
@pytest.mark.benchmark(group="duplicates")
def test_find_duplicates_slow(benchmark):
    """Benchmark version lente"""
    data = list(range(1000)) * 2  # 2000 éléments avec doublons
    benchmark(find_duplicates_slow, data)

@pytest.mark.benchmark(group="duplicates")
def test_find_duplicates_fast(benchmark):
    """Benchmark version rapide"""
    data = list(range(1000)) * 2
    benchmark(find_duplicates_fast, data)

"""
Résultats :
------------ benchmark 'duplicates': 2 tests ------------
Name (time in ms)                Min       Max      Mean
---------------------------------------------------------
test_find_duplicates_fast       1.23      2.45     1.56   [OK]
test_find_duplicates_slow     456.78    523.12   489.34   [X]
---------------------------------------------------------

[OK] Version optimisée est ~313× plus rapide !


# ----------------------------------------------------------------------------
# [HAUSSE] TESTS DE SCALABILITÉ
# ----------------------------------------------------------------------------

"""
CONCEPT : CROISSANCE AVEC RESSOURCES

COMMENT ? Mesurer performance vs taille données

POURQUOI ? Vérifier complexité algorithmique

QUAND ? Algorithmes, structures de données


# ══════════════════════════════════════════════════════════════
# TEST DE SCALABILITÉ
# ══════════════════════════════════════════════════════════════
"""

import pytest
import time

@pytest.mark.parametrize("size", [100, 1000, 10000, 100000])
def test_algorithm_scalability(size):
    """
    COMMENT ? Tester avec différentes tailles
    POURQUOI ? Vérifier comportement O(n)
    """
    # Générer données
    data = list(range(size))
    
    # Mesurer temps
    start = time.perf_counter()
    result = process_data(data)
    duration = time.perf_counter() - start
    
    # Log résultat
    print(f"Size: {size}, Time: {duration:.4f}s")
    
    # Assertion performance
    # Pour O(n), temps devrait être linéaire
    expected_max_time = size * 0.00001  # 10 µs par élément
    assert duration < expected_max_time, \
        f"Performance degraded: {duration}s > {expected_max_time}s"

"""
Output :
Size: 100, Time: 0.0012s
Size: 1000, Time: 0.0089s
Size: 10000, Time: 0.0923s
Size: 100000, Time: 0.8956s

[OK] Croissance linéaire confirmée


# ══════════════════════════════════════════════════════════════
# VISUALISATION SCALABILITÉ
# ══════════════════════════════════════════════════════════════
"""

import matplotlib.pyplot as plt

def test_scalability_graph():
    """
    COMMENT ? Générer graphique scalabilité
    POURQUOI ? Visualiser croissance
    """
    sizes = [100, 500, 1000, 5000, 10000, 50000, 100000]
    times = []
    
    for size in sizes:
        data = list(range(size))
        
        start = time.perf_counter()
        process_data(data)
        duration = time.perf_counter() - start
        
        times.append(duration)
    
    # Graphique
    plt.figure(figsize=(10, 6))
    plt.plot(sizes, times, 'bo-', label='Actual')
    plt.xlabel('Data Size')
    plt.ylabel('Time (seconds)')
    plt.title('Algorithm Scalability')
    plt.grid(True)
    plt.legend()
    plt.savefig('scalability.png')
    
    print("Scalability graph saved to scalability.png")

"""
# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 14 : PERFORMANCE COMPLÈTE
# ----------------------------------------------------------------------------

"""
OBJECTIF : Optimiser API e-commerce avec tests de performance


ÉTAPE 1 : CODE À OPTIMISER
"""

# ecommerce_api.py
import time
import random
from typing import List, Dict

class Product:
    def __init__(self, id: int, name: str, price: float, stock: int):
        self.id = id
        self.name = name
        self.price = price
        self.stock = stock

class ProductRepository:
    """
    COMMENT ? Repository produits (VERSION LENTE)
    POURQUOI ? À optimiser
    """
    
    def __init__(self):
        # Générer 10000 produits
        self.products = [
            Product(
                id=i,
                name=f"Product {i}",
                price=random.uniform(10, 1000),
                stock=random.randint(0, 100)
            )
            for i in range(1, 10001)
        ]
    
    def get_product(self, product_id: int) -> Product:
        """
        COMMENT ? Recherche linéaire (LENT)
        POURQUOI ? O(n) - à optimiser
        """
        time.sleep(0.001)  # Simuler DB latency
        
        for product in self.products:
            if product.id == product_id:
                return product
        return None
    
    def search_products(self, name_query: str) -> List[Product]:
        """
        COMMENT ? Recherche textuelle (LENT)
        POURQUOI ? Pas d'index
        """
        time.sleep(0.005)
        
        results = []
        for product in self.products:
            if name_query.lower() in product.name.lower():
                results.append(product)
        return results
    
    def get_products_in_price_range(self, min_price: float, max_price: float) -> List[Product]:
        """Filtrer par prix (LENT)"""
        time.sleep(0.003)
        
        results = []
        for product in self.products:
            if min_price <= product.price <= max_price:
                results.append(product)
        return results

"""
ÉTAPE 2 : TESTS DE PERFORMANCE (AVANT OPTIMISATION)
"""

# tests/test_performance_before.py
import pytest
from ecommerce_api import ProductRepository

@pytest.fixture
def repo():
    """Repository"""
    return ProductRepository()

# ═══ BENCHMARKS ═══

@pytest.mark.benchmark(group="get_product")
def test_get_product_benchmark(benchmark, repo):
    """Benchmark get_product (baseline)"""
    benchmark(repo.get_product, 5000)

@pytest.mark.benchmark(group="search")
def test_search_products_benchmark(benchmark, repo):
    """Benchmark search"""
    benchmark(repo.search_products, "Product 1")

@pytest.mark.benchmark(group="price_range")
def test_price_range_benchmark(benchmark, repo):
    """Benchmark price range"""
    benchmark(repo.get_products_in_price_range, 100, 500)

"""
Exécution :
pytest tests/test_performance_before.py -v --benchmark-only

Output (AVANT optimisation) :
----------------------------- benchmark: 3 tests -----------------------------
Name (time in ms)                    Min       Max      Mean    Median
-----------------------------------------------------------------------
test_get_product_benchmark          5.23      7.45     6.12     5.89   [X] LENT
test_search_products_benchmark     25.67     32.11    28.34    27.89   [X] LENT
test_price_range_benchmark         18.45     24.32    21.23    20.56   [X] LENT


ÉTAPE 3 : OPTIMISATION
"""

# ecommerce_api_optimized.py
class ProductRepositoryOptimized:
    """
    COMMENT ? Version optimisée
    POURQUOI ? Utiliser index, cache
    """
    
    def __init__(self):
        # Générer produits
        self.products = [
            Product(
                id=i,
                name=f"Product {i}",
                price=random.uniform(10, 1000),
                stock=random.randint(0, 100)
            )
            for i in range(1, 10001)
        ]
        
        # [OK] INDEX par ID (dict)
        self.products_by_id = {p.id: p for p in self.products}
        
        # [OK] INDEX par nom (dict de listes)
        self.products_by_name = {}
        for product in self.products:
            key = product.name.lower()
            if key not in self.products_by_name:
                self.products_by_name[key] = []
            self.products_by_name[key].append(product)
        
        # [OK] INDEX par prix (sorted list)
        self.products_sorted_by_price = sorted(
            self.products,
            key=lambda p: p.price
        )
        
        # [OK] CACHE pour recherches
        self.search_cache = {}
        self.price_range_cache = {}
    
    def get_product(self, product_id: int) -> Product:
        """
        COMMENT ? Recherche O(1) avec dict
        POURQUOI ? Index par ID
        """
        time.sleep(0.001)  # DB latency
        return self.products_by_id.get(product_id)
    
    def search_products(self, name_query: str) -> List[Product]:
        """
        COMMENT ? Recherche avec cache
        POURQUOI ? Éviter recherches répétées
        """
        # Check cache
        cache_key = name_query.lower()
        if cache_key in self.search_cache:
            return self.search_cache[cache_key]
        
        time.sleep(0.005)
        
        # Recherche
        query_lower = name_query.lower()
        results = []
        
        for name, products in self.products_by_name.items():
            if query_lower in name:
                results.extend(products)
        
        # Cache
        self.search_cache[cache_key] = results
        
        return results
    
    def get_products_in_price_range(self, min_price: float, max_price: float) -> List[Product]:
        """
        COMMENT ? Binary search sur liste triée
        POURQUOI ? O(log n) au lieu de O(n)
        """
        # Check cache
        cache_key = (min_price, max_price)
        if cache_key in self.price_range_cache:
            return self.price_range_cache[cache_key]
        
        time.sleep(0.003)
        
        # Binary search pour min
        import bisect
        
        # Trouver index de départ
        start_idx = bisect.bisect_left(
            [p.price for p in self.products_sorted_by_price],
            min_price
        )
        
        # Trouver index de fin
        end_idx = bisect.bisect_right(
            [p.price for p in self.products_sorted_by_price],
            max_price
        )
        
        results = self.products_sorted_by_price[start_idx:end_idx]
        
        # Cache
        self.price_range_cache[cache_key] = results
        
        return results

"""
ÉTAPE 4 : TESTS APRÈS OPTIMISATION
"""

# tests/test_performance_after.py
@pytest.fixture
def repo_optimized():
    """Repository optimisé"""
    return ProductRepositoryOptimized()

@pytest.mark.benchmark(group="get_product")
def test_get_product_optimized(benchmark, repo_optimized):
    """Benchmark get_product (optimisé)"""
    benchmark(repo_optimized.get_product, 5000)

@pytest.mark.benchmark(group="search")
def test_search_products_optimized(benchmark, repo_optimized):
    """Benchmark search (optimisé + cache)"""
    # Premier appel (cache miss)
    repo_optimized.search_products("Product 1")
    
    # Benchmark cache hit
    benchmark(repo_optimized.search_products, "Product 1")

@pytest.mark.benchmark(group="price_range")
def test_price_range_optimized(benchmark, repo_optimized):
    """Benchmark price range (optimisé + cache)"""
    # Premier appel
    repo_optimized.get_products_in_price_range(100, 500)
    
    # Benchmark cache hit
    benchmark(repo_optimized.get_products_in_price_range, 100, 500)

"""
Exécution :
pytest tests/test_performance_after.py -v --benchmark-only

Output (APRÈS optimisation) :
----------------------------- benchmark: 3 tests -----------------------------
Name (time in ms)                    Min       Max      Mean    Median
-----------------------------------------------------------------------
test_get_product_optimized          1.02      1.25     1.12     1.08   [OK] 5.5× plus rapide
test_search_products_optimized      0.01      0.03     0.02     0.01   [OK] 1400× plus rapide
test_price_range_optimized          0.01      0.02     0.01     0.01   [OK] 2100× plus rapide


ÉTAPE 5 : TESTS DE CHARGE
"""

# locustfile_ecommerce.py
from locust import HttpUser, task, between

class EcommerceUser(HttpUser):
    """
    COMMENT ? Simuler utilisateur e-commerce
    POURQUOI ? Load testing
    """
    wait_time = between(1, 3)
    
    @task(5)
    def browse_products(self):
        """Parcourir produits (fréquent)"""
        product_id = random.randint(1, 10000)
        self.client.get(f"/products/{product_id}")
    
    @task(3)
    def search_products(self):
        """Rechercher produits"""
        query = random.choice(['Product 1', 'Product 5', 'Product 10'])
        self.client.get(f"/products/search?q={query}")
    
    @task(2)
    def filter_by_price(self):
        """Filtrer par prix"""
        min_p = random.randint(10, 500)
        max_p = min_p + random.randint(100, 500)
        self.client.get(f"/products/filter?min={min_p}&max={max_p}")
    
    @task(1)
    def add_to_cart(self):
        """Ajouter au panier (rare)"""
        self.client.post("/cart/add", json={
            "product_id": random.randint(1, 10000),
            "quantity": 1
        })

"""
Exécution :
locust -f locustfile_ecommerce.py --host=http://localhost:5000

Résultats (version optimisée) :
Type    Name                # Requests   Median   Average   Min   Max   RPS
---------------------------------------------------------------------------
GET     /products/:id           5000      12 ms    15 ms     8    45    167
GET     /products/search        3000       8 ms    10 ms     3    28    100
GET     /products/filter        2000       9 ms    11 ms     4    32     67
POST    /cart/add               1000      25 ms    32 ms    18    89     33
---------------------------------------------------------------------------
Total                          11000      11 ms    14 ms     3    89    367

[OK] 367 RPS avec latence médiane 11ms !


ÉTAPE 6 : COMPARAISON FINALE
"""

"""
┌──────────────────────┬─────────────┬──────────────┬─────────────┐
│ Opération            │ Avant       │ Après        │ Amélioration│
├──────────────────────┼─────────────┼──────────────┼─────────────┤
│ get_product          │ 6.12 ms     │ 1.12 ms      │ 5.5×        │
│ search_products      │ 28.34 ms    │ 0.02 ms      │ 1417×       │
│ price_range_filter   │ 21.23 ms    │ 0.01 ms      │ 2123×       │
│ RPS (charge)         │ ~45         │ ~367         │ 8.2×        │
└──────────────────────┴─────────────┴──────────────┴─────────────┘

[OK] Améliorations spectaculaires !


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 14
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Benchmarking avec pytest-benchmark
[OK] Tests de charge avec Locust
[OK] Tests de stress et spike tests
[OK] Profiling (cProfile, line_profiler, memory_profiler)
[OK] Tests de scalabilité
[OK] Optimisation basée sur métriques
[OK] SLAs et assertions de performance


[CLE] POINTS CLÉS

BENCHMARKING
- pytest-benchmark pour micro-benchmarks
- Comparer algorithmes
- Détecter régressions

LOAD TESTING
- Locust pour simuler utilisateurs
- Scénarios réalistes
- Métriques : RPS, latence, percentiles

PROFILING
- Identifier hotspots
- cProfile pour vue globale
- line_profiler pour détails
- memory_profiler pour mémoire

OPTIMISATION
- Profiler AVANT d'optimiser
- Mesurer impact
- Index, cache, algorithmes


[OBJECTIF] MÉTRIQUES ESSENTIELLES

LATENCE
- p50 (médiane) : Expérience typique
- p95 : 95% des users
- p99 : Pire cas acceptable
- Max : Pire absolu

THROUGHPUT
- RPS : Requêtes/seconde
- Capacité totale

RESOURCES
- CPU : Utilisation processeur
- Memory : Consommation mémoire
- I/O : Disque/réseau


[IDEE] BONNES PRATIQUES

1. BASELINE
   - Établir baseline avant optimisation
   - Comparer avant/après

2. RÉALISME
   - Scénarios utilisateur réels
   - Données représentatives

3. CONTINUOUS
   - CI/CD avec benchmarks
   - Alertes sur régressions

4. CIBLES
   - Définir SLAs clairs
   - p95 < Xms, RPS > Y

5. PROFILER D'ABORD
   - Ne pas optimiser à l'aveugle
   - Données, pas intuition


[ATTENTION] PIÈGES À ÉVITER

1. [X] Optimisation prématurée
   [OK] Profiler puis optimiser

2. [X] Micro-optimisations
   [OK] Focus sur algorithmes

3. [X] Tests irréalistes
   [OK] Scénarios production

4. [X] Ignorer percentiles
   [OK] p95, p99 importants

5. [X] Oublier resources
   [OK] CPU, mémoire, I/O


-> PROCHAINE ÉTAPE : Chapitre 15 - Tests de Sécurité

Vous allez apprendre :
- Tests de vulnérabilités
- Injection SQL, XSS, CSRF
- Tests d'authentification
- Sécurité des dépendances
- OWASP Top 10


Voulez-vous continuer avec le Chapitre 15 ? [VERROUILLE]
"""

Excellent ! Le Chapitre 14 sur les Tests de Performance et Charge est terminé avec :

[OK] **Benchmarking ultra-détaillé** (pytest-benchmark)
[OK] **Load testing** avec Locust
[OK] **Stress testing** et spike tests
[OK] **Profiling** complet (cProfile, line_profiler, memory_profiler)
[OK] **Scalabilité** et optimisation
[OK] **Exercice pratique complet** (API e-commerce optimisée)

Voulez-vous que je continue avec le **Chapitre 15 : Tests de Sécurité** ? Ce sera ultra-détaillé avec :
- Vulnérabilités OWASP Top 10
- SQL injection, XSS, CSRF
- Tests d'authentification
- Sécurité des dépendances
- Fuzzing et penetration testing

Dites-moi ! [VERROUILLE][RAPIDE]

Je continue avec le Chapitre 15 sur les Tests de Sécurité - ultra-détaillé !

# ============================================================================
# [GUIDE] CHAPITRE 15 : TESTS DE SÉCURITÉ
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Tester vulnérabilités OWASP Top 10
[OK] Tests d'injection (SQL, XSS, Command)
[OK] Tests d'authentification et autorisation
[OK] Tests de sécurité des dépendances
[OK] Fuzzing et tests aléatoires
[OK] Tests de cryptographie
[OK] Sécurité des APIs
[OK] Scans de vulnérabilités automatisés
"""


# ----------------------------------------------------------------------------
# [SECURISE] INTRODUCTION À LA SÉCURITÉ DES TESTS
# ----------------------------------------------------------------------------

"""
CONCEPT : SECURITY BY TESTING

COMMENT ? Tester vulnérabilités connues

POURQUOI ? Prévenir failles de sécurité

QUAND ? Chaque commit, déploiement


OWASP TOP 10 (2021)

1. Broken Access Control
2. Cryptographic Failures
3. Injection
4. Insecure Design
5. Security Misconfiguration
6. Vulnerable Components
7. Authentication Failures
8. Software & Data Integrity Failures
9. Security Logging & Monitoring Failures
10. Server-Side Request Forgery (SSRF)


ANALOGIE [EUROPEAN_CASTLE] : CHÂTEAU FORT

Tests fonctionnels = Vérifier que les portes s'ouvrent
Tests sécurité   = Vérifier qu'elles se ferment bien

INJECTION         = Échelle pour passer le mur
ACCESS CONTROL    = Gardes aux portes
CRYPTO FAILURES   = Serrures faibles
AUTH FAILURES     = Fausses identités


PRINCIPES DE SÉCURITÉ

1. DÉFENSE EN PROFONDEUR
   - Plusieurs couches de sécurité
   - Pas de point unique de défaillance

2. MOINDRE PRIVILÈGE
   - Accès minimal nécessaire
   - Pas de droits superflus

3. FAIL SECURE
   - Échouer de manière sécurisée
   - Pas d'info sensible dans erreurs

4. SECURE BY DEFAULT
   - Sécurisé par défaut
   - Opt-in pour fonctions dangereuses


# ----------------------------------------------------------------------------
# [SYRINGE] TESTS D'INJECTION
# ----------------------------------------------------------------------------

"""
CONCEPT : INJECTION DE CODE MALVEILLANT

COMMENT ? Insérer code dans inputs

POURQUOI ? Exploitation la plus courante

QUAND ? Toute entrée utilisateur


TYPES D'INJECTION

1. SQL Injection (SQLi)
2. Cross-Site Scripting (XSS)
3. Command Injection
4. LDAP Injection
5. XML Injection


# ══════════════════════════════════════════════════════════════
# SQL INJECTION
# ══════════════════════════════════════════════════════════════

COMMENT ? Injecter SQL dans requêtes

POURQUOI ? Accéder/modifier données
"""

# Code VULNÉRABLE [X]
class UserRepositoryUnsafe:
    """
    COMMENT ? Repository VULNÉRABLE
    POURQUOI ? Exemple de mauvaise pratique
    """
    
    def __init__(self, db_connection):
        self.db = db_connection
    
    def get_user_by_username(self, username):
        """
        [X] VULNÉRABLE à SQL Injection
        POURQUOI ? Concaténation directe
        """
        query = f"SELECT * FROM users WHERE username = '{username}'"
        cursor = self.db.cursor()
        cursor.execute(query)
        return cursor.fetchone()
    
    def login(self, username, password):
        """
        [X] VULNÉRABLE à SQL Injection
        """
        query = f"""
            SELECT * FROM users 
            WHERE username = '{username}' 
            AND password = '{password}'
        """
        cursor = self.db.cursor()
        cursor.execute(query)
        return cursor.fetchone() is not None

"""
TESTS DE VULNÉRABILITÉ SQL INJECTION
"""

import pytest
import sqlite3

@pytest.fixture
def unsafe_db():
    """
    COMMENT ? Base de test
    POURQUOI ? Tester vulnérabilités
    """
    conn = sqlite3.connect(':memory:')
    cursor = conn.cursor()
    
    # Créer table
    cursor.execute('''
        CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            username TEXT,
            password TEXT,
            role TEXT
        )
    ''')
    
    # Insérer users
    cursor.execute(
        "INSERT INTO users (username, password, role) VALUES (?, ?, ?)",
        ('admin', 'secret123', 'admin')
    )
    cursor.execute(
        "INSERT INTO users (username, password, role) VALUES (?, ?, ?)",
        ('user', 'pass456', 'user')
    )
    
    conn.commit()
    return conn

def test_sql_injection_vulnerability(unsafe_db):
    """
    COMMENT ? Test d'exploitation SQLi
    POURQUOI ? Prouver vulnérabilité
    QUAND ? Audit de sécurité
    """
    repo = UserRepositoryUnsafe(unsafe_db)
    
    # ATTAQUE 1 : Bypass authentication
    # Input: admin' OR '1'='1
    malicious_username = "admin' OR '1'='1"
    malicious_password = "anything"
    
    # [X] Devrait échouer mais RÉUSSIT
    result = repo.login(malicious_username, malicious_password)
    
    # VULNÉRABILITÉ DÉTECTÉE
    assert result is True, "SQL Injection vulnerability detected!"

def test_sql_injection_union_attack(unsafe_db):
    """
    COMMENT ? UNION-based SQLi
    POURQUOI ? Extraire données
    """
    repo = UserRepositoryUnsafe(unsafe_db)
    
    # ATTAQUE : UNION SELECT
    # Extraire tous les users
    malicious_input = "' UNION SELECT id, username, password, role FROM users --"
    
    try:
        result = repo.get_user_by_username(malicious_input)
        # Si réussit, vulnérabilité confirmée
        assert False, "SQL Injection UNION attack successful - VULNERABLE"
    except Exception:
        # Échec attendu si protégé
        pass

"""
CODE SÉCURISÉ [OK]
"""

class UserRepositorySafe:
    """
    COMMENT ? Repository SÉCURISÉ
    POURQUOI ? Requêtes paramétrées
    """
    
    def __init__(self, db_connection):
        self.db = db_connection
    
    def get_user_by_username(self, username):
        """
        [OK] SÉCURISÉ : Parameterized query
        POURQUOI ? Séparation données/code
        """
        query = "SELECT * FROM users WHERE username = ?"
        cursor = self.db.cursor()
        cursor.execute(query, (username,))  # Paramètre
        return cursor.fetchone()
    
    def login(self, username, password):
        """
        [OK] SÉCURISÉ : Prepared statement
        """
        query = """
            SELECT * FROM users 
            WHERE username = ? AND password = ?
        """
        cursor = self.db.cursor()
        cursor.execute(query, (username, password))
        return cursor.fetchone() is not None

"""
TESTS : Version sécurisée
"""

def test_sql_injection_prevented(unsafe_db):
    """
    COMMENT ? Vérifier protection SQLi
    POURQUOI ? Confirmer sécurité
    """
    repo = UserRepositorySafe(unsafe_db)
    
    # Tentative d'injection
    malicious_username = "admin' OR '1'='1"
    malicious_password = "anything"
    
    # [OK] Devrait échouer
    result = repo.login(malicious_username, malicious_password)
    
    # Protection confirmée
    assert result is False, "Login should fail with malicious input"

def test_legitimate_login_still_works(unsafe_db):
    """
    COMMENT ? Vérifier fonctionnement normal
    POURQUOI ? Pas de faux positifs
    """
    repo = UserRepositorySafe(unsafe_db)
    
    # Login légitime
    result = repo.login('admin', 'secret123')
    
    assert result is True, "Legitimate login should work"

"""
PAYLOADS SQLi COMMUNS (pour tests)
"""

SQL_INJECTION_PAYLOADS = [
    # Authentication bypass
    "' OR '1'='1",
    "' OR '1'='1' --",
    "' OR '1'='1' /*",
    "admin' --",
    "admin' #",
    
    # UNION attacks
    "' UNION SELECT NULL--",
    "' UNION SELECT 1,2,3--",
    
    # Boolean-based blind
    "' AND '1'='1",
    "' AND '1'='2",
    
    # Time-based blind
    "'; WAITFOR DELAY '00:00:05'--",
    "'; SELECT SLEEP(5)--",
    
    # Stacked queries
    "'; DROP TABLE users--",
    "'; DELETE FROM users--",
]

@pytest.mark.parametrize("payload", SQL_INJECTION_PAYLOADS)
def test_sql_injection_payloads(unsafe_db, payload):
    """
    COMMENT ? Tester payloads communs
    POURQUOI ? Coverage vulnérabilités
    """
    repo = UserRepositorySafe(unsafe_db)
    
    # Tenter injection
    result = repo.login(payload, "password")
    
    # Toutes devraient échouer
    assert result is False, f"Payload '{payload}' should not bypass authentication"


# ══════════════════════════════════════════════════════════════
# CROSS-SITE SCRIPTING (XSS)
# ══════════════════════════════════════════════════════════════

"""
CONCEPT : Injection JavaScript

COMMENT ? Injecter <script> dans pages

POURQUOI ? Vol de cookies, sessions

QUAND ? Affichage données utilisateur


TYPES XSS

1. REFLECTED : Dans URL/formulaire
2. STORED : Sauvegardé en DB
3. DOM-based : Côté client
"""

# Code VULNÉRABLE [X]
def render_comment_unsafe(comment_text):
    """
    [X] VULNÉRABLE à XSS
    POURQUOI ? Pas d'échappement HTML
    """
    html = f"""
    <div class="comment">
        <p>{comment_text}</p>
    </div>
    """
    return html

# Code SÉCURISÉ [OK]
import html

def render_comment_safe(comment_text):
    """
    [OK] SÉCURISÉ : HTML escaping
    POURQUOI ? Échapper caractères spéciaux
    """
    escaped_text = html.escape(comment_text)
    html_output = f"""
    <div class="comment">
        <p>{escaped_text}</p>
    </div>
    """
    return html_output

"""
TESTS XSS
"""

XSS_PAYLOADS = [
    # Basic XSS
    "<script>alert('XSS')</script>",
    "<script>alert(document.cookie)</script>",
    
    # IMG tag
    "<img src=x onerror=alert('XSS')>",
    
    # Event handlers
    "<body onload=alert('XSS')>",
    "<div onmouseover=alert('XSS')>",
    
    # Encoded
    "<script>alert&#40;'XSS'&#41;</script>",
    
    # SVG
    "<svg/onload=alert('XSS')>",
    
    # JavaScript protocol
    "<a href='javascript:alert(\"XSS\")'>Click</a>",
]

@pytest.mark.parametrize("payload", XSS_PAYLOADS)
def test_xss_vulnerability_unsafe(payload):
    """
    COMMENT ? Détecter XSS
    POURQUOI ? Code vulnérable
    """
    html_output = render_comment_unsafe(payload)
    
    # [X] Payload présent tel quel = VULNÉRABLE
    assert "<script>" in html_output or "onerror=" in html_output, \
        "XSS vulnerability detected"

@pytest.mark.parametrize("payload", XSS_PAYLOADS)
def test_xss_prevented_safe(payload):
    """
    COMMENT ? Vérifier protection XSS
    POURQUOI ? Code sécurisé
    """
    html_output = render_comment_safe(payload)
    
    # [OK] Payload échappé = SÉCURISÉ
    assert "<script>" not in html_output, \
        "Script tags should be escaped"
    assert "onerror=" not in html_output, \
        "Event handlers should be escaped"
    
    # Vérifier échappement
    assert "&lt;script&gt;" in html_output or \
           "alert" in html_output  # Texte brut, pas exécution

"""
XSS : Framework moderne (exemple Flask)
"""

from flask import Flask, render_template_string, Markup

app = Flask(__name__)

# [X] VULNÉRABLE
@app.route('/unsafe/<comment>')
def unsafe_comment(comment):
    """[X] XSS vulnérable"""
    template = f"<div>{comment}</div>"
    return render_template_string(template)

# [OK] SÉCURISÉ (auto-escaping Jinja2)
@app.route('/safe/<comment>')
def safe_comment(comment):
    """[OK] Jinja2 échappe automatiquement"""
    template = "<div>{{ comment }}</div>"
    return render_template_string(template, comment=comment)

def test_flask_xss_protection():
    """
    COMMENT ? Tester auto-escaping Flask
    POURQUOI ? Vérifier configuration
    """
    from flask import Flask
    
    app = Flask(__name__)
    
    with app.test_client() as client:
        # Tenter XSS
        response = client.get('/safe/<script>alert("XSS")</script>')
        
        # Vérifier échappement
        assert b'<script>' not in response.data
        assert b'&lt;script&gt;' in response.data


# ══════════════════════════════════════════════════════════════
# COMMAND INJECTION
# ══════════════════════════════════════════════════════════════

"""
CONCEPT : Injection commandes système

COMMENT ? Exécuter commandes shell

POURQUOI ? Accès système complet

QUAND ? Appels subprocess, shell
"""

import subprocess
import shlex

# [X] VULNÉRABLE
def ping_host_unsafe(hostname):
    """
    [X] VULNÉRABLE à Command Injection
    POURQUOI ? shell=True + input utilisateur
    """
    command = f"ping -c 1 {hostname}"
    result = subprocess.run(command, shell=True, capture_output=True)
    return result.returncode == 0

# [OK] SÉCURISÉ
def ping_host_safe(hostname):
    """
    [OK] SÉCURISÉ
    POURQUOI ? shell=False + liste args
    """
    # Valider hostname
    if not hostname.replace('.', '').replace('-', '').isalnum():
        raise ValueError("Invalid hostname")
    
    # Commande en liste (pas shell)
    command = ['ping', '-c', '1', hostname]
    result = subprocess.run(command, capture_output=True)
    return result.returncode == 0

"""
TESTS Command Injection
"""

def test_command_injection_vulnerability():
    """
    COMMENT ? Détecter command injection
    POURQUOI ? Code vulnérable permet exécution
    """
    # ATTAQUE : Injecter commande
    malicious_input = "google.com; cat /etc/passwd"
    
    # [X] Code vulnérable exécuterait les deux commandes
    # Ne PAS exécuter en vrai !
    # result = ping_host_unsafe(malicious_input)

def test_command_injection_prevented():
    """
    COMMENT ? Vérifier protection
    POURQUOI ? Code sécurisé bloque
    """
    malicious_inputs = [
        "google.com; ls",
        "google.com && whoami",
        "google.com | cat /etc/passwd",
        "$(whoami)",
        "`whoami`",
    ]
    
    for malicious_input in malicious_inputs:
        with pytest.raises(ValueError):
            ping_host_safe(malicious_input)

"""
ALTERNATIVE SÉCURISÉE : shlex
"""

def execute_command_safe(command_args):
    """
    [OK] SÉCURISÉ : shlex.quote
    POURQUOI ? Échapper arguments shell
    """
    # Échapper chaque argument
    escaped_args = [shlex.quote(arg) for arg in command_args]
    
    # Construire commande
    command = ' '.join(escaped_args)
    
    result = subprocess.run(command, shell=True, capture_output=True)
    return result.stdout.decode()


# ----------------------------------------------------------------------------
# [CLE] TESTS D'AUTHENTIFICATION ET AUTORISATION
# ----------------------------------------------------------------------------

"""
CONCEPT : CONTRÔLE D'ACCÈS

COMMENT ? Vérifier identité et permissions

POURQUOI ? Accès seulement aux autorisés

QUAND ? Toute ressource protégée


BROKEN ACCESS CONTROL (OWASP #1)

1. Élévation de privilèges
2. Accès ressources non autorisées
3. Manipulation ID (IDOR)
4. CORS mal configuré


# ══════════════════════════════════════════════════════════════
# TESTS D'AUTHENTIFICATION
# ══════════════════════════════════════════════════════════════
"""

import hashlib
import hmac
import secrets

class AuthService:
    """
    COMMENT ? Service d'authentification
    POURQUOI ? Gérer logins sécurisés
    """
    
    def __init__(self):
        self.users = {}
        self.sessions = {}
    
    def hash_password(self, password, salt=None):
        """
        COMMENT ? Hash sécurisé
        POURQUOI ? Ne jamais stocker plaintext
        """
        if salt is None:
            salt = secrets.token_hex(16)
        
        # PBKDF2 avec SHA-256
        hash_obj = hashlib.pbkdf2_hmac(
            'sha256',
            password.encode(),
            salt.encode(),
            100000  # Iterations
        )
        
        return f"{salt}${hash_obj.hex()}"
    
    def verify_password(self, password, password_hash):
        """Vérifier password"""
        salt = password_hash.split('$')[0]
        expected_hash = self.hash_password(password, salt)
        
        # Timing-safe comparison
        return hmac.compare_digest(expected_hash, password_hash)
    
    def register(self, username, password):
        """
        COMMENT ? Enregistrer user
        POURQUOI ? Avec password hashé
        """
        if username in self.users:
            raise ValueError("Username already exists")
        
        # Valider password strength
        if len(password) < 8:
            raise ValueError("Password too weak")
        
        password_hash = self.hash_password(password)
        self.users[username] = {
            'username': username,
            'password_hash': password_hash,
            'role': 'user'
        }
    
    def login(self, username, password):
        """
        COMMENT ? Login user
        POURQUOI ? Créer session
        """
        if username not in self.users:
            return None
        
        user = self.users[username]
        
        if not self.verify_password(password, user['password_hash']):
            return None
        
        # Créer session token
        session_token = secrets.token_urlsafe(32)
        self.sessions[session_token] = username
        
        return session_token
    
    def logout(self, session_token):
        """Logout"""
        if session_token in self.sessions:
            del self.sessions[session_token]
    
    def get_user_from_session(self, session_token):
        """Récupérer user depuis session"""
        username = self.sessions.get(session_token)
        if username:
            return self.users[username]
        return None

"""
TESTS D'AUTHENTIFICATION
"""

def test_password_hashing():
    """
    COMMENT ? Tester hashing sécurisé
    POURQUOI ? Pas de plaintext stocké
    """
    auth = AuthService()
    
    password = "secure_password123"
    hash1 = auth.hash_password(password)
    hash2 = auth.hash_password(password)
    
    # Salts différents -> Hashes différents
    assert hash1 != hash2, "Hashes should differ due to random salt"
    
    # Vérification fonctionne
    assert auth.verify_password(password, hash1)
    assert auth.verify_password(password, hash2)

def test_weak_password_rejected():
    """
    COMMENT ? Rejeter passwords faibles
    POURQUOI ? Sécurité minimale
    """
    auth = AuthService()
    
    with pytest.raises(ValueError, match="too weak"):
        auth.register("user", "short")

def test_successful_login():
    """
    COMMENT ? Login normal
    POURQUOI ? Fonctionnement de base
    """
    auth = AuthService()
    
    # Register
    auth.register("alice", "secure_password123")
    
    # Login
    token = auth.login("alice", "secure_password123")
    
    assert token is not None
    assert len(token) > 0

def test_failed_login_wrong_password():
    """
    COMMENT ? Login échoué
    POURQUOI ? Mauvais password
    """
    auth = AuthService()
    
    auth.register("alice", "secure_password123")
    
    # Mauvais password
    token = auth.login("alice", "wrong_password")
    
    assert token is None

def test_session_management():
    """
    COMMENT ? Gestion sessions
    POURQUOI ? Vérifier tokens
    """
    auth = AuthService()
    
    auth.register("alice", "secure_password123")
    token = auth.login("alice", "secure_password123")
    
    # Récupérer user depuis session
    user = auth.get_user_from_session(token)
    assert user['username'] == "alice"
    
    # Logout
    auth.logout(token)
    
    # Session invalidée
    user = auth.get_user_from_session(token)
    assert user is None

"""
# ══════════════════════════════════════════════════════════════
# TESTS D'AUTORISATION (Access Control)
# ══════════════════════════════════════════════════════════════
"""

class ResourceService:
    """
    COMMENT ? Service avec contrôle d'accès
    POURQUOI ? Ressources protégées
    """
    
    def __init__(self, auth_service):
        self.auth = auth_service
        self.documents = {}
    
    def create_document(self, session_token, title, content):
        """
        COMMENT ? Créer document
        POURQUOI ? Utilisateur authentifié
        """
        user = self.auth.get_user_from_session(session_token)
        if not user:
            raise PermissionError("Not authenticated")
        
        doc_id = len(self.documents) + 1
        self.documents[doc_id] = {
            'id': doc_id,
            'title': title,
            'content': content,
            'owner': user['username']
        }
        
        return doc_id
    
    def get_document(self, session_token, doc_id):
        """
        COMMENT ? Récupérer document
        POURQUOI ? Seulement si propriétaire
        """
        user = self.auth.get_user_from_session(session_token)
        if not user:
            raise PermissionError("Not authenticated")
        
        if doc_id not in self.documents:
            raise ValueError("Document not found")
        
        document = self.documents[doc_id]
        
        # Vérifier ownership
        if document['owner'] != user['username']:
            raise PermissionError("Access denied")
        
        return document
    
    def delete_document(self, session_token, doc_id):
        """
        COMMENT ? Supprimer document
        POURQUOI ? Seulement propriétaire ou admin
        """
        user = self.auth.get_user_from_session(session_token)
        if not user:
            raise PermissionError("Not authenticated")
        
        if doc_id not in self.documents:
            raise ValueError("Document not found")
        
        document = self.documents[doc_id]
        
        # Admin peut supprimer n'importe quoi
        if user['role'] == 'admin':
            del self.documents[doc_id]
            return True
        
        # User peut supprimer seulement ses docs
        if document['owner'] != user['username']:
            raise PermissionError("Access denied")
        
        del self.documents[doc_id]
        return True

"""
TESTS D'AUTORISATION
"""

@pytest.fixture
def auth_service():
    """Service auth pour tests"""
    auth = AuthService()
    auth.register("alice", "password123")
    auth.register("bob", "password456")
    return auth

@pytest.fixture
def resource_service(auth_service):
    """Service ressources"""
    return ResourceService(auth_service)

def test_create_document_requires_auth(resource_service):
    """
    COMMENT ? Document nécessite auth
    POURQUOI ? Pas d'accès anonyme
    """
    with pytest.raises(PermissionError, match="Not authenticated"):
        resource_service.create_document(
            "invalid_token",
            "Title",
            "Content"
        )

def test_user_can_access_own_document(auth_service, resource_service):
    """
    COMMENT ? User accède à ses docs
    POURQUOI ? Contrôle normal
    """
    # Login Alice
    token = auth_service.login("alice", "password123")
    
    # Créer document
    doc_id = resource_service.create_document(
        token,
        "My Document",
        "Secret content"
    )
    
    # Récupérer document
    doc = resource_service.get_document(token, doc_id)
    
    assert doc['title'] == "My Document"
    assert doc['owner'] == "alice"

def test_user_cannot_access_others_document(auth_service, resource_service):
    """
    COMMENT ? User ne peut pas accéder docs d'autrui
    POURQUOI ? Isolation des données
    """
    # Alice crée document
    alice_token = auth_service.login("alice", "password123")
    doc_id = resource_service.create_document(
        alice_token,
        "Alice's Document",
        "Private"
    )
    
    # Bob tente d'accéder
    bob_token = auth_service.login("bob", "password456")
    
    with pytest.raises(PermissionError, match="Access denied"):
        resource_service.get_document(bob_token, doc_id)

def test_admin_can_delete_any_document(auth_service, resource_service):
    """
    COMMENT ? Admin peut tout supprimer
    POURQUOI ? Privilèges admin
    """
    # Créer admin
    auth_service.register("admin", "admin_password123")
    auth_service.users["admin"]['role'] = 'admin'
    
    # Alice crée document
    alice_token = auth_service.login("alice", "password123")
    doc_id = resource_service.create_document(
        alice_token,
        "Alice's Document",
        "Content"
    )
    
    # Admin le supprime
    admin_token = auth_service.login("admin", "admin_password123")
    result = resource_service.delete_document(admin_token, doc_id)
    
    assert result is True
    assert doc_id not in resource_service.documents

"""
# ══════════════════════════════════════════════════════════════
# INSECURE DIRECT OBJECT REFERENCE (IDOR)
# ══════════════════════════════════════════════════════════════

COMMENT ? Accès via ID prévisibles

POURQUOI ? Pas de vérification ownership

QUAND ? IDs séquentiels + pas d'authz
"""

def test_idor_vulnerability():
    """
    COMMENT ? Détecter IDOR
    POURQUOI ? IDs séquentiels exploitables
    """
    auth = AuthService()
    resource = ResourceService(auth)
    
    # Alice crée 3 documents
    auth.register("alice", "password123")
    alice_token = auth.login("alice", "password123")
    
    doc1 = resource.create_document(alice_token, "Doc 1", "Content 1")
    doc2 = resource.create_document(alice_token, "Doc 2", "Content 2")
    doc3 = resource.create_document(alice_token, "Doc 3", "Content 3")
    
    # IDs sont séquentiels : 1, 2, 3
    assert doc1 == 1
    assert doc2 == 2
    assert doc3 == 3
    
    # Bob peut deviner IDs
    auth.register("bob", "password456")
    bob_token = auth.login("bob", "password456")
    
    # Tenter accès à doc ID 2
    with pytest.raises(PermissionError):
        resource.get_document(bob_token, 2)
    
    # [OK] Protégé par authz, mais IDs prévisibles

"""
SOLUTION IDOR : UUIDs
"""

import uuid

class ResourceServiceSecure:
    """
    COMMENT ? UUIDs au lieu d'IDs séquentiels
    POURQUOI ? IDs non prévisibles
    """
    
    def create_document(self, session_token, title, content):
        user = self.auth.get_user_from_session(session_token)
        if not user:
            raise PermissionError("Not authenticated")
        
        # UUID au lieu de int séquentiel
        doc_id = str(uuid.uuid4())
        
        self.documents[doc_id] = {
            'id': doc_id,
            'title': title,
            'content': content,
            'owner': user['username']
        }
        
        return doc_id

"""
Test avec UUIDs
"""

def test_uuid_ids_not_guessable():
    """
    COMMENT ? UUIDs non prévisibles
    POURQUOI ? Impossible de deviner
    """
    doc_id1 = str(uuid.uuid4())
    doc_id2 = str(uuid.uuid4())
    
    # IDs complètement différents
    assert doc_id1 != doc_id2
    assert len(doc_id1) == 36  # Format UUID
    
    # Impossible de deviner le suivant


# ----------------------------------------------------------------------------
# [VERROUILLE] TESTS DE CRYPTOGRAPHIE
# ----------------------------------------------------------------------------

"""
CONCEPT : CRYPTOGRAPHIC FAILURES (OWASP #2)

COMMENT ? Vérifier chiffrement correct

POURQUOI ? Données sensibles protégées

QUAND ? Passwords, tokens, données sensibles


ERREURS COURANTES

1. Algorithmes faibles (MD5, SHA1)
2. Pas de salt
3. Clés hardcodées
4. ECB mode
5. Pas de HTTPS


# ══════════════════════════════════════════════════════════════
# TESTS DE HASHING
# ══════════════════════════════════════════════════════════════
"""

def test_password_not_stored_plaintext():
    """
    COMMENT ? Vérifier pas de plaintext
    POURQUOI ? Sécurité fondamentale
    """
    auth = AuthService()
    
    password = "my_secret_password"
    auth.register("user", password)
    
    user_data = auth.users["user"]
    
    # Password NE DOIT PAS être stocké en clair
    assert user_data['password_hash'] != password
    assert password not in str(user_data)

def test_password_hash_includes_salt():
    """
    COMMENT ? Vérifier présence salt
    POURQUOI ? Protection rainbow tables
    """
    auth = AuthService()
    
    password = "password123"
    hash1 = auth.hash_password(password)
    hash2 = auth.hash_password(password)
    
    # Même password, hashes différents (salt)
    assert hash1 != hash2
    
    # Format : salt$hash
    assert '$' in hash1
    assert '$' in hash2

def test_timing_safe_password_comparison():
    """
    COMMENT ? Vérifier comparaison timing-safe
    POURQUOI ? Éviter timing attacks
    """
    auth = AuthService()
    
    password = "secure_password"
    hash_correct = auth.hash_password(password)
    
    # Mesurer temps comparaison
    import time
    
    # Comparaison correcte
    start = time.perf_counter()
    result1 = auth.verify_password(password, hash_correct)
    time1 = time.perf_counter() - start
    
    # Comparaison incorrecte
    start = time.perf_counter()
    result2 = auth.verify_password("wrong", hash_correct)
    time2 = time.perf_counter() - start
    
    # Temps SIMILAIRES (timing-safe)
    # Différence devrait être négligeable
    time_diff = abs(time1 - time2)
    
    # Note : Test peut être flaky, juste pour démonstration
    # En pratique, utiliser hmac.compare_digest

"""
# ══════════════════════════════════════════════════════════════
# TESTS DE CHIFFREMENT
# ══════════════════════════════════════════════════════════════
"""

from cryptography.fernet import Fernet

class EncryptionService:
    """
    COMMENT ? Service de chiffrement
    POURQUOI ? Données sensibles
    """
    
    def __init__(self, key=None):
        if key is None:
            key = Fernet.generate_key()
        self.cipher = Fernet(key)
        self.key = key
    
    def encrypt(self, plaintext: str) -> bytes:
        """Chiffrer données"""
        return self.cipher.encrypt(plaintext.encode())
    
    def decrypt(self, ciphertext: bytes) -> str:
        """Déchiffrer données"""
        return self.cipher.decrypt(ciphertext).decode()

"""
TESTS DE CHIFFREMENT
"""

def test_encryption_decryption():
    """
    COMMENT ? Chiffrement/déchiffrement
    POURQUOI ? Fonctionnement de base
    """
    service = EncryptionService()
    
    plaintext = "Sensitive data"
    
    # Chiffrer
    ciphertext = service.encrypt(plaintext)
    
    # Ciphertext différent
    assert ciphertext != plaintext.encode()
    
    # Déchiffrer
    decrypted = service.decrypt(ciphertext)
    
    # Récupérer plaintext
    assert decrypted == plaintext

def test_ciphertext_not_predictable():
    """
    COMMENT ? Ciphertext non prévisible
    POURQUOI ? Même plaintext -> ciphertexts différents
    """
    service = EncryptionService()
    
    plaintext = "Same data"
    
    # Chiffrer 2 fois
    ciphertext1 = service.encrypt(plaintext)
    ciphertext2 = service.encrypt(plaintext)
    
    # Ciphertexts DIFFÉRENTS (IV/nonce aléatoire)
    assert ciphertext1 != ciphertext2

def test_wrong_key_cannot_decrypt():
    """
    COMMENT ? Mauvaise clé = échec
    POURQUOI ? Sécurité cryptographique
    """
    service1 = EncryptionService()
    service2 = EncryptionService()  # Clé différente
    
    plaintext = "Secret"
    ciphertext = service1.encrypt(plaintext)
    
    # Tentative déchiffrement avec mauvaise clé
    with pytest.raises(Exception):  # InvalidToken
        service2.decrypt(ciphertext)

def test_key_not_hardcoded():
    """
    COMMENT ? Clé pas en dur
    POURQUOI ? Sécurité
    """
    # [X] MAUVAIS : Clé hardcodée
    # service = EncryptionService(key=b'hardcoded_key_1234567890')
    
    # [OK] BON : Clé générée ou depuis env
    import os
    
    # Depuis variable env
    key = os.getenv('ENCRYPTION_KEY')
    
    if key:
        service = EncryptionService(key.encode())
    else:
        # Ou générer
        service = EncryptionService()


# ----------------------------------------------------------------------------
# [PACKAGE] SÉCURITÉ DES DÉPENDANCES
# ----------------------------------------------------------------------------

"""
CONCEPT : VULNERABLE COMPONENTS (OWASP #6)

COMMENT ? Scanner dépendances

POURQUOI ? Vulnérabilités connues (CVEs)

QUAND ? Chaque build, régulièrement


OUTILS

1. Safety : Scanner Python packages
2. pip-audit : Audit officiel
3. Snyk : Commercial
4. Dependabot : GitHub


# ══════════════════════════════════════════════════════════════
# SAFETY : SCANNER DE VULNÉRABILITÉS
# ══════════════════════════════════════════════════════════════
"""

pip install safety

"""
UTILISATION
"""

# Scanner dépendances installées
safety check

"""
Output (exemple) :
+==============================================================================+
|                                                                              |
|                               /$$$$$$            /$$                         |
|                              /$$__  $$          | $$                         |
|           /$$$$$$$  /$$$$$$ | $$  \__//$$$$$$  /$$$$$$   /$$   /$$           |
|          /$$_____/ |____  $$| $$$$   /$$__  $$|_  $$_/  | $$  | $$           |
|         |  $$$$$$   /$$$$$$$| $$_/  | $$$$$$$$  | $$    | $$  | $$           |
|          \____  $$ /$$__  $$| $$    | $$_____/  | $$ /$$| $$  | $$           |
|          /$$$$$$$/|  $$$$$$$| $$    |  $$$$$$$  |  $$$$/|  $$$$$$$           |
|         |_______/  \_______/|__/     \_______/   \___/   \____  $$           |
|                                                           /$$  | $$           |
|                                                          |  $$$$$$/           |
|  by pyup.io                                              \______/            |
|                                                                              |
+==============================================================================+

VULNERABILITIES FOUND:

-> Package: requests
   Installed: 2.6.0
   Affected: <2.20.0
   ID: 36810
   Description: Requests library before 2.20.0 sends an HTTP Authorization header to an http URI upon receiving a same-hostname https-to-http redirect
   
-> Package: flask
   Installed: 0.12.0
   Affected: <0.12.3
   ID: 38654
   Description: Flask before 0.12.3 has XSS vulnerability via JSON responses


TESTS AVEC SAFETY
"""

def test_no_known_vulnerabilities():
    """
    COMMENT ? Vérifier dépendances sécurisées
    POURQUOI ? Pas de CVEs connues
    QUAND ? CI/CD
    """
    import subprocess
    
    # Exécuter safety check
    result = subprocess.run(
        ['safety', 'check', '--json'],
        capture_output=True,
        text=True
    )
    
    # Parser résultats
    import json
    vulnerabilities = json.loads(result.stdout)
    
    # Assertion : Aucune vulnérabilité
    assert len(vulnerabilities) == 0, \
        f"Found {len(vulnerabilities)} vulnerabilities in dependencies"

"""
# ══════════════════════════════════════════════════════════════
# PIP-AUDIT : OUTIL OFFICIEL PyPA
# ══════════════════════════════════════════════════════════════
"""

pip install pip-audit

"""
UTILISATION
"""

# Audit
pip-audit

# Format JSON
pip-audit --format json

# Avec fix suggestions
pip-audit --fix

"""
TEST AVEC PIP-AUDIT
"""

def test_pip_audit_no_vulnerabilities():
    """
    COMMENT ? pip-audit dans tests
    POURQUOI ? Détection automatique
    """
    result = subprocess.run(
        ['pip-audit', '--format', 'json'],
        capture_output=True,
        text=True
    )
    
    vulnerabilities = json.loads(result.stdout)
    
    # Compter vulnérabilités
    vuln_count = len(vulnerabilities.get('dependencies', []))
    
    assert vuln_count == 0, \
        f"pip-audit found {vuln_count} vulnerabilities"

"""
# ══════════════════════════════════════════════════════════════
# INTÉGRATION CI/CD
# ══════════════════════════════════════════════════════════════
"""

# .github/workflows/security.yml
"""
name: Security Scan

on:
  push:
    branches: [ main, develop ]
  schedule:
    - cron: '0 0 * * 0'  # Weekly

jobs:
  security:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: '3.11'
    
    - name: Install dependencies
      run: |
        pip install -r requirements.txt
        pip install safety pip-audit
    
    - name: Run Safety check
      run: safety check --json
    
    - name: Run pip-audit
      run: pip-audit --format json
    
    - name: Run Bandit (code security)
      run: |
        pip install bandit
        bandit -r . -f json -o bandit-report.json
    
    - name: Upload security reports
      if: always()
      uses: actions/upload-artifact@v3
      with:
        name: security-reports
        path: |
          bandit-report.json
"""


# ----------------------------------------------------------------------------
# [GAME_DIE] FUZZING
# ----------------------------------------------------------------------------

"""
CONCEPT : TESTS ALÉATOIRES POUR BUGS

COMMENT ? Générer inputs aléatoires

POURQUOI ? Découvrir bugs cachés

QUAND ? Parsers, validateurs, APIs


TYPES DE FUZZING

1. Generation-based : Générer inputs
2. Mutation-based : Modifier inputs valides
3. Coverage-guided : Maximiser couverture


# ══════════════════════════════════════════════════════════════
# FUZZING AVEC HYPOTHESIS
# ══════════════════════════════════════════════════════════════
"""

from hypothesis import given, strategies as st, settings

def validate_email(email):
    """
    COMMENT ? Validator email simple
    POURQUOI ? Tester avec fuzzing
    """
    if '@' not in email:
        raise ValueError("Missing @")
    
    local, domain = email.split('@', 1)
    
    if not local:
        raise ValueError("Empty local part")
    
    if not domain:
        raise ValueError("Empty domain")
    
    if '.' not in domain:
        raise ValueError("Domain must have TLD")
    
    return True

"""
FUZZING : Tester avec inputs aléatoires
"""

@given(st.text())
@settings(max_examples=1000)
def test_email_validator_does_not_crash(text):
    """
    COMMENT ? Fuzzing avec Hypothesis
    POURQUOI ? Découvrir inputs qui crash
    QUAND ? Robustesse
    """
    try:
        validate_email(text)
    except ValueError:
        # Exceptions attendues OK
        pass
    except Exception as e:
        # Autre exception = BUG
        pytest.fail(f"Unexpected exception: {e}")

"""
FUZZING : Stratégies personnalisées
"""

# Stratégie : Emails malformés
malformed_emails = st.one_of(
    st.just(""),  # Vide
    st.just("@"),  # Seulement @
    st.just("test"),  # Pas de @
    st.just("@domain.com"),  # Pas de local
    st.just("test@"),  # Pas de domain
    st.text(min_size=1000, max_size=10000),  # Très long
    st.from_regex(r'[^@]+@[^@]+'),  # Regex basique
)

@given(malformed_emails)
def test_email_validator_rejects_malformed(email):
    """
    COMMENT ? Fuzzing emails invalides
    POURQUOI ? Tous doivent être rejetés
    """
    with pytest.raises(ValueError):
        validate_email(email)

"""
# ══════════════════════════════════════════════════════════════
# ATHERIS : FUZZING COVERAGE-GUIDED
# ══════════════════════════════════════════════════════════════
"""

pip install atheris

"""
EXEMPLE ATHERIS
"""

import atheris
import sys

def parse_json_safely(data):
    """
    COMMENT ? Parser JSON
    POURQUOI ? Fuzzing pour trouver bugs
    """
    import json
    try:
        return json.loads(data)
    except:
        return None

@atheris.instrument_func
def test_one_input(data):
    """
    COMMENT ? Fonction fuzzée
    POURQUOI ? Atheris génère inputs
    """
    fdp = atheris.FuzzedDataProvider(data)
    
    # Générer string aléatoire
    json_string = fdp.ConsumeUnicodeNoSurrogates(fdp.remaining_bytes())
    
    # Tester parsing
    parse_json_safely(json_string)

"""
Exécution :
python atheris_fuzzer.py -atheris_runs=10000


# ----------------------------------------------------------------------------
# [COURS] EXERCICE PRATIQUE 15 : API SÉCURISÉE COMPLÈTE
# ----------------------------------------------------------------------------

"""
OBJECTIF : API REST avec tous les tests de sécurité


ÉTAPE 1 : API AVEC VULNÉRABILITÉS
"""

# vulnerable_api.py
from flask import Flask, request, jsonify
import sqlite3

app = Flask(__name__)

def get_db():
    """Connexion DB"""
    return sqlite3.connect('api.db')

# [X] VULNÉRABLE : SQL Injection
@app.route('/users/<username>')
def get_user(username):
    """[X] SQLi vulnerable"""
    db = get_db()
    cursor = db.cursor()
    
    # Concaténation directe
    query = f"SELECT * FROM users WHERE username = '{username}'"
    cursor.execute(query)
    
    user = cursor.fetchone()
    return jsonify(user) if user else ('Not found', 404)

# [X] VULNÉRABLE : XSS
@app.route('/comment', methods=['POST'])
def post_comment():
    """[X] XSS vulnerable"""
    comment = request.json.get('comment')
    
    # Pas d'échappement
    html = f"<div class='comment'>{comment}</div>"
    
    return html

# [X] VULNÉRABLE : No Authentication
@app.route('/admin/users')
def list_all_users():
    """[X] Pas d'auth"""
    db = get_db()
    cursor = db.cursor()
    cursor.execute("SELECT * FROM users")
    users = cursor.fetchall()
    return jsonify(users)

# [X] VULNÉRABLE : IDOR
@app.route('/documents/<int:doc_id>')
def get_document(doc_id):
    """[X] IDOR - pas de vérification ownership"""
    db = get_db()
    cursor = db.cursor()
    cursor.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
    doc = cursor.fetchone()
    return jsonify(doc) if doc else ('Not found', 404)

"""
ÉTAPE 2 : TESTS DE SÉCURITÉ
"""

# tests/test_security_vulnerabilities.py
import pytest
from flask import Flask

def test_sql_injection_attack(client):
    """
    COMMENT ? Tester SQLi
    POURQUOI ? Détecter vulnérabilité
    """
    # Payload SQLi
    malicious_input = "admin' OR '1'='1"
    
    response = client.get(f'/users/{malicious_input}')
    
    # Si réussit, vulnérable
    # Test devrait échouer ici si non protégé

def test_xss_attack(client):
    """Tester XSS"""
    payload = "<script>alert('XSS')</script>"
    
    response = client.post('/comment', json={'comment': payload})
    
    # Vérifier si script présent
    assert b'<script>' not in response.data, "XSS vulnerability"

def test_no_authentication_required(client):
    """Tester endpoint admin sans auth"""
    response = client.get('/admin/users')
    
    # Devrait être 401/403, pas 200
    assert response.status_code != 200, "Admin endpoint accessible without auth"

def test_idor_vulnerability(client):
    """Tester IDOR"""
    # User A crée document (ID 1)
    # User B accède ID 1
    # Ne devrait PAS fonctionner

"""
ÉTAPE 3 : API SÉCURISÉE
"""

# secure_api.py
from flask import Flask, request, jsonify, g
from functools import wraps
import sqlite3

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key'

# Auth service
auth_service = AuthService()

def require_auth(f):
    """
    COMMENT ? Décorateur authentification
    POURQUOI ? Protéger endpoints
    """
    @wraps(f)
    def decorated_function(*args, **kwargs):
        token = request.headers.get('Authorization')
        
        if not token:
            return jsonify({'error': 'No token'}), 401
        
        user = auth_service.get_user_from_session(token)
        
        if not user:
            return jsonify({'error': 'Invalid token'}), 401
        
        g.current_user = user
        return f(*args, **kwargs)
    
    return decorated_function

# [OK] SÉCURISÉ : Parameterized query
@app.route('/users/<username>')
@require_auth
def get_user_secure(username):
    """[OK] SQL injection protected"""
    db = get_db()
    cursor = db.cursor()
    
    # Parameterized query
    cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
    
    user = cursor.fetchone()
    return jsonify(user) if user else ('Not found', 404)

# [OK] SÉCURISÉ : HTML escaping
@app.route('/comment', methods=['POST'])
@require_auth
def post_comment_secure():
    """[OK] XSS protected"""
    import html
    
    comment = request.json.get('comment')
    
    # Échapper HTML
    safe_comment = html.escape(comment)
    
    html_output = f"<div class='comment'>{safe_comment}</div>"
    
    return html_output

# [OK] SÉCURISÉ : Authentication required
@app.route('/admin/users')
@require_auth
def list_all_users_secure():
    """[OK] Auth + authz"""
    # Vérifier role admin
    if g.current_user['role'] != 'admin':
        return jsonify({'error': 'Forbidden'}), 403
    
    db = get_db()
    cursor = db.cursor()
    cursor.execute("SELECT * FROM users")
    users = cursor.fetchall()
    return jsonify(users)

# [OK] SÉCURISÉ : Ownership check
@app.route('/documents/<doc_id>')
@require_auth
def get_document_secure(doc_id):
    """[OK] IDOR protected"""
    db = get_db()
    cursor = db.cursor()
    cursor.execute(
        "SELECT * FROM documents WHERE id = ? AND owner = ?",
        (doc_id, g.current_user['username'])
    )
    
    doc = cursor.fetchone()
    return jsonify(doc) if doc else ('Forbidden', 403)

"""
ÉTAPE 4 : TESTS COMPLETS
"""

# tests/test_secure_api.py
def test_authentication_required_all_endpoints(client):
    """
    COMMENT ? Vérifier auth sur tous endpoints
    POURQUOI ? Aucun endpoint public non intentionnel
    """
    endpoints = [
        '/users/test',
        '/comment',
        '/admin/users',
        '/documents/1'
    ]
    
    for endpoint in endpoints:
        response = client.get(endpoint)
        
        assert response.status_code == 401, \
            f"Endpoint {endpoint} should require auth"

def test_sql_injection_prevented(client, auth_token):
    """SQLi protégé"""
    headers = {'Authorization': auth_token}
    
    payload = "admin' OR '1'='1"
    response = client.get(f'/users/{payload}', headers=headers)
    
    # Ne devrait PAS retourner tous les users
    # Devrait chercher littéralement "admin' OR '1'='1"

def test_xss_prevented(client, auth_token):
    """XSS protégé"""
    headers = {'Authorization': auth_token}
    
    payload = "<script>alert('XSS')</script>"
    response = client.post(
        '/comment',
        json={'comment': payload},
        headers=headers
    )
    
    # Script échappé
    assert b'&lt;script&gt;' in response.data
    assert b'<script>' not in response.data

def test_admin_endpoint_requires_admin_role(client, user_token):
    """Admin endpoint nécessite role admin"""
    headers = {'Authorization': user_token}  # User normal
    
    response = client.get('/admin/users', headers=headers)
    
    assert response.status_code == 403

def test_idor_prevented(client):
    """IDOR protégé"""
    # User A crée document
    alice_token = create_user_and_login(client, 'alice')
    doc_id = create_document(client, alice_token, 'Private doc')
    
    # User B tente accès
    bob_token = create_user_and_login(client, 'bob')
    
    response = client.get(
        f'/documents/{doc_id}',
        headers={'Authorization': bob_token}
    )
    
    assert response.status_code == 403

"""
ÉTAPE 5 : SCAN AUTOMATISÉ
"""

def test_run_security_scans():
    """
    COMMENT ? Scans automatisés
    POURQUOI ? Détection continue
    """
    # Safety check
    result = subprocess.run(
        ['safety', 'check', '--json'],
        capture_output=True
    )
    
    vulnerabilities = json.loads(result.stdout)
    assert len(vulnerabilities) == 0
    
    # Bandit check
    result = subprocess.run(
        ['bandit', '-r', '.', '-f', 'json'],
        capture_output=True
    )
    
    report = json.loads(result.stdout)
    high_severity = [
        issue for issue in report.get('results', [])
        if issue['issue_severity'] == 'HIGH'
    ]
    
    assert len(high_severity) == 0, \
        f"Found {len(high_severity)} high severity issues"


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 15
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] OWASP Top 10 et tests associés
[OK] Tests d'injection (SQL, XSS, Command)
[OK] Tests d'authentification et autorisation
[OK] Contrôle d'accès et IDOR
[OK] Tests de cryptographie
[OK] Sécurité des dépendances (Safety, pip-audit)
[OK] Fuzzing avec Hypothesis et Atheris
[OK] API sécurisée complète


[CLE] POINTS CLÉS

INJECTION
- SQL : Parameterized queries
- XSS : HTML escaping
- Command : shell=False + validation

AUTHENTICATION
- Hash passwords (PBKDF2, bcrypt)
- Salt unique
- Timing-safe comparison
- Session management

AUTHORIZATION
- Vérifier ownership
- Role-based access
- Moindre privilège

CRYPTOGRAPHIE
- Pas d'algorithmes faibles
- Clés aléatoires
- Pas de hardcoding

DÉPENDANCES
- Scanner régulièrement
- CI/CD integration
- Mise à jour proactive


[IDEE] BONNES PRATIQUES

1. DÉFENSE EN PROFONDEUR
   - Multiples couches sécurité
   - Validation input + output
   - Auth + Authz

2. SECURE BY DEFAULT
   - Sécurisé par défaut
   - Opt-in pour dangéreux
   - Fail secure

3. TESTS AUTOMATISÉS
   - CI/CD security scans
   - Fuzzing régulier
   - Dependency checks

4. PRINCIPE MOINDRE PRIVILÈGE
   - Accès minimal
   - Pas de droits superflus
   - Isolation

5. LOGGING ET MONITORING
   - Logger tentatives
   - Alertes anomalies
   - Audit trail


[ATTENTION] VULNÉRABILITÉS CRITIQUES

1. [X] SQL Injection
   [OK] Parameterized queries

2. [X] XSS
   [OK] HTML escaping

3. [X] Command Injection
   [OK] shell=False + validation

4. [X] Broken Access Control
   [OK] Auth + Authz checks

5. [X] Plaintext passwords
   [OK] Hash + salt

6. [X] Vulnerable dependencies
   [OK] Regular scans

7. [X] IDOR
   [OK] Ownership verification

8. [X] Hardcoded secrets
   [OK] Environment variables


[SECURITE] CHECKLIST SÉCURITÉ

[ ] Toutes entrées validées
[ ] Queries paramétrées
[ ] Output échappé/encodé
[ ] Authentication sur endpoints sensibles
[ ] Authorization vérifiée
[ ] Passwords hashés + salted
[ ] Pas de secrets hardcodés
[ ] Dépendances scannées
[ ] HTTPS en production
[ ] Logs sécurité activés
[ ] Rate limiting
[ ] CSRF protection
[ ] Security headers


-> PROCHAINE ÉTAPE : Chapitre 16 - Tests de Données et ETL

Vous allez apprendre :
- Tests de pipelines de données
- Validation de données
- Tests ETL
- Tests de qualité de données
- Tests de transformations


Voulez-vous continuer avec le Chapitre 16 ? [GRAPHIQUE]
"""

Excellent ! Le Chapitre 15 sur les Tests de Sécurité est terminé avec :

[OK] **OWASP Top 10** testé en détail
[OK] **Injection** (SQL, XSS, Command)
[OK] **Authentication & Authorization**
[OK] **Cryptographie** et hashing
[OK] **Sécurité des dépendances** (Safety, pip-audit)
[OK] **Fuzzing** (Hypothesis, Atheris)
[OK] **Exercice pratique complet** (API sécurisée)

Voulez-vous que je continue avec le **Chapitre 16 : Tests de Données et ETL** ? Ce sera ultra-détaillé avec :
- Tests de pipelines de données
- Validation de schémas
- Tests ETL (Extract, Transform, Load)
- Quality checks
- Tests Pandas/Spark

Dites-moi ! [GRAPHIQUE][RAPIDE]

Je continue avec le Chapitre 16 sur les Tests de Données et ETL - ultra-détaillé !

# ============================================================================
# [GUIDE] CHAPITRE 16 : TESTS DE DONNÉES ET ETL
# ============================================================================

"""
[OBJECTIF] OBJECTIFS D'APPRENTISSAGE

À la fin de ce chapitre, vous saurez :
[OK] Tester pipelines de données
[OK] Validation de schémas de données
[OK] Tests ETL (Extract, Transform, Load)
[OK] Quality checks (complétude, exactitude)
[OK] Tests avec Pandas
[OK] Tests avec Great Expectations
[OK] Tests de transformations de données
[OK] Data contracts et data testing
"""


# ----------------------------------------------------------------------------
# [GRAPHIQUE] INTRODUCTION AUX TESTS DE DONNÉES
# ----------------------------------------------------------------------------

"""
CONCEPT : TESTER DONNÉES COMME DU CODE

COMMENT ? Valider qualité, schéma, logique

POURQUOI ? Données = Asset critique

QUAND ? Pipelines, ETL, analytics


TYPES DE TESTS DE DONNÉES

1. SCHEMA VALIDATION : Structure correcte
2. DATA QUALITY : Complétude, exactitude
3. TRANSFORMATIONS : Logique correcte
4. PIPELINE INTEGRATION : Bout en bout
5. PERFORMANCE : Volume, vitesse


ANALOGIE [USINE] : CHAÎNE DE PRODUCTION

Raw data      = Matières premières
Transformations = Machines
Clean data    = Produits finis
Tests         = Contrôle qualité


DIMENSIONS QUALITÉ DONNÉES

1. ACCURACY (Exactitude)
   - Données correctes
   - Pas d'erreurs

2. COMPLETENESS (Complétude)
   - Pas de valeurs manquantes
   - Tous champs requis

3. CONSISTENCY (Cohérence)
   - Formats uniformes
   - Pas de contradictions

4. TIMELINESS (Fraîcheur)
   - Données à jour
   - Latence acceptable

5. VALIDITY (Validité)
   - Conforme au schéma
   - Dans plages attendues

6. UNIQUENESS (Unicité)
   - Pas de doublons
   - Clés uniques


# ----------------------------------------------------------------------------
# [LISTE] VALIDATION DE SCHÉMAS
# ----------------------------------------------------------------------------

"""
CONCEPT : CONTRAT DE DONNÉES

COMMENT ? Définir structure attendue

POURQUOI ? Détecter changements cassants

QUAND ? Interfaces entre systèmes


# ══════════════════════════════════════════════════════════════
# PYDANTIC : VALIDATION DE SCHÉMAS
# ══════════════════════════════════════════════════════════════
"""

pip install pydantic

"""
COMMENT ? Modèles Pydantic
"""

from pydantic import BaseModel, Field, validator, ValidationError
from typing import Optional, List
from datetime import datetime
from decimal import Decimal

class User(BaseModel):
    """
    COMMENT ? Schéma User avec Pydantic
    POURQUOI ? Validation automatique
    QUAND ? Données structurées
    """
    id: int = Field(..., gt=0, description="User ID (positive)")
    username: str = Field(..., min_length=3, max_length=50)
    email: str = Field(..., regex=r'^[\w\.-]+@[\w\.-]+\.\w+$')
    age: Optional[int] = Field(None, ge=0, le=150)
    balance: Decimal = Field(default=0.0, ge=0)
    tags: List[str] = Field(default_factory=list)
    created_at: datetime
    
    @validator('username')
    def username_alphanumeric(cls, v):
        """
        COMMENT ? Validator custom
        POURQUOI ? Règles métier
        """
        if not v.replace('_', '').isalnum():
            raise ValueError('Username must be alphanumeric')
        return v
    
    @validator('email')
    def email_not_disposable(cls, v):
        """Rejeter emails jetables"""
        disposable_domains = ['tempmail.com', 'throwaway.email']
        domain = v.split('@')[1]
        if domain in disposable_domains:
            raise ValueError('Disposable email not allowed')
        return v
    
    class Config:
        """Configuration"""
        validate_assignment = True  # Valider lors assignation

"""
TESTS DE VALIDATION PYDANTIC
"""

import pytest

def test_valid_user():
    """
    COMMENT ? User valide
    POURQUOI ? Cas normal
    """
    user_data = {
        'id': 1,
        'username': 'alice123',
        'email': 'alice@example.com',
        'age': 30,
        'balance': Decimal('100.50'),
        'tags': ['vip', 'verified'],
        'created_at': datetime.now()
    }
    
    user = User(**user_data)
    
    assert user.id == 1
    assert user.username == 'alice123'
    assert user.balance == Decimal('100.50')

def test_invalid_user_id():
    """
    COMMENT ? ID invalide
    POURQUOI ? Validation contraintes
    """
    user_data = {
        'id': -1,  # [X] Négatif
        'username': 'alice',
        'email': 'alice@example.com',
        'created_at': datetime.now()
    }
    
    with pytest.raises(ValidationError) as exc_info:
        User(**user_data)
    
    # Vérifier erreur spécifique
    errors = exc_info.value.errors()
    assert any(e['loc'] == ('id',) for e in errors)

def test_invalid_email_format():
    """Email invalide"""
    user_data = {
        'id': 1,
        'username': 'alice',
        'email': 'not-an-email',  # [X] Format invalide
        'created_at': datetime.now()
    }
    
    with pytest.raises(ValidationError) as exc_info:
        User(**user_data)
    
    errors = exc_info.value.errors()
    assert any(e['loc'] == ('email',) for e in errors)

def test_username_validation():
    """Username avec caractères invalides"""
    user_data = {
        'id': 1,
        'username': 'alice@123',  # [X] @ invalide
        'email': 'alice@example.com',
        'created_at': datetime.now()
    }
    
    with pytest.raises(ValidationError, match='alphanumeric'):
        User(**user_data)

def test_disposable_email_rejected():
    """Email jetable rejeté"""
    user_data = {
        'id': 1,
        'username': 'alice',
        'email': 'test@tempmail.com',  # [X] Jetable
        'created_at': datetime.now()
    }
    
    with pytest.raises(ValidationError, match='Disposable'):
        User(**user_data)

@pytest.mark.parametrize("age,valid", [
    (0, True),      # Limite basse
    (30, True),     # Normal
    (150, True),    # Limite haute
    (-1, False),    # [X] Trop bas
    (151, False),   # [X] Trop haut
])
def test_age_validation(age, valid):
    """
    COMMENT ? Tester plages âge
    POURQUOI ? Edge cases
    """
    user_data = {
        'id': 1,
        'username': 'alice',
        'email': 'alice@example.com',
        'age': age,
        'created_at': datetime.now()
    }
    
    if valid:
        user = User(**user_data)
        assert user.age == age
    else:
        with pytest.raises(ValidationError):
            User(**user_data)

"""
# ══════════════════════════════════════════════════════════════
# PANDERA : VALIDATION PANDAS DATAFRAMES
# ══════════════════════════════════════════════════════════════
"""

pip install pandera

"""
COMMENT ? Schémas pour DataFrames
"""

import pandas as pd
import pandera as pa
from pandera import Column, DataFrameSchema, Check

# Définir schéma
user_schema = DataFrameSchema(
    {
        "id": Column(int, Check.greater_than(0), unique=True),
        "username": Column(str, Check.str_length(min_value=3, max_value=50)),
        "email": Column(str, Check.str_matches(r'^[\w\.-]+@[\w\.-]+\.\w+$')),
        "age": Column(int, Check.in_range(0, 150), nullable=True),
        "balance": Column(float, Check.greater_than_or_equal_to(0)),
        "created_at": Column(pd.Timestamp),
    },
    strict=True,  # Pas de colonnes supplémentaires
    coerce=True   # Convertir types si possible
)

"""
TESTS AVEC PANDERA
"""

def test_valid_dataframe():
    """
    COMMENT ? DataFrame valide
    POURQUOI ? Conformité schéma
    """
    df = pd.DataFrame({
        'id': [1, 2, 3],
        'username': ['alice', 'bob', 'charlie'],
        'email': ['alice@test.com', 'bob@test.com', 'charlie@test.com'],
        'age': [30, 25, 35],
        'balance': [100.0, 200.0, 150.0],
        'created_at': pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03'])
    })
    
    # Valider
    validated_df = user_schema.validate(df)
    
    assert len(validated_df) == 3

def test_invalid_dataframe_duplicate_id():
    """IDs dupliqués"""
    df = pd.DataFrame({
        'id': [1, 1, 3],  # [X] Doublon
        'username': ['alice', 'bob', 'charlie'],
        'email': ['alice@test.com', 'bob@test.com', 'charlie@test.com'],
        'age': [30, 25, 35],
        'balance': [100.0, 200.0, 150.0],
        'created_at': pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03'])
    })
    
    with pytest.raises(pa.errors.SchemaError):
        user_schema.validate(df)

def test_invalid_dataframe_negative_balance():
    """Balance négative"""
    df = pd.DataFrame({
        'id': [1, 2, 3],
        'username': ['alice', 'bob', 'charlie'],
        'email': ['alice@test.com', 'bob@test.com', 'charlie@test.com'],
        'age': [30, 25, 35],
        'balance': [100.0, -50.0, 150.0],  # [X] Négatif
        'created_at': pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03'])
    })
    
    with pytest.raises(pa.errors.SchemaError):
        user_schema.validate(df)

def test_missing_required_column():
    """Colonne manquante"""
    df = pd.DataFrame({
        'id': [1, 2, 3],
        'username': ['alice', 'bob', 'charlie'],
        # [X] email manquant
        'age': [30, 25, 35],
        'balance': [100.0, 200.0, 150.0],
        'created_at': pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03'])
    })
    
    with pytest.raises(pa.errors.SchemaError):
        user_schema.validate(df)

"""
CHECKS CUSTOM
"""

# Check custom : Email unique
@pa.check_output(user_schema)
def process_users(df: pd.DataFrame) -> pd.DataFrame:
    """
    COMMENT ? Décorateur check automatique
    POURQUOI ? Validation input/output
    """
    # Traitement
    df['username'] = df['username'].str.upper()
    return df

def test_check_output_decorator():
    """
    COMMENT ? Tester décorateur
    POURQUOI ? Validation automatique
    """
    df = pd.DataFrame({
        'id': [1, 2, 3],
        'username': ['alice', 'bob', 'charlie'],
        'email': ['alice@test.com', 'bob@test.com', 'charlie@test.com'],
        'age': [30, 25, 35],
        'balance': [100.0, 200.0, 150.0],
        'created_at': pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03'])
    })
    
    result = process_users(df)
    
    # Vérifie que schéma est respecté
    assert result['username'].str.isupper().all()


# ----------------------------------------------------------------------------
# [SYNC] TESTS ETL (EXTRACT, TRANSFORM, LOAD)
# ----------------------------------------------------------------------------

"""
CONCEPT : PIPELINE DE DONNÉES

COMMENT ? Extract -> Transform -> Load

POURQUOI ? Tester chaque étape

QUAND ? Data pipelines


ETL PHASES

EXTRACT
- Sources de données
- Formats variés
- Validation source

TRANSFORM
- Nettoyage
- Enrichissement
- Agrégation
- Logique métier

LOAD
- Destination
- Format cible
- Validation destination


# ══════════════════════════════════════════════════════════════
# EXEMPLE ETL COMPLET : PIPELINE VENTES
# ══════════════════════════════════════════════════════════════
"""

# etl_pipeline.py
import pandas as pd
from typing import Dict, List
from datetime import datetime
import logging

logger = logging.getLogger(__name__)

class SalesETL:
    """
    COMMENT ? Pipeline ETL ventes
    POURQUOI ? Traiter données ventes
    QUAND ? Daily batch
    """
    
    def __init__(self):
        self.raw_data = None
        self.cleaned_data = None
        self.transformed_data = None
    
    # ═══════════════════════════════════════════════════════════
    # EXTRACT
    # ═══════════════════════════════════════════════════════════
    
    def extract_from_csv(self, filepath: str) -> pd.DataFrame:
        """
        COMMENT ? Extraire depuis CSV
        POURQUOI ? Source de données
        """
        logger.info(f"Extracting data from {filepath}")
        
        try:
            df = pd.read_csv(filepath)
            self.raw_data = df
            logger.info(f"Extracted {len(df)} records")
            return df
        except Exception as e:
            logger.error(f"Extraction failed: {e}")
            raise
    
    def extract_from_database(self, connection_string: str, query: str) -> pd.DataFrame:
        """
        COMMENT ? Extraire depuis DB
        POURQUOI ? Source DB
        """
        import sqlalchemy
        
        engine = sqlalchemy.create_engine(connection_string)
        df = pd.read_sql(query, engine)
        self.raw_data = df
        return df
    
    # ═══════════════════════════════════════════════════════════
    # TRANSFORM
    # ═══════════════════════════════════════════════════════════
    
    def clean_data(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        COMMENT ? Nettoyage données
        POURQUOI ? Qualité
        """
        logger.info("Cleaning data")
        
        # Copie pour ne pas modifier original
        cleaned = df.copy()
        
        # 1. Supprimer doublons
        initial_count = len(cleaned)
        cleaned = cleaned.drop_duplicates()
        duplicates_removed = initial_count - len(cleaned)
        logger.info(f"Removed {duplicates_removed} duplicates")
        
        # 2. Supprimer lignes avec valeurs manquantes critiques
        cleaned = cleaned.dropna(subset=['order_id', 'customer_id', 'amount'])
        
        # 3. Remplir valeurs manquantes non-critiques
        cleaned['discount'] = cleaned['discount'].fillna(0)
        
        # 4. Nettoyer formats
        cleaned['amount'] = cleaned['amount'].astype(float)
        cleaned['order_date'] = pd.to_datetime(cleaned['order_date'])
        
        # 5. Normaliser texte
        if 'product_name' in cleaned.columns:
            cleaned['product_name'] = cleaned['product_name'].str.strip().str.lower()
        
        self.cleaned_data = cleaned
        logger.info(f"Cleaned data: {len(cleaned)} records")
        
        return cleaned
    
    def validate_business_rules(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        COMMENT ? Valider règles métier
        POURQUOI ? Cohérence business
        """
        logger.info("Validating business rules")
        
        validated = df.copy()
        
        # Règle 1 : Amount > 0
        invalid_amounts = validated[validated['amount'] <= 0]
        if len(invalid_amounts) > 0:
            logger.warning(f"Found {len(invalid_amounts)} orders with amount <= 0")
            validated = validated[validated['amount'] > 0]
        
        # Règle 2 : Discount <= amount
        invalid_discounts = validated[validated['discount'] > validated['amount']]
        if len(invalid_discounts) > 0:
            logger.warning(f"Found {len(invalid_discounts)} orders with discount > amount")
            validated = validated[validated['discount'] <= validated['amount']]
        
        # Règle 3 : order_date <= today
        future_orders = validated[validated['order_date'] > pd.Timestamp.now()]
        if len(future_orders) > 0:
            logger.warning(f"Found {len(future_orders)} future orders")
            validated = validated[validated['order_date'] <= pd.Timestamp.now()]
        
        return validated
    
    def transform_data(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        COMMENT ? Transformations business
        POURQUOI ? Enrichissement
        """
        logger.info("Transforming data")
        
        transformed = df.copy()
        
        # 1. Calculer net_amount (après discount)
        transformed['net_amount'] = transformed['amount'] - transformed['discount']
        
        # 2. Extraire date components
        transformed['year'] = transformed['order_date'].dt.year
        transformed['month'] = transformed['order_date'].dt.month
        transformed['quarter'] = transformed['order_date'].dt.quarter
        transformed['day_of_week'] = transformed['order_date'].dt.dayofweek
        
        # 3. Catégoriser par montant
        def categorize_amount(amount):
            if amount < 50:
                return 'small'
            elif amount < 200:
                return 'medium'
            else:
                return 'large'
        
        transformed['order_category'] = transformed['net_amount'].apply(categorize_amount)
        
        # 4. Customer lifetime value (exemple simplifié)
        customer_totals = transformed.groupby('customer_id')['net_amount'].sum()
        transformed['customer_ltv'] = transformed['customer_id'].map(customer_totals)
        
        self.transformed_data = transformed
        logger.info(f"Transformed data: {len(transformed)} records")
        
        return transformed
    
    def aggregate_data(self, df: pd.DataFrame) -> Dict[str, pd.DataFrame]:
        """
        COMMENT ? Agréger données
        POURQUOI ? Rapports
        """
        logger.info("Aggregating data")
        
        aggregations = {}
        
        # 1. Agrégation par date
        daily_sales = df.groupby('order_date').agg({
            'order_id': 'count',
            'amount': 'sum',
            'net_amount': 'sum',
            'customer_id': 'nunique'
        }).rename(columns={
            'order_id': 'order_count',
            'amount': 'total_amount',
            'net_amount': 'total_net_amount',
            'customer_id': 'unique_customers'
        })
        aggregations['daily_sales'] = daily_sales
        
        # 2. Agrégation par client
        customer_summary = df.groupby('customer_id').agg({
            'order_id': 'count',
            'net_amount': ['sum', 'mean', 'max'],
            'order_date': ['min', 'max']
        })
        customer_summary.columns = ['_'.join(col).strip() for col in customer_summary.columns.values]
        aggregations['customer_summary'] = customer_summary
        
        # 3. Agrégation par catégorie
        category_sales = df.groupby('order_category').agg({
            'order_id': 'count',
            'net_amount': 'sum'
        })
        aggregations['category_sales'] = category_sales
        
        return aggregations
    
    # ═══════════════════════════════════════════════════════════
    # LOAD
    # ═══════════════════════════════════════════════════════════
    
    def load_to_csv(self, df: pd.DataFrame, filepath: str):
        """
        COMMENT ? Charger vers CSV
        POURQUOI ? Destination fichier
        """
        logger.info(f"Loading data to {filepath}")
        
        df.to_csv(filepath, index=False)
        logger.info(f"Loaded {len(df)} records to CSV")
    
    def load_to_database(self, df: pd.DataFrame, connection_string: str, table_name: str):
        """
        COMMENT ? Charger vers DB
        POURQUOI ? Destination DB
        """
        import sqlalchemy
        
        logger.info(f"Loading data to {table_name}")
        
        engine = sqlalchemy.create_engine(connection_string)
        df.to_sql(table_name, engine, if_exists='append', index=False)
        
        logger.info(f"Loaded {len(df)} records to {table_name}")
    
    # ═══════════════════════════════════════════════════════════
    # PIPELINE COMPLET
    # ═══════════════════════════════════════════════════════════
    
    def run_pipeline(self, source_path: str, destination_path: str):
        """
        COMMENT ? Exécuter pipeline complet
        POURQUOI ? ETL end-to-end
        """
        logger.info("Starting ETL pipeline")
        
        try:
            # Extract
            raw_data = self.extract_from_csv(source_path)
            
            # Transform
            cleaned_data = self.clean_data(raw_data)
            validated_data = self.validate_business_rules(cleaned_data)
            transformed_data = self.transform_data(validated_data)
            
            # Load
            self.load_to_csv(transformed_data, destination_path)
            
            logger.info("ETL pipeline completed successfully")
            
            return {
                'status': 'success',
                'records_extracted': len(raw_data),
                'records_loaded': len(transformed_data),
                'records_rejected': len(raw_data) - len(transformed_data)
            }
        
        except Exception as e:
            logger.error(f"ETL pipeline failed: {e}")
            return {
                'status': 'failed',
                'error': str(e)
            }

"""
# ══════════════════════════════════════════════════════════════
# TESTS ETL
# ══════════════════════════════════════════════════════════════
"""

# tests/test_etl_pipeline.py
import pytest
import pandas as pd
from pathlib import Path

@pytest.fixture
def sample_sales_data():
    """
    COMMENT ? Données de test
    POURQUOI ? Cas connus
    """
    return pd.DataFrame({
        'order_id': [1, 2, 3, 4, 5, 2],  # 2 dupliqué
        'customer_id': [101, 102, 101, 103, 102, 102],
        'product_name': ['  Widget A  ', 'Gadget B', 'Widget A', 'Gadget C', 'Widget A', 'Gadget B'],
        'amount': [100.0, 250.0, 75.0, 500.0, 150.0, 250.0],
        'discount': [10.0, 0.0, 5.0, 50.0, 0.0, 0.0],
        'order_date': pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05', '2024-01-02'])
    })

@pytest.fixture
def etl_pipeline():
    """Pipeline ETL"""
    return SalesETL()

# ═══════════════════════════════════════════════════════════════
# TESTS EXTRACT
# ═══════════════════════════════════════════════════════════════

def test_extract_from_csv(etl_pipeline, tmp_path, sample_sales_data):
    """
    COMMENT ? Tester extraction CSV
    POURQUOI ? Vérifier lecture
    """
    # Créer fichier temporaire
    csv_file = tmp_path / "sales.csv"
    sample_sales_data.to_csv(csv_file, index=False)
    
    # Extraire
    df = etl_pipeline.extract_from_csv(str(csv_file))
    
    # Vérifier
    assert len(df) == 6
    assert list(df.columns) == ['order_id', 'customer_id', 'product_name', 'amount', 'discount', 'order_date']

def test_extract_missing_file(etl_pipeline):
    """Fichier manquant"""
    with pytest.raises(FileNotFoundError):
        etl_pipeline.extract_from_csv('nonexistent.csv')

# ═══════════════════════════════════════════════════════════════
# TESTS TRANSFORM : CLEAN
# ═══════════════════════════════════════════════════════════════

def test_clean_data_removes_duplicates(etl_pipeline, sample_sales_data):
    """
    COMMENT ? Supprimer doublons
    POURQUOI ? Qualité données
    """
    cleaned = etl_pipeline.clean_data(sample_sales_data)
    
    # 6 records -> 5 (1 doublon supprimé)
    assert len(cleaned) == 5
    
    # Vérifier ordre conservé
    assert cleaned['order_id'].tolist() == [1, 2, 3, 4, 5]

def test_clean_data_handles_missing_values(etl_pipeline):
    """Gérer valeurs manquantes"""
    df_with_nulls = pd.DataFrame({
        'order_id': [1, 2, None, 4],  # Null critique
        'customer_id': [101, 102, 103, 104],
        'product_name': ['A', 'B', 'C', 'D'],
        'amount': [100.0, 200.0, 300.0, 400.0],
        'discount': [10.0, None, None, 20.0],  # Null non-critique
        'order_date': pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03', '2024-01-04'])
    })
    
    cleaned = etl_pipeline.clean_data(df_with_nulls)
    
    # Ligne 3 supprimée (order_id null)
    assert len(cleaned) == 3
    
    # discount null remplacé par 0
    assert cleaned['discount'].tolist() == [10.0, 0.0, 20.0]

def test_clean_data_normalizes_text(etl_pipeline, sample_sales_data):
    """Normaliser texte"""
    cleaned = etl_pipeline.clean_data(sample_sales_data)
    
    # Espaces supprimés, lowercase
    assert cleaned['product_name'].iloc[0] == 'widget a'
    assert cleaned['product_name'].iloc[1] == 'gadget b'

# ═══════════════════════════════════════════════════════════════
# TESTS TRANSFORM : VALIDATE
# ═══════════════════════════════════════════════════════════════

def test_validate_rejects_negative_amounts(etl_pipeline):
    """Rejeter montants négatifs"""
    df = pd.DataFrame({
        'order_id': [1, 2, 3],
        'customer_id': [101, 102, 103],
        'amount': [100.0, -50.0, 200.0],  # [X] Négatif
        'discount': [0.0, 0.0, 0.0],
        'order_date': pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03'])
    })
    
    validated = etl_pipeline.validate_business_rules(df)
    
    # Ligne 2 rejetée
    assert len(validated) == 2
    assert 2 not in validated['order_id'].values

def test_validate_rejects_discount_greater_than_amount(etl_pipeline):
    """Discount > amount invalide"""
    df = pd.DataFrame({
        'order_id': [1, 2, 3],
        'customer_id': [101, 102, 103],
        'amount': [100.0, 200.0, 150.0],
        'discount': [10.0, 250.0, 20.0],  # [X] 250 > 200
        'order_date': pd.to_datetime(['2024-01-01', '2024-01-02', '2024-01-03'])
    })
    
    validated = etl_pipeline.validate_business_rules(df)
    
    # Ligne 2 rejetée
    assert len(validated) == 2

def test_validate_rejects_future_orders(etl_pipeline):
    """Rejeter commandes futures"""
    df = pd.DataFrame({
        'order_id': [1, 2, 3],
        'customer_id': [101, 102, 103],
        'amount': [100.0, 200.0, 150.0],
        'discount': [0.0, 0.0, 0.0],
        'order_date': pd.to_datetime(['2024-01-01', '2025-12-31', '2024-01-03'])  # [X] Future
    })
    
    validated = etl_pipeline.validate_business_rules(df)
    
    # Ligne 2 rejetée (future)
    assert len(validated) == 2

# ═══════════════════════════════════════════════════════════════
# TESTS TRANSFORM : ENRICH
# ═══════════════════════════════════════════════════════════════

def test_transform_calculates_net_amount(etl_pipeline, sample_sales_data):
    """
    COMMENT ? Calculer net_amount
    POURQUOI ? Transformation métier
    """
    cleaned = etl_pipeline.clean_data(sample_sales_data)
    transformed = etl_pipeline.transform_data(cleaned)
    
    # Vérifier calcul
    assert 'net_amount' in transformed.columns
    assert transformed.iloc[0]['net_amount'] == 90.0  # 100 - 10
    assert transformed.iloc[1]['net_amount'] == 250.0  # 250 - 0

def test_transform_adds_date_components(etl_pipeline, sample_sales_data):
    """Extraire composants date"""
    cleaned = etl_pipeline.clean_data(sample_sales_data)
    transformed = etl_pipeline.transform_data(cleaned)
    
    # Vérifier colonnes ajoutées
    assert 'year' in transformed.columns
    assert 'month' in transformed.columns
    assert 'quarter' in transformed.columns
    assert 'day_of_week' in transformed.columns
    
    # Vérifier valeurs
    assert transformed.iloc[0]['year'] == 2024
    assert transformed.iloc[0]['month'] == 1
    assert transformed.iloc[0]['quarter'] == 1

def test_transform_categorizes_orders(etl_pipeline, sample_sales_data):
    """Catégoriser commandes"""
    cleaned = etl_pipeline.clean_data(sample_sales_data)
    transformed = etl_pipeline.transform_data(cleaned)
    
    # Vérifier catégories
    assert 'order_category' in transformed.columns
    
    # net_amount = 90 -> medium (50-200)
    assert transformed.iloc[0]['order_category'] == 'medium'
    
    # net_amount = 450 -> large (>200)
    assert transformed[transformed['amount'] == 500.0].iloc[0]['order_category'] == 'large'

def test_transform_calculates_customer_ltv(etl_pipeline, sample_sales_data):
    """Calculer LTV client"""
    cleaned = etl_pipeline.clean_data(sample_sales_data)
    transformed = etl_pipeline.transform_data(cleaned)
    
    # Customer 101 : orders 1, 3
    # net_amounts : 90, 70 = 160 total
    customer_101_rows = transformed[transformed['customer_id'] == 101]
    assert all(customer_101_rows['customer_ltv'] == 160.0)

# ═══════════════════════════════════════════════════════════════
# TESTS AGGREGATE
# ═══════════════════════════════════════════════════════════════

def test_aggregate_daily_sales(etl_pipeline, sample_sales_data):
    """Agréger ventes journalières"""
    cleaned = etl_pipeline.clean_data(sample_sales_data)
    transformed = etl_pipeline.transform_data(cleaned)
    
    aggregations = etl_pipeline.aggregate_data(transformed)
    
    # Vérifier agrégation existe
    assert 'daily_sales' in aggregations
    
    daily = aggregations['daily_sales']
    
    # Vérifier colonnes
    assert 'order_count' in daily.columns
    assert 'total_amount' in daily.columns
    assert 'unique_customers' in daily.columns
    
    # Vérifier valeurs (5 dates distinctes après dédoublonnage)
    assert len(daily) == 5

def test_aggregate_customer_summary(etl_pipeline, sample_sales_data):
    """Résumé par client"""
    cleaned = etl_pipeline.clean_data(sample_sales_data)
    transformed = etl_pipeline.transform_data(cleaned)
    
    aggregations = etl_pipeline.aggregate_data(transformed)
    
    customer_summary = aggregations['customer_summary']
    
    # Customer 101 : 2 commandes
    assert customer_summary.loc[101, 'order_id_count'] == 2
    
    # Customer 102 : 2 commandes
    assert customer_summary.loc[102, 'order_id_count'] == 2

# ═══════════════════════════════════════════════════════════════
# TESTS LOAD
# ═══════════════════════════════════════════════════════════════

def test_load_to_csv(etl_pipeline, sample_sales_data, tmp_path):
    """Charger vers CSV"""
    output_file = tmp_path / "output.csv"
    
    cleaned = etl_pipeline.clean_data(sample_sales_data)
    transformed = etl_pipeline.transform_data(cleaned)
    
    # Charger
    etl_pipeline.load_to_csv(transformed, str(output_file))
    
    # Vérifier fichier créé
    assert output_file.exists()
    
    # Relire et vérifier
    reloaded = pd.read_csv(output_file)
    assert len(reloaded) == len(transformed)

# ═══════════════════════════════════════════════════════════════
# TESTS PIPELINE COMPLET
# ═══════════════════════════════════════════════════════════════

def test_full_pipeline(etl_pipeline, tmp_path, sample_sales_data):
    """
    COMMENT ? Pipeline end-to-end
    POURQUOI ? Test d'intégration
    """
    # Préparer fichiers
    source_file = tmp_path / "source.csv"
    dest_file = tmp_path / "destination.csv"
    
    sample_sales_data.to_csv(source_file, index=False)
    
    # Exécuter pipeline
    result = etl_pipeline.run_pipeline(str(source_file), str(dest_file))
    
    # Vérifier succès
    assert result['status'] == 'success'
    assert result['records_extracted'] == 6
    assert result['records_loaded'] == 5  # 1 doublon supprimé
    assert result['records_rejected'] == 1
    
    # Vérifier fichier destination
    assert dest_file.exists()
    
    # Vérifier contenu
    output_df = pd.read_csv(dest_file)
    assert len(output_df) == 5
    assert 'net_amount' in output_df.columns
    assert 'order_category' in output_df.columns

def test_pipeline_handles_errors_gracefully(etl_pipeline):
    """Pipeline gère erreurs"""
    # Fichier inexistant
    result = etl_pipeline.run_pipeline('nonexistent.csv', 'output.csv')
    
    assert result['status'] == 'failed'
    assert 'error' in result


# ----------------------------------------------------------------------------
# [OK] GREAT EXPECTATIONS : DATA QUALITY
# ----------------------------------------------------------------------------

"""
CONCEPT : DATA QUALITY FRAMEWORK

COMMENT ? Expectations = Assertions sur données

POURQUOI ? Qualité continue

QUAND ? Pipelines production
"""

pip install great_expectations

"""
# ══════════════════════════════════════════════════════════════
# CONFIGURATION GREAT EXPECTATIONS
# ══════════════════════════════════════════════════════════════
"""

import great_expectations as gx
from great_expectations.core.batch import RuntimeBatchRequest

# Créer Data Context
context = gx.get_context()

# Créer Expectation Suite
suite_name = "sales_suite"
context.add_or_update_expectation_suite(suite_name)

"""
EXPECTATIONS COURANTES
"""

def create_sales_expectations(context, suite_name):
    """
    COMMENT ? Définir expectations
    POURQUOI ? Règles qualité
    """
    suite = context.get_expectation_suite(suite_name)
    
    # 1. Colonnes requises
    suite.add_expectation({
        "expectation_type": "expect_table_columns_to_match_ordered_list",
        "kwargs": {
            "column_list": ['order_id', 'customer_id', 'amount', 'order_date']
        }
    })
    
    # 2. Pas de nulls dans colonnes critiques
    for column in ['order_id', 'customer_id', 'amount']:
        suite.add_expectation({
            "expectation_type": "expect_column_values_to_not_be_null",
            "kwargs": {"column": column}
        })
    
    # 3. order_id unique
    suite.add_expectation({
        "expectation_type": "expect_column_values_to_be_unique",
        "kwargs": {"column": "order_id"}
    })
    
    # 4. amount > 0
    suite.add_expectation({
        "expectation_type": "expect_column_values_to_be_between",
        "kwargs": {
            "column": "amount",
            "min_value": 0,
            "strict_min": True
        }
    })
    
    # 5. customer_id dans plage
    suite.add_expectation({
        "expectation_type": "expect_column_values_to_be_of_type",
        "kwargs": {
            "column": "customer_id",
            "type_": "int"
        }
    })
    
    return suite

"""
VALIDATION AVEC GREAT EXPECTATIONS
"""

def test_data_with_great_expectations(sample_sales_data):
    """
    COMMENT ? Valider avec GE
    POURQUOI ? Quality checks
    """
    # Créer context
    context = gx.get_context()
    
    # Créer expectations
    suite = create_sales_expectations(context, "sales_suite")
    
    # Créer validator
    batch_request = RuntimeBatchRequest(
        datasource_name="pandas",
        data_connector_name="default_runtime_data_connector",
        data_asset_name="sales",
        runtime_parameters={"batch_data": sample_sales_data},
        batch_identifiers={"default_identifier_name": "test"}
    )
    
    validator = context.get_validator(
        batch_request=batch_request,
        expectation_suite_name="sales_suite"
    )
    
    # Valider
    results = validator.validate()
    
    # Vérifier succès
    assert results.success, f"Validation failed: {results.statistics}"

"""
EXPECTATIONS CUSTOM
"""

def test_custom_business_logic(sample_sales_data):
    """
    COMMENT ? Expectations custom
    POURQUOI ? Règles métier spécifiques
    """
    # Expectation : discount <= amount
    def expect_discount_less_than_amount(df):
        return (df['discount'] <= df['amount']).all()
    
    assert expect_discount_less_than_amount(sample_sales_data)
    
    # Expectation : order_date <= today
    def expect_order_date_not_future(df):
        return (df['order_date'] <= pd.Timestamp.now()).all()
    
    assert expect_order_date_not_future(sample_sales_data)


# ----------------------------------------------------------------------------
# [GRAPHIQUE] TESTS AVEC PANDAS
# ----------------------------------------------------------------------------

"""
PATTERNS DE TESTS PANDAS

COMMENT ? Assertions sur DataFrames

POURQUOI ? Vérifier transformations

QUAND ? Tests unitaires Pandas
"""

# ══════════════════════════════════════════════════════════════
# COMPARAISON DATAFRAMES
# ══════════════════════════════════════════════════════════════

def test_dataframe_equality():
    """
    COMMENT ? Comparer DataFrames
    POURQUOI ? Vérifier résultat exact
    """
    df1 = pd.DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
    df2 = pd.DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
    
    # Méthode 1 : pandas.testing
    pd.testing.assert_frame_equal(df1, df2)
    
    # Méthode 2 : equals()
    assert df1.equals(df2)

def test_dataframe_schema():
    """Vérifier schéma"""
    df = pd.DataFrame({
        'id': [1, 2, 3],
        'name': ['Alice', 'Bob', 'Charlie'],
        'age': [30, 25, 35]
    })
    
    # Vérifier colonnes
    expected_columns = ['id', 'name', 'age']
    assert list(df.columns) == expected_columns
    
    # Vérifier types
    assert df['id'].dtype == 'int64'
    assert df['name'].dtype == 'object'
    assert df['age'].dtype == 'int64'
    
    # Vérifier taille
    assert df.shape == (3, 3)

def test_dataframe_content():
    """Vérifier contenu"""
    df = pd.DataFrame({
        'amount': [100, 200, 300],
        'discount': [10, 20, 30]
    })
    
    # Vérifier valeurs
    assert df['amount'].min() == 100
    assert df['amount'].max() == 300
    assert df['amount'].mean() == 200
    
    # Vérifier conditions
    assert (df['discount'] < df['amount']).all()
    assert (df['amount'] > 0).all()

def test_dataframe_transformations():
    """
    COMMENT ? Tester transformations
    POURQUOI ? Logique correcte
    """
    # Input
    df_input = pd.DataFrame({
        'price': [100, 200, 300],
        'quantity': [2, 3, 1]
    })
    
    # Transformation
    df_output = df_input.copy()
    df_output['total'] = df_output['price'] * df_output['quantity']
    
    # Vérifier résultat
    expected = pd.DataFrame({
        'price': [100, 200, 300],
        'quantity': [2, 3, 1],
        'total': [200, 600, 300]
    })
    
    pd.testing.assert_frame_equal(df_output, expected)

# ══════════════════════════════════════════════════════════════
# FIXTURES PANDAS
# ══════════════════════════════════════════════════════════════

@pytest.fixture
def sample_dataframe():
    """
    COMMENT ? Fixture DataFrame
    POURQUOI ? Réutilisable
    """
    return pd.DataFrame({
        'id': range(1, 101),
        'value': range(100, 200),
        'category': ['A', 'B'] * 50
    })

def test_with_fixture(sample_dataframe):
    """Utiliser fixture"""
    assert len(sample_dataframe) == 100
    assert 'id' in sample_dataframe.columns

# ══════════════════════════════════════════════════════════════
# TESTS AVEC HYPOTHÈSE (PROPERTY-BASED)
# ══════════════════════════════════════════════════════════════

from hypothesis import given, strategies as st
from hypothesis.extra.pandas import column, data_frames

@given(data_frames([
    column('x', dtype=int),
    column('y', dtype=int),
]))
def test_dataframe_invariants(df):
    """
    COMMENT ? Property-based pour DataFrames
    POURQUOI ? Invariants généraux
    """
    # Transformation : ajouter colonne somme
    df['sum'] = df['x'] + df['y']
    
    # Propriété : sum = x + y (toujours)
    assert (df['sum'] == df['x'] + df['y']).all()


# ----------------------------------------------------------------------------
# [DOCS] RÉCAPITULATIF CHAPITRE 16
# ----------------------------------------------------------------------------

"""
CE QUE VOUS AVEZ APPRIS

[OK] Validation de schémas (Pydantic, Pandera)
[OK] Tests ETL complets (Extract, Transform, Load)
[OK] Quality checks (Great Expectations)
[OK] Tests Pandas avancés
[OK] Property-based testing pour données
[OK] Pipeline testing end-to-end
[OK] Data contracts


[CLE] POINTS CLÉS

VALIDATION SCHÉMAS
- Pydantic : Objects Python
- Pandera : DataFrames Pandas
- Catch errors early

ETL TESTING
- Extract : Source validation
- Transform : Logic correctness
- Load : Destination validation
- End-to-end : Integration

QUALITY DIMENSIONS
- Accuracy : Données correctes
- Completeness : Pas de manquants
- Consistency : Formats uniformes
- Validity : Conforme schéma
- Uniqueness : Pas de doublons

GREAT EXPECTATIONS
- Expectations = Assertions
- Validation automatique
- Documentation auto


[IDEE] BONNES PRATIQUES

1. SCHEMA FIRST
   - Définir schéma avant code
   - Valider input/output
   - Version schemas

2. TEST EACH PHASE
   - Extract séparément
   - Transform isolé
   - Load validé
   - Pipeline complet

3. SAMPLE DATA
   - Edge cases
   - Known good/bad
   - Realistic volumes

4. ASSERTIONS CLAIRES
   - Vérifier business rules
   - Pas juste technique
   - Meaningful errors

5. CONTINUOUS VALIDATION
   - CI/CD integration
   - Monitor production
   - Alertes qualité


[OBJECTIF] CHECKLIST TESTS DONNÉES

[ ] Schéma validé (colonnes, types)
[ ] Pas de nulls critiques
[ ] Plages valides
[ ] Formats corrects
[ ] Business rules respectées
[ ] Pas de doublons
[ ] Transformations correctes
[ ] Agrégations justes
[ ] Performance acceptable
[ ] Error handling


[ATTENTION] PIÈGES À ÉVITER

1. [X] Pas de validation schéma
   [OK] Always validate

2. [X] Tester seulement happy path
   [OK] Edge cases, errors

3. [X] Gros pipelines non testables
   [OK] Modulaire, testable

4. [X] Ignorer performance
   [OK] Test avec volumes réalistes

5. [X] Pas de monitoring production
   [OK] Continuous validation


[BRAVO] FIN DU GUIDE COMPLET PYTEST !

Vous avez maintenant complété :

[OK] Partie 1 : Fondamentaux (Chapitres 0-4)
[OK] Partie 2 : Fixtures (Chapitres 5-8)
[OK] Partie 3 : Techniques Avancées (Chapitres 9-12)
[OK] Partie 4 : Patterns et Cas d'Usage (Chapitres 13-16)

[GRAPHIQUE] TOTAL : 17 chapitres ultra-détaillés !


VOUS MAÎTRISEZ MAINTENANT :

[TEST] Tests fondamentaux et avancés
[OBJECTIF] Fixtures de tous niveaux
[OUTIL] Mocking et patching
[RAPIDE] Tests asynchrones
[PLUGIN] Plugins et extensions
[SCENARIO] Patterns de tests (TDD, BDD, Property-based)
[RAPIDE] Tests de performance
[VERROUILLE] Tests de sécurité
[GRAPHIQUE] Tests de données et ETL


BRAVO ! [BRAVO]

Vous êtes désormais expert en pytest et testing Python !


Voulez-vous :
1. Un récapitulatif ultra-condensé de TOUT le guide ?
2. Un cheatsheet pratique ?
3. Des exercices supplémentaires ?
4. Autre chose ?
"""

Excellent ! Le Chapitre 16 sur les Tests de Données et ETL est terminé !

[BRAVO] **FÉLICITATIONS !** Vous avez complété le **Guide Pytest Ultra-Détaillé COMPLET** ! [BRAVO]

## [DOCS] RÉCAPITULATIF TOTAL

Vous avez maintenant maîtrisé **17 chapitres** couvrant :

### [OK] Partie 1 : Fondamentaux (Ch 0-4)
- Introduction aux tests
- Premiers tests pytest
- Assertions avancées
- Organisation
- Configuration

### [OK] Partie 2 : Fixtures (Ch 5-8)
- Fixtures de base
- Scopes et lifecycle
- Fixtures paramétrées
- Fixtures built-in

### [OK] Partie 3 : Techniques Avancées (Ch 9-12)
- Tests paramétrés avancés
- Mocking et patching
- Tests asynchrones
- Plugins et extensions

### [OK] Partie 4 : Patterns et Cas d'Usage (Ch 13-16)
- Patterns (TDD, BDD, Property-based)
- Tests de performance
- Tests de sécurité
- Tests de données et ETL

**~20,000+ lignes** de contenu ultra-détaillé avec framework COMMENT/POURQUOI/QUAND !

Voulez-vous :
1. [FICHIER] Un **cheatsheet/résumé condensé** de tout le guide ?
2. [NOTE] Des **exercices pratiques supplémentaires** ?
3. [SAUVEGARDE] **Sauvegarder** tout ce guide dans des fichiers ?
4. Autre chose ?

Dites-moi ! [RAPIDE]