Skip to main content Aller au contenu principal

Les Tests Unitaires: Écrire du Code Fiable et Maintenable

Les tests unitaires, c’est ce qui t’évite de casser ton code sans t’en rendre compte. Dans cet article, on va voir pourquoi ils sont indispensables en Python et comment en écrire simplement, étape par étape, pour rendre ton code plus robuste et plus agréable à maintenir.

M
Mpia
02/28/2026 40 min
4 vues
0 comme.
#Python#tags: Test#Quality
Les Tests Unitaires: Écrire du Code Fiable et Maintenable

Les tests unitaires, c’est ce qui t’évite de casser ton code sans t’en rendre compte. Dans cet article, on va voir pourquoi ils sont indispensables en Python et comment en écrire simplement, étape par étape, pour rendre ton code plus robuste et plus agréable à maintenir.

Pourquoi écrire des tests ?

Le problème sans tests

Imaginez que vous développez une application bancaire :

# banking.py
class CompteBancaire:
    def __init__(self, solde_initial=0):
        self.solde = solde_initial

    def deposer(self, montant):
        self.solde += montant

    def retirer(self, montant):
        self.solde -= montant  # ❌ Bug : pas de vérification du solde !

    def transferer(self, autre_compte, montant):
        self.retirer(montant)
        autre_compte.deposer(montant)

# Utilisation sans tests
compte = CompteBancaire(100)
compte.retirer(150)  # Solde devient -50 ! 😱

print(f"Solde : {compte.solde}€")  # -50€

Les problèmes :

  • Aucune garantie que le code fonctionne correctement

  • Les bugs ne sont découverts qu'en production

  • Difficile de savoir ce qui casse après une modification

  • Pas de documentation sur le comportement attendu

La solution : les tests automatisés

# test_banking.py
import unittest
from banking import CompteBancaire

class TestCompteBancaire(unittest.TestCase):
    def test_retrait_avec_solde_insuffisant(self):
        """Vérifie qu'on ne peut pas retirer plus que le solde."""
        compte = CompteBancaire(100)

        with self.assertRaises(ValueError):
            compte.retirer(150)

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

Les avantages :

  • Confiance : vous savez que votre code fonctionne

  • Documentation : les tests montrent comment utiliser le code

  • Refactoring sûr : vous pouvez modifier sans casser

  • Détection précoce : les bugs sont trouvés immédiatement

  • Régression : évite que d'anciens bugs réapparaissent

Les bases avec unittest

Python inclut le module unittest dans sa bibliothèque standard.

Votre premier test

# calculatrice.py
def additionner(a, b):
    """Additionne deux nombres."""
    return a + b

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

def multiplier(a, b):
    """Multiplie deux nombres."""
    return a * b

def diviser(a, b):
    """Divise a par b."""
    if b == 0:
        raise ValueError("Division par zéro impossible")
    return a / b
# test_calculatrice.py
import unittest
from calculatrice import additionner, soustraire, multiplier, diviser

class TestCalculatrice(unittest.TestCase):
    """Tests pour les fonctions de calculatrice."""

    def test_additionner(self):
        """Test de l'addition."""
        self.assertEqual(additionner(2, 3), 5)
        self.assertEqual(additionner(-1, 1), 0)
        self.assertEqual(additionner(0, 0), 0)

    def test_soustraire(self):
        """Test de la soustraction."""
        self.assertEqual(soustraire(5, 3), 2)
        self.assertEqual(soustraire(0, 5), -5)

    def test_multiplier(self):
        """Test de la multiplication."""
        self.assertEqual(multiplier(3, 4), 12)
        self.assertEqual(multiplier(-2, 3), -6)
        self.assertEqual(multiplier(0, 100), 0)

    def test_diviser(self):
        """Test de la division."""
        self.assertEqual(diviser(10, 2), 5)
        self.assertAlmostEqual(diviser(1, 3), 0.333, places=3)

    def test_diviser_par_zero(self):
        """Test de la division par zéro."""
        with self.assertRaises(ValueError):
            diviser(10, 0)

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

Exécution :

python test_calculatrice.py

Résultat :

.....
----------------------------------------------------------------------
Ran 5 tests in 0.001s

OK

Comprendre la structure

import unittest

# 1. Créer une classe de test héritant de unittest.TestCase
class TestMaFonction(unittest.TestCase):

    # 2. Chaque méthode de test commence par "test_"
    def test_cas_normal(self):
        """Description du test."""
        # 3. Préparer (Arrange)
        valeur = 42

        # 4. Exécuter (Act)
        resultat = ma_fonction(valeur)

        # 5. Vérifier (Assert)
        self.assertEqual(resultat, valeur_attendue)

Le pattern AAA (Arrange, Act, Assert) :

  1. Arrange : préparer les données et l'environnement

  2. Act : exécuter le code à tester

  3. Assert : vérifier que le résultat est correct

Les assertions essentielles

Assertions d'égalité

class TestAssertions(unittest.TestCase):

    def test_egalite(self):
        """Test d'égalité basique."""
        self.assertEqual(2 + 2, 4)
        self.assertEqual("hello", "hello")
        self.assertEqual([1, 2, 3], [1, 2, 3])

    def test_inegalite(self):
        """Test d'inégalité."""
        self.assertNotEqual(5, 3)
        self.assertNotEqual("chat", "chien")

    def test_egalite_approximative(self):
        """Pour les nombres flottants."""
        self.assertAlmostEqual(0.1 + 0.2, 0.3, places=7)
        self.assertAlmostEqual(1/3, 0.333, places=3)

Assertions booléennes

class TestBooleens(unittest.TestCase):

    def test_verite(self):
        """Test de vérité."""
        self.assertTrue(True)
        self.assertTrue(1 == 1)
        self.assertTrue("texte")  # Valeur truthy

    def test_faussete(self):
        """Test de fausseté."""
        self.assertFalse(False)
        self.assertFalse(0 == 1)
        self.assertFalse("")  # Valeur falsy

    def test_is(self):
        """Test d'identité."""
        a = [1, 2, 3]
        b = a
        self.assertIs(a, b)  # Même objet en mémoire

        c = [1, 2, 3]
        self.assertIsNot(a, c)  # Objets différents

    def test_none(self):
        """Test de None."""
        valeur = None
        self.assertIsNone(valeur)

        valeur = "quelque chose"
        self.assertIsNotNone(valeur)

Assertions de conteneurs

class TestConteneurs(unittest.TestCase):

    def test_in(self):
        """Test d'appartenance."""
        self.assertIn(3, [1, 2, 3, 4])
        self.assertIn('a', 'chaîne')
        self.assertIn('key', {'key': 'value'})

    def test_not_in(self):
        """Test de non-appartenance."""
        self.assertNotIn(5, [1, 2, 3])
        self.assertNotIn('z', 'chaîne')

    def test_count_equal(self):
        """Vérifie que deux séquences ont les mêmes éléments."""
        self.assertCountEqual([1, 2, 3], [3, 2, 1])  # Ordre importe pas
        self.assertCountEqual("abc", "bca")

Assertions de types

class TestTypes(unittest.TestCase):

    def test_isinstance(self):
        """Test de type."""
        self.assertIsInstance("texte", str)
        self.assertIsInstance(42, int)
        self.assertIsInstance([1, 2], list)

        self.assertNotIsInstance("42", int)

Assertions d'exceptions

class TestExceptions(unittest.TestCase):

    def test_raises_simple(self):
        """Test qu'une exception est levée."""
        def diviser_par_zero():
            return 1 / 0

        self.assertRaises(ZeroDivisionError, diviser_par_zero)

    def test_raises_with_context(self):
        """Test avec gestionnaire de contexte."""
        with self.assertRaises(ValueError):
            int("pas un nombre")

    def test_raises_avec_message(self):
        """Vérifier le message de l'exception."""
        with self.assertRaises(ValueError) as context:
            raise ValueError("Message d'erreur spécifique")

        self.assertIn("spécifique", str(context.exception))

    def test_raises_regex(self):
        """Vérifier le message avec une regex."""
        with self.assertRaisesRegex(ValueError, r'\d+ est invalide'):
            raise ValueError("42 est invalide")

setUp et tearDown : préparer et nettoyer

Méthodes de configuration

class TestAvecSetup(unittest.TestCase):

    def setUp(self):
        """
        Exécuté AVANT chaque test.
        Utilisé pour préparer l'environnement.
        """
        print("setUp : préparation")
        self.liste = [1, 2, 3, 4, 5]
        self.compte = CompteBancaire(1000)

    def tearDown(self):
        """
        Exécuté APRÈS chaque test.
        Utilisé pour nettoyer.
        """
        print("tearDown : nettoyage")
        self.liste = None
        self.compte = None

    def test_premier(self):
        """Premier test."""
        print("  test_premier")
        self.assertEqual(len(self.liste), 5)

    def test_deuxieme(self):
        """Deuxième test."""
        print("  test_deuxieme")
        self.assertEqual(self.compte.solde, 1000)

# Résultat :
# setUp : préparation
#   test_premier
# tearDown : nettoyage
# setUp : préparation
#   test_deuxieme
# tearDown : nettoyage

Configuration de classe

class TestAvecSetupClass(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        """
        Exécuté UNE SEULE FOIS avant tous les tests.
        Pour les opérations coûteuses.
        """
        print("setUpClass : initialisation de la classe")
        cls.connexion_db = "connexion simulée"

    @classmethod
    def tearDownClass(cls):
        """
        Exécuté UNE SEULE FOIS après tous les tests.
        Pour nettoyer les ressources partagées.
        """
        print("tearDownClass : fermeture connexion")
        cls.connexion_db = None

    def setUp(self):
        """Exécuté avant chaque test."""
        print("  setUp : préparation test")

    def test_un(self):
        print("    test_un")
        self.assertIsNotNone(self.connexion_db)

    def test_deux(self):
        print("    test_deux")
        self.assertIsNotNone(self.connexion_db)

Tests de classes : exemple complet

# banking.py (version corrigée)
class CompteBancaire:
    """Représente un compte bancaire."""

    def __init__(self, titulaire, solde_initial=0):
        if solde_initial < 0:
            raise ValueError("Le solde initial ne peut pas être négatif")

        self.titulaire = titulaire
        self.solde = solde_initial
        self.historique = []

    def deposer(self, montant):
        """Dépose de l'argent sur le compte."""
        if montant <= 0:
            raise ValueError("Le montant doit être positif")

        self.solde += montant
        self.historique.append(f"Dépôt: +{montant}€")
        return self.solde

    def retirer(self, montant):
        """Retire de l'argent du compte."""
        if montant <= 0:
            raise ValueError("Le montant doit être positif")

        if montant > self.solde:
            raise ValueError("Solde insuffisant")

        self.solde -= montant
        self.historique.append(f"Retrait: -{montant}€")
        return self.solde

    def transferer(self, autre_compte, montant):
        """Transfère de l'argent vers un autre compte."""
        self.retirer(montant)
        autre_compte.deposer(montant)
        return self.solde

    def obtenir_historique(self):
        """Retourne l'historique des transactions."""
        return self.historique.copy()
# test_banking.py
import unittest
from banking import CompteBancaire

class TestCompteBancaire(unittest.TestCase):
    """Suite de tests pour CompteBancaire."""

    def setUp(self):
        """Prépare un compte pour chaque test."""
        self.compte = CompteBancaire("Alice", 1000)

    # Tests du constructeur
    def test_creation_compte_valide(self):
        """Test de création d'un compte valide."""
        compte = CompteBancaire("Bob", 500)
        self.assertEqual(compte.titulaire, "Bob")
        self.assertEqual(compte.solde, 500)
        self.assertEqual(len(compte.historique), 0)

    def test_creation_compte_sans_solde(self):
        """Test de création sans solde initial."""
        compte = CompteBancaire("Charlie")
        self.assertEqual(compte.solde, 0)

    def test_creation_solde_negatif(self):
        """Test qu'on ne peut pas créer un compte avec solde négatif."""
        with self.assertRaises(ValueError):
            CompteBancaire("Dave", -100)

    # Tests de dépôt
    def test_depot_valide(self):
        """Test d'un dépôt valide."""
        nouveau_solde = self.compte.deposer(500)
        self.assertEqual(nouveau_solde, 1500)
        self.assertEqual(self.compte.solde, 1500)

    def test_depot_negatif(self):
        """Test qu'on ne peut pas déposer un montant négatif."""
        with self.assertRaises(ValueError):
            self.compte.deposer(-100)

    def test_depot_zero(self):
        """Test qu'on ne peut pas déposer zéro."""
        with self.assertRaises(ValueError):
            self.compte.deposer(0)

    def test_depot_historique(self):
        """Test que le dépôt est ajouté à l'historique."""
        self.compte.deposer(200)
        historique = self.compte.obtenir_historique()
        self.assertEqual(len(historique), 1)
        self.assertIn("Dépôt: +200€", historique)

    # Tests de retrait
    def test_retrait_valide(self):
        """Test d'un retrait valide."""
        nouveau_solde = self.compte.retirer(300)
        self.assertEqual(nouveau_solde, 700)
        self.assertEqual(self.compte.solde, 700)

    def test_retrait_solde_insuffisant(self):
        """Test qu'on ne peut pas retirer plus que le solde."""
        with self.assertRaises(ValueError) as context:
            self.compte.retirer(2000)

        self.assertIn("insuffisant", str(context.exception).lower())
        self.assertEqual(self.compte.solde, 1000)  # Solde inchangé

    def test_retrait_negatif(self):
        """Test qu'on ne peut pas retirer un montant négatif."""
        with self.assertRaises(ValueError):
            self.compte.retirer(-50)

    def test_retrait_historique(self):
        """Test que le retrait est ajouté à l'historique."""
        self.compte.retirer(100)
        historique = self.compte.obtenir_historique()
        self.assertIn("Retrait: -100€", historique)

    # Tests de transfert
    def test_transfert_valide(self):
        """Test d'un transfert valide."""
        compte_bob = CompteBancaire("Bob", 500)

        self.compte.transferer(compte_bob, 200)

        self.assertEqual(self.compte.solde, 800)
        self.assertEqual(compte_bob.solde, 700)

    def test_transfert_solde_insuffisant(self):
        """Test d'un transfert avec solde insuffisant."""
        compte_bob = CompteBancaire("Bob", 500)

        with self.assertRaises(ValueError):
            self.compte.transferer(compte_bob, 2000)

        # Vérifier qu'aucun compte n'a été modifié
        self.assertEqual(self.compte.solde, 1000)
        self.assertEqual(compte_bob.solde, 500)

    def test_transfert_historiques(self):
        """Test que le transfert apparaît dans les deux historiques."""
        compte_bob = CompteBancaire("Bob", 500)

        self.compte.transferer(compte_bob, 100)

        historique_alice = self.compte.obtenir_historique()
        historique_bob = compte_bob.obtenir_historique()

        self.assertEqual(len(historique_alice), 1)
        self.assertEqual(len(historique_bob), 1)
        self.assertIn("Retrait", historique_alice[0])
        self.assertIn("Dépôt", historique_bob[0])

    # Tests d'historique
    def test_historique_vide(self):
        """Test qu'un nouveau compte a un historique vide."""
        compte = CompteBancaire("Test", 100)
        self.assertEqual(len(compte.obtenir_historique()), 0)

    def test_historique_multiple(self):
        """Test d'un historique avec plusieurs transactions."""
        self.compte.deposer(100)
        self.compte.retirer(50)
        self.compte.deposer(200)

        historique = self.compte.obtenir_historique()
        self.assertEqual(len(historique), 3)

    def test_historique_copie(self):
        """Test que obtenir_historique retourne une copie."""
        historique = self.compte.obtenir_historique()
        historique.append("Fausse transaction")

        # L'historique du compte ne doit pas être modifié
        self.assertEqual(len(self.compte.obtenir_historique()), 0)

if __name__ == '__main__':
    unittest.main(verbosity=2)

Exécution avec verbosité :

python test_banking.py

Résultat :

test_creation_compte_sans_solde (__main__.TestCompteBancaire) ... ok
test_creation_compte_valide (__main__.TestCompteBancaire) ... ok
test_creation_solde_negatif (__main__.TestCompteBancaire) ... ok
test_depot_historique (__main__.TestCompteBancaire) ... ok
test_depot_negatif (__main__.TestCompteBancaire) ... ok
test_depot_valide (__main__.TestCompteBancaire) ... ok
test_depot_zero (__main__.TestCompteBancaire) ... ok
...

----------------------------------------------------------------------
Ran 15 tests in 0.003s

OK

Organiser ses tests

Structure de projet recommandée

mon_projet/
├── src/
│   ├── __init__.py
│   ├── banking.py
│   ├── calculatrice.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── test_banking.py
│   ├── test_calculatrice.py
│   └── test_utils.py
├── requirements.txt
└── README.md

Exécuter tous les tests

# Découverte automatique des tests
python -m unittest discover tests

# Avec verbosité
python -m unittest discover tests -v

# Exécuter un fichier spécifique
python -m unittest tests.test_banking

# Exécuter une classe spécifique
python -m unittest tests.test_banking.TestCompteBancaire

# Exécuter un test spécifique
python -m unittest tests.test_banking.TestCompteBancaire.test_depot_valide

Test-Driven Development (TDD)

Le cycle Red-Green-Refactor

Le TDD suit un cycle en trois étapes :

  1. 🔴 Red : Écrire un test qui échoue

  2. 🟢 Green : Écrire le code minimal pour passer le test

  3. 🔵 Refactor : Améliorer le code sans casser les tests

Exemple : Développer une fonction avec TDD

# Étape 1 : 🔴 RED - Écrire le test d'abord
class TestValidateurEmail(unittest.TestCase):

    def test_email_valide(self):
        """Test qu'un email valide est accepté."""
        self.assertTrue(valider_email("alice@exemple.com"))

    def test_email_sans_arobase(self):
        """Test qu'un email sans @ est rejeté."""
        self.assertFalse(valider_email("alice.exemple.com"))

    def test_email_sans_domaine(self):
        """Test qu'un email sans domaine est rejeté."""
        self.assertFalse(valider_email("alice@"))

    def test_email_sans_extension(self):
        """Test qu'un email sans extension est rejeté."""
        self.assertFalse(valider_email("alice@exemple"))
# Étape 2 : 🟢 GREEN - Implémenter pour passer les tests
def valider_email(email):
    """
    Valide une adresse email.

    Returns:
        bool: True si l'email est valide, False sinon
    """
    if '@' not in email:
        return False

    parties = email.split('@')
    if len(parties) != 2:
        return False

    local, domaine = parties

    if not local or not domaine:
        return False

    if '.' not in domaine:
        return False

    return True
# Étape 3 : 🔵 REFACTOR - Améliorer avec regex
import re

def valider_email(email):
    """Valide une adresse email."""
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))

# Les tests passent toujours !

Mocking : simuler des dépendances

Pourquoi mocker ?

Parfois, vous devez tester du code qui dépend de ressources externes :

  • Bases de données

  • APIs web

  • Système de fichiers

  • Services tiers

Les mocks permettent de simuler ces dépendances.

Utiliser unittest.mock

from unittest.mock import Mock, patch, MagicMock
import unittest

# Code à tester
import requests

def obtenir_utilisateur(user_id):
    """Récupère un utilisateur depuis une API."""
    response = requests.get(f"https://api.exemple.com/users/{user_id}")
    if response.status_code == 200:
        return response.json()
    return None

# Tests avec mock
class TestObtenirUtilisateur(unittest.TestCase):

    @patch('requests.get')
    def test_utilisateur_existe(self, mock_get):
        """Test quand l'utilisateur existe."""
        # Configurer le mock
        mock_response = Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {'id': 1, 'nom': 'Alice'}
        mock_get.return_value = mock_response

        # Exécuter
        resultat = obtenir_utilisateur(1)

        # Vérifier
        self.assertEqual(resultat['nom'], 'Alice')
        mock_get.assert_called_once_with("https://api.exemple.com/users/1")

    @patch('requests.get')
    def test_utilisateur_inexistant(self, mock_get):
        """Test quand l'utilisateur n'existe pas."""
        mock_response = Mock()
        mock_response.status_code = 404
        mock_get.return_value = mock_response

        resultat = obtenir_utilisateur(999)

        self.assertIsNone(resultat)

Mocker des méthodes de classe

class ServiceEmail:
    """Service d'envoi d'emails."""

    def envoyer(self, destinataire, message):
        """Envoie un email (appelle un service externe)."""
        # Code complexe d'envoi d'email
        pass

class GestionnaireUtilisateurs:
    """Gère les utilisateurs."""

    def __init__(self, service_email):
        self.service_email = service_email
        self.utilisateurs = []

    def inscrire_utilisateur(self, nom, email):
        """Inscrit un utilisateur et envoie un email de bienvenue."""
        utilisateur = {'nom': nom, 'email': email}
        self.utilisateurs.append(utilisateur)

        # Envoyer email de bienvenue
        self.service_email.envoyer(
            email,
            f"Bienvenue {nom} !"
        )

        return utilisateur

# Tests
class TestGestionnaireUtilisateurs(unittest.TestCase):

    def setUp(self):
        # Créer un mock du service email
        self.mock_service_email = Mock(spec=ServiceEmail)
        self.gestionnaire = GestionnaireUtilisateurs(self.mock_service_email)

    def test_inscrire_utilisateur(self):
        """Test l'inscription d'un utilisateur."""
        utilisateur = self.gestionnaire.inscrire_utilisateur("Alice", "alice@mail.com")

        # Vérifier que l'utilisateur est ajouté
        self.assertEqual(len(self.gestionnaire.utilisateurs), 1)
        self.assertEqual(utilisateur['nom'], "Alice")

        # Vérifier que l'email a été envoyé
        self.mock_service_email.envoyer.assert_called_once_with(
            "alice@mail.com",
            "Bienvenue Alice !"
        )

Mocker des fichiers

from unittest.mock import mock_open, patch

def lire_configuration(fichier):
    """Lit un fichier de configuration."""
    with open(fichier, 'r') as f:
        contenu = f.read()
    return contenu.strip()

class TestLireConfiguration(unittest.TestCase):

    @patch('builtins.open', new_callable=mock_open, read_data='DEBUG=True\n')
    def test_lire_configuration(self, mock_file):
        """Test de lecture de fichier."""
        resultat = lire_configuration('config.txt')

        self.assertEqual(resultat, 'DEBUG=True')
        mock_file.assert_called_once_with('config.txt', 'r')

Pytest : une alternative moderne

Installation

pip install pytest

Avantages de pytest

  • Syntaxe plus simple

  • Meilleurs messages d'erreur

  • Fixtures puissantes

  • Paramétrage facile

  • Plugins riches

Exemple basique avec pytest

# test_calculatrice_pytest.py
from calculatrice import additionner, soustraire, diviser
import pytest

def test_additionner():
    """Test de l'addition."""
    assert additionner(2, 3) == 5
    assert additionner(-1, 1) == 0

def test_soustraire():
    """Test de la soustraction."""
    assert soustraire(5, 3) == 2
    assert soustraire(0, 5) == -5

def test_diviser():
    """Test de la division."""
    assert diviser(10, 2) == 5

def test_diviser_par_zero():
    """Test de la division par zéro."""
    with pytest.raises(ValueError):
        diviser(10, 0)

Exécution :

pytest test_calculatrice_pytest.py -v

Fixtures pytest

import pytest

@pytest.fixture
def compte():
    """Fixture qui fournit un compte bancaire."""
    return CompteBancaire("Alice", 1000)

@pytest.fixture
def deux_comptes():
    """Fixture qui fournit deux comptes."""
    compte1 = CompteBancaire("Alice", 1000)
    compte2 = CompteBancaire("Bob", 500)
    return compte1, compte2

# Utilisation
def test_depot(compte):
    """Test de dépôt avec fixture."""
    compte.deposer(500)
    assert compte.solde == 1500

def test_transfert(deux_comptes):
    """Test de transfert avec fixture."""
    alice, bob = deux_comptes
    alice.transferer(bob, 200)
    assert alice.solde == 800
    assert bob.solde == 700

Paramétrage de tests

@pytest.mark.parametrize("a,b,attendu", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
    (100, 200, 300),
    (-5, -3, -8),
])
def test_additionner_parametres(a, b, attendu):
    """Test paramétré de l'addition."""
    assert additionner(a, b) == attendu

# Pytest exécutera 5 tests automatiquement !

Couverture de code

Installation

pip install coverage

Utilisation

# Exécuter les tests avec couverture
coverage run -m unittest discover tests

# Générer le rapport
coverage report

# Générer un rapport HTML
coverage html
# Ouvrir htmlcov/index.html dans un navigateur

Exemple de rapport :

Name                Stmts   Miss  Cover
---------------------------------------
banking.py             45      2    96%
calculatrice.py        12      0   100%
---------------------------------------
TOTAL                  57      2    96%

Objectifs de couverture

  • 80-90% : bon objectif pour la plupart des projets

  • 100% : souvent inutile et coûteux

  • < 70% : probablement insuffisant

Important : La couverture mesure ce qui est exécuté, pas ce qui est bien testé !

Bonnes pratiques

1. Un test, une assertion (idéalement)

# ❌ Plusieurs assertions non liées
def test_compte_mauvais(self):
    compte = CompteBancaire("Alice", 1000)
    self.assertEqual(compte.titulaire, "Alice")  # Test du nom
    self.assertEqual(compte.solde, 1000)  # Test du solde
    compte.deposer(500)
    self.assertEqual(compte.solde, 1500)  # Test du dépôt

# ✅ Tests séparés et focalisés
def test_creation_titulaire(self):
    compte = CompteBancaire("Alice", 1000)
    self.assertEqual(compte.titulaire, "Alice")

def test_creation_solde(self):
    compte = CompteBancaire("Alice", 1000)
    self.assertEqual(compte.solde, 1000)

def test_depot(self):
    compte = CompteBancaire("Alice", 1000)
    compte.deposer(500)
    self.assertEqual(compte.solde, 1500)

2. Tests indépendants

# ❌ Tests dépendants (l'ordre compte)
class TestMauvais(unittest.TestCase):
    compte = None

    def test_1_creation(self):
        self.compte = CompteBancaire("Alice", 1000)

    def test_2_depot(self):
        self.compte.deposer(500)  # Dépend de test_1 !

# ✅ Tests indépendants
class TestBon(unittest.TestCase):
    def setUp(self):
        self.compte = CompteBancaire("Alice", 1000)

    def test_creation(self):
        self.assertEqual(self.compte.solde, 1000)

    def test_depot(self):
        self.compte.deposer(500)
        self.assertEqual(self.compte.solde, 1500)

3. Noms descriptifs

# ❌ Noms peu clairs
def test_1(self):
    pass

def test_compte(self):
    pass

# ✅ Noms descriptifs
def test_retrait_avec_solde_insuffisant_leve_value_error(self):
    pass

def test_depot_montant_negatif_leve_value_error(self):
    pass

def test_transfert_met_a_jour_les_deux_comptes(self):
    pass

4. Pattern AAA (Arrange, Act, Assert)

def test_transfert(self):
    # Arrange - Préparer
    compte_alice = CompteBancaire("Alice", 1000)
    compte_bob = CompteBancaire("Bob", 500)

    # Act - Exécuter
    compte_alice.transferer(compte_bob, 200)

    # Assert - Vérifier
    self.assertEqual(compte_alice.solde, 800)
    self.assertEqual(compte_bob.solde, 700)

5. Tester les cas limites

class TestCasLimites(unittest.TestCase):

    def test_liste_vide(self):
        """Test avec liste vide."""
        self.assertEqual(somme([]), 0)

    def test_un_element(self):
        """Test avec un seul élément."""
        self.assertEqual(somme([5]), 5)

    def test_valeurs_negatives(self):
        """Test avec valeurs négatives."""
        self.assertEqual(somme([-1, -2, -3]), -6)

    def test_grands_nombres(self):
        """Test avec très grands nombres."""
        self.assertEqual(somme([10**10, 10**10]), 2 * 10**10)

6. Ne pas tester les détails d'implémentation

# ❌ Test trop couplé à l'implémentation
def test_mauvais(self):
    compte = CompteBancaire("Alice", 1000)
    compte.deposer(100)
    # Ne testez pas les détails internes
    self.assertEqual(compte._solde_interne, 1100)  # Mauvais !

# ✅ Test du comportement public
def test_bon(self):
    compte = CompteBancaire("Alice", 1000)
    compte.deposer(100)
    # Testez l'interface publique
    self.assertEqual(compte.solde, 1100)  # Bon !

Conclusion

Les tests unitaires, ce n’est pas du “nice to have”, c’est ce qui te permet de faire évoluer ton code sans sueurs froides à chaque commit. Ils t’aident à détecter les bugs tôt, documenter ton code au passage et garder une base propre même après plusieurs refactors, tout en te donnant la confiance de shipper plus souvent sans peur de tout casser.

Commentaires (0)

Laisser un commentaire

Aucun commentaire pour le moment. Soyez le premier à commenter !