Archives Wild Code School
Python

Unit Tests in Python

Quête 62 sur 66

Un unit test (test unitaire : vérification d'un seul morceau de code isolé) est une façon de valider que des composants individuels (ou « unités ») d'un programme fonctionnent comme prévu. Dans la plupart des langages de programmation, une unité est en général une seule fonction, méthode ou classe. En écrivant et en exécutant des tests unitaires, tu peux t'assurer que des parties précises de ton code se comportent correctement, même quand tu fais des modifications et ajoutes des fonctionnalités.

Concepts clés du test unitaire :

  • Test isolé : un test unitaire vérifie un seul élément de fonctionnalité sans dépendre de dépendances externes comme des bases de données, des API ou d'autres composants. Ça garantit des tests ciblés et fiables.
  • Automatisation : les tests unitaires sont automatisés, ce qui veut dire qu'ils peuvent être relancés en boucle avec un effort minimal. C'est particulièrement utile pendant le développement pour attraper les bugs tôt.
  • Petits et rapides : les tests unitaires sont rapides à écrire et à exécuter, ce qui en fait une façon légère de vérifier ton code.
  • Prévention des régressions : les tests unitaires aident à s'assurer que de nouvelles modifications ne cassent pas accidentellement des fonctionnalités existantes.

Python fournit des outils puissants pour le test unitaire, dont le module intégré unittest, ainsi que des bibliothèques tierces comme pytest.


Premier exemple de test unitaire

Écrivons un test unitaire simple pour illustrer le concept. Prends la fonction Python suivante qui additionne deux nombres :

python
def add_numbers(a, b):
    return a + b

On veut tester cette fonction pour s'assurer qu'elle se comporte comme prévu.

Étapes pour écrire un test unitaire :

  1. Organiser le code :
    Enregistre la fonction dans un fichier Python, par exemple my_module.py.

    python
    # File: my_module.py
    def add_numbers(a, b):
        return a + b
    
  2. Créer un fichier de test :
    Crée un nouveau fichier Python pour le test, nommé en général test_<quelquechose>.py. Pour cet exemple, appelons-le test_math_operations.py.

  3. Importer la fonction :
    Utilise la syntaxe from <module> import <function> pour amener la fonction dans le fichier de test. Pour notre exemple :

    python
    from my_module import add_numbers  # Assume the function is in a file called my_module.py
    
  4. Définir une classe de test :
    Écris une classe de test qui hérite de unittest.TestCase, et définis des méthodes pour tester des scénarios précis.


Voici le code complet du fichier de test :

python
import unittest
from my_module import add_numbers

class TestMathOperations(unittest.TestCase):

    def test_add_positive_numbers(self):
        result = add_numbers(2, 3)
        self.assertEqual(result, 5)

    def test_add_negative_numbers(self):
        result = add_numbers(-1, -4)
        self.assertEqual(result, -5)

    def test_add_zero(self):
        result = add_numbers(0, 5)
        self.assertEqual(result, 5)

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

Explication des étapes :

  1. Enregistrer la fonction : stocke la fonction dans my_module.py. Ça permet au fichier de test de l'importer.
    python
    from my_module import add_numbers
    
  2. Écrire les tests : définis une classe avec des méthodes de test, chacune testant un comportement précis.
  3. Assertions (assertion : affirmation que le code vérifie, vraie ou fausse) : utilise self.assertEqual pour comparer le résultat attendu au résultat réel.
  4. Lancer les tests : utilise unittest.main() pour découvrir et exécuter les tests.

Lancer le test :

  1. Enregistre le fichier de test sous test_math_operations.py.
  2. Lance-le dans le terminal :
    bash
    python test_math_operations.py
    
  3. Observe la sortie :
    • Si les tests passent :
      ...
      ----------------------------------------------------------------------
      Ran 3 tests in 0.001s
      
      OK
      
    • Si un test échoue, tu verras des informations d'erreur détaillées.

Quand on parle de test unitaire, deux composants essentiels sont le Test Case (cas de test : un test ciblé sur un scénario précis) et le Test Runner (lanceur de tests : l'outil qui exécute les tests et rapporte les résultats). Ces concepts sont fondamentaux pour écrire et lancer des tests fiables en Python. Décortiquons-les avec précision et étoffons notre exemple.


Qu'est-ce qu'un Test Case ?

Un Test Case est un test structuré conçu pour valider un aspect précis de ton code. Chaque cas de test se concentre sur un scénario particulier, comme vérifier la sortie d'une fonction pour des entrées données.

Caractéristiques clés d'un Test Case :

  • Ciblé : chaque cas de test vise un comportement ou une fonctionnalité précise.
  • Indépendant : les cas de test ne devraient pas dépendre d'un état ou de données partagés.
  • Reproductible : un cas de test devrait toujours produire les mêmes résultats dans les mêmes conditions.

Qu'est-ce qu'un Test Runner ?

Un Test Runner est un outil qui automatise la découverte, l'exécution et le rapport des cas de test. Le module unittest de Python fournit un test runner intégré, qui fonctionne sans accroc depuis la ligne de commande ou dans des IDE comme PyCharm.

Capacités d'un Test Runner :

  • Découvre tous les cas de test dans les fichiers ou dossiers indiqués.
  • Exécute chaque test indépendamment et de façon isolée.
  • Rapporte clairement les résultats, en identifiant les tests réussis et échoués.

Exemple étoffé : tester add_numbers

Revenons sur l'exemple avec une erreur volontaire pour comprendre le retour fourni par le test runner.

Étape 1 : La fonction testée

Supposons qu'on ait la fonction suivante dans un fichier nommé my_module.py. Pour la démonstration, cette version contient une erreur volontaire :

python
# File: my_module.py
def add_numbers(a, b):
    return None  # Deliberate bug for demonstration

Cette implémentation renvoie toujours None, quelles que soient les entrées, ce qui va faire échouer les tests.

Étape 2 : Écrire les cas de test

On crée le fichier de test, test_math_operations.py, et on écrit des cas de test avec le framework unittest de Python :

python
import unittest
from my_module import add_numbers

class TestMathOperations(unittest.TestCase):

    def test_add_positive_numbers(self):
        result = add_numbers(2, 3)
        self.assertEqual(result, 5)

    def test_add_negative_numbers(self):
        result = add_numbers(-1, -4)
        self.assertEqual(result, -5)

    def test_add_zero(self):
        result = add_numbers(0, 5)
        self.assertEqual(result, 5)

    def test_add_large_numbers(self):
        result = add_numbers(12345, 67890)
        self.assertEqual(result, 80235)  # Testing with large inputs

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

Étape 3 : Lancer les tests

Depuis la ligne de commande, place-toi dans le dossier qui contient à la fois my_module.py et test_math_operations.py, puis exécute :

bash
python test_math_operations.py

Sortie attendue :

Comme la fonction add_numbers est défaillante (elle renvoie toujours None), tous les tests vont échouer. La sortie ressemblera à ceci :

FFFF
======================================================================
FAIL: test_add_positive_numbers (__main__.TestMathOperations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_operations.py", line 7, in test_add_positive_numbers
    self.assertEqual(result, 5)
AssertionError: None != 5

======================================================================
FAIL: test_add_negative_numbers (__main__.TestMathOperations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_operations.py", line 11, in test_add_negative_numbers
    self.assertEqual(result, -5)
AssertionError: None != -5

======================================================================
FAIL: test_add_zero (__main__.TestMathOperations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_operations.py", line 15, in test_add_zero
    self.assertEqual(result, 5)
AssertionError: None != 5

======================================================================
FAIL: test_add_large_numbers (__main__.TestMathOperations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_operations.py", line 19, in test_add_large_numbers
    self.assertEqual(result, 80235)
AssertionError: None != 80235

----------------------------------------------------------------------
Ran 4 tests in 0.001s

FAILED (failures=4)

Explication de la sortie :

  • F : chaque F représente un cas de test échoué.
  • Détails : pour chaque échec, la sortie fournit :
    • Le nom du cas de test (par exemple test_add_positive_numbers).
    • L'erreur d'assertion (None != 5), qui indique ce qui a cloché.
    • Le fichier et le numéro de ligne où l'échec s'est produit, ce qui aide à localiser le problème.

Corriger la fonction

Maintenant, corrigeons la fonction et relançons les tests :

python
# File: my_module.py
def add_numbers(a, b):
    return a + b  # Fixed implementation

Après correction, relancer les tests produira :

....
----------------------------------------------------------------------
Ran 4 tests in 0.001s

OK

Ça confirme que tous les tests passent désormais.

Le test unitaire en Python, ce n'est pas seulement vérifier les sorties attendues : il est aussi important de s'assurer que ton code gère correctement les situations inattendues, en levant les exceptions appropriées quand c'est nécessaire. En plus, à mesure que ton projet grossit, tu auras besoin d'organiser tes tests en Test Suites (suites de tests : regroupements de cas de test exécutés ensemble) pour les lancer de façon structurée et efficace.


Partie 1 : vérifier les exceptions avec assertRaises

Un des aspects les plus importants de la gestion des erreurs, c'est de s'assurer que ton code lève les bonnes exceptions quand les choses tournent mal. Le framework unittest fournit la méthode assertRaises dans ce but.

assertRaises te permet de vérifier qu'une exception précise est levée quand tu appelles une fonction ou exécutes un bloc de code.


Exemple : tester la division par zéro

Disons que tu as une fonction qui effectue une division, et que tu veux vérifier qu'elle lève une ZeroDivisionError quand on divise par zéro.

Voici l'implémentation de la fonction testée :

python
# File: my_calculator.py
def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("division by zero")
    return a / b

Dans ce cas, on s'attend à une ZeroDivisionError si le deuxième argument (b) vaut zéro. Si b n'est pas zéro, la fonction devrait renvoyer le résultat de la division.


Écrire le cas de test

Ensuite, on va écrire un cas de test avec assertRaises pour s'assurer que la fonction de division lève la bonne exception quand on divise par zéro.

python
import unittest
from my_calculator import divide

class TestCalculator(unittest.TestCase):

    def test_divide_valid(self):
        result = divide(10, 2)
        self.assertEqual(result, 5)  # 10 divided by 2 should be 5

    def test_divide_by_zero(self):
        # Check that ZeroDivisionError is raised when dividing by zero
        with self.assertRaises(ZeroDivisionError):
            divide(10, 0)

Comment fonctionne assertRaises

  • Context manager (with) (gestionnaire de contexte : bloc qui encadre un morceau de code avec une entrée et une sortie) : le code dont tu attends qu'il lève une exception est enveloppé dans un bloc with.
  • Résultat du test : si l'exception indiquée (ZeroDivisionError) est levée pendant l'exécution, le test passe. Si aucune exception, ou une exception différente, est levée, le test échoue.

Lancer les tests

Maintenant, si tu lances le fichier de test depuis la ligne de commande :

bash
python test_calculator.py

Tu verras la sortie suivante (si le code fonctionne correctement) :

..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

Si l'exception n'a pas été levée ou si une exception différente s'est produite, tu obtiendrais un message d'échec, du genre :

..
======================================================================
FAIL: test_divide_by_zero (__main__.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_calculator.py", line 13, in test_divide_by_zero
    divide(10, 0)
  File "my_calculator.py", line 4, in divide
    raise ZeroDivisionError("division by zero")
ZeroDivisionError: division by zero

Partie 2 : qu'est-ce qu'une Test Suite ?

À mesure que tes tests se multiplient, il devient important de regrouper les tests liés. Une Test Suite est simplement une collection de cas de test lancés ensemble par un test runner. Les suites de tests aident à organiser et exécuter les tests plus efficacement.

Une Test Suite peut être composée de :

  • Plusieurs classes de test : regrouper des tests liés provenant de classes différentes.
  • Plusieurs cas de test : même au sein d'une même classe, les tests peuvent être regroupés en une suite.

Voici comment créer une Test Suite combinant les vrais fichiers de test (test_math_operations.py et test_calculator.py) que tu as fournis :


Pourquoi utiliser une Test Suite ?

  1. Organisation : regrouper les tests liés pour améliorer la lisibilité et la maintenabilité du code.
  2. Contrôle de l'exécution : lancer facilement un groupe de tests ou se concentrer sur des sous-ensembles précis.
  3. Passage à l'échelle : à mesure que ton projet grossit, gérer les tests répartis sur plusieurs modules de façon structurée.

Créer une Test Suite

Tu as deux fichiers de test :

  • test_math_operations.py pour tester la fonction add_numbers.
  • test_calculator.py pour tester la fonction divide.

On va maintenant créer une Test Suite pour combiner et exécuter les tests des deux fichiers.


Implémentation de la Test Suite

python
import unittest
from test_math_operations import TestMathOperations
from test_calculator import TestCalculator

# Create a test suite combining multiple test classes
def suite():
    loader = unittest.TestLoader()
    suite = unittest.TestSuite()
    suite.addTests(loader.loadTestsFromTestCase(TestMathOperations))  # Add TestMathOperations
    suite.addTests(loader.loadTestsFromTestCase(TestCalculator))  # Add TestCalculator
    return suite

if __name__ == '__main__':
    # Run the test suite
    runner = unittest.TextTestRunner()
    runner.run(suite())

Comment ça marche

  1. Importer les classes de test :

    • Les classes de test (TestMathOperations et TestCalculator) sont importées depuis leurs fichiers respectifs.
  2. Ajouter les tests à la suite :

    • loader.loadTestsFromTestCase(TestMathOperations) : charge toutes les méthodes de test de la classe TestMathOperations.
    • loader.loadTestsFromTestCase(TestCalculator) : charge toutes les méthodes de test de la classe TestCalculator.
  3. Exécuter la suite :

    • Le unittest.TextTestRunner() lance tous les tests de la suite et rapporte les résultats.

Lancer la Test Suite

Enregistre le code ci-dessus dans un fichier nommé test_suite.py. Pour lancer la suite, exécute la commande suivante dans le terminal :

bash
python test_suite.py

Exemple de sortie

Si tous les tests passent :

......
----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

Si un test échoue (par exemple, add_numbers ou divide a un problème) :

.F....
======================================================================
FAIL: test_add_positive_numbers (test_math_operations.TestMathOperations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_operations.py", line 7, in test_add_positive_numbers
    self.assertEqual(result, 6)  # Intentional failure for demonstration
AssertionError: 5 != 6

----------------------------------------------------------------------
Ran 6 tests in 0.002s

FAILED (failures=1)

Pourquoi ignorer un cas de test ?

Parfois, pendant que tu développes ou refactores du code, certains tests ne sont pas prêts à être exécutés. Le module unittest en Python te permet d'ignorer ces tests. C'est utile quand :

  1. Un cas de test est incomplet (par exemple, en cours de développement ou en travaux).
  2. La fonctionnalité testée n'est pas encore implémentée.
  3. Un cas de test est temporairement sans objet (par exemple, lié à une fonctionnalité en cours de révision).

Le décorateur (decorator : syntaxe @ qui modifie le comportement d'une fonction) @unittest.skip te permet de marquer de tels tests.


Exemple : ignorer un test

Écrivons un exemple où un cas de test est marqué pour être ignoré :

python
import unittest
from my_module import add_numbers

class TestMathOperations(unittest.TestCase):

    def test_add_positive_numbers(self):
        result = add_numbers(2, 3)
        self.assertEqual(result, 5)

    @unittest.skip("Work in progress")
    def test_add_large_numbers(self):
        result = add_numbers(12345, 67890)
        self.assertEqual(result, 80235)  # Test is skipped

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

Sortie quand on ignore un test

.s
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK (skipped=1)

Ici, le s indique le test ignoré, et la raison "Work in progress" est affichée.


Explication des Test Fixtures

Un Test Fixture (fixture : préparation nécessaire pour qu'un test tourne de façon fiable et indépendante) désigne la préparation requise pour que tes cas de test s'exécutent de façon fiable et indépendante. Le framework unittest fournit deux méthodes clés pour gérer les fixtures de test :

  1. setUp : exécutée avant chaque méthode de test pour initialiser les ressources.
  2. tearDown : exécutée après chaque méthode de test pour nettoyer les ressources.

Les fixtures garantissent que les cas de test restent indépendants les uns des autres en préparant un état neuf pour chaque test.


Exemple : utiliser des Test Fixtures

Supposons qu'on veuille tester une fonction multiply de my_module.py. Au lieu d'initialiser la même préparation dans chaque méthode de test, on utilise setUp pour la préparation.

python
import unittest
from my_module import multiply

class TestMathOperations(unittest.TestCase):

    def setUp(self):
        """Set up test fixtures."""
        self.a = 6
        self.b = 7

    def tearDown(self):
        """Clean up after test methods."""
        print("Cleaning up resources...")

    def test_multiply_positive_numbers(self):
        result = multiply(self.a, self.b)
        self.assertEqual(result, 42)

    def test_multiply_with_zero(self):
        result = multiply(self.a, 0)
        self.assertEqual(result, 0)

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

Ordre d'exécution des méthodes de fixture

  1. setUp est appelée avant chaque méthode de test.
  2. La méthode de test est exécutée.
  3. tearDown est appelée après la méthode de test, même si une exception se produit pendant le test.

Exemple de sortie

Cleaning up resources...
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

Si une exception se produit pendant un test, tearDown s'exécute quand même pour nettoyer les ressources. En revanche, si une exception se produit dans setUp, le test et tearDown sont ignorés.

Voici la section complète réécrite pour Exemple de mauvaise conception de test unitaire et meilleure conception avec Arrange-Act-Assert, avec un exemple concret basé sur un module LibraryCatalog.


Exemple de mauvaise conception de test unitaire et meilleure conception avec Arrange-Act-Assert

Mauvaise conception de test unitaire

Commençons par un exemple de mauvaise conception de test unitaire. Prends la classe LibraryCatalog, qui suit les titres de livres d'une bibliothèque. Elle a une méthode is_consistent qui vérifie les doublons ou les relations de préfixe entre les titres de livres.

Voici un exemple de mauvaise conception de test unitaire :

python
import unittest
from library_catalog import LibraryCatalog

class TestLibraryCatalogPoorDesign(unittest.TestCase):
    def test_is_consistent(self):
        catalog = LibraryCatalog()

        # Add a book and check consistency
        catalog.add_book("The Great Gatsby")
        self.assertTrue(catalog.is_consistent())

        # Add another book and check consistency
        catalog.add_book("To Kill a Mockingbird")
        self.assertTrue(catalog.is_consistent())

        # Add a duplicate and check consistency
        catalog.add_book("The Great Gatsby")  # Duplicate
        self.assertFalse(catalog.is_consistent())

        # Add a book with a prefix relationship and check consistency
        catalog.add_book("The")  # Prefix of "The Great Gatsby"
        self.assertFalse(catalog.is_consistent())
Problèmes de cette conception
  1. L'exécution s'arrête au premier échec : si le test échoue sur un assert, les étapes suivantes du test sont ignorées. Du coup, des problèmes ultérieurs peuvent passer inaperçus.
  2. Résultats ambigus : quand ce test échoue, on ne sait pas quel scénario précis a causé l'échec.
  3. Mauvaise lisibilité : mélanger plusieurs scénarios dans un seul test rend plus difficile de comprendre l'intention du test ou de diagnostiquer les problèmes.

Meilleure conception avec Arrange-Act-Assert

Pour régler les problèmes ci-dessus, on peut réécrire le cas de test en tests plus petits et ciblés, en suivant le motif Arrange-Act-Assert (Préparer-Agir-Vérifier : structure en trois temps d'un test) :

  1. Arrange : met en place les conditions du test.
  2. Act : exécute le comportement testé.
  3. Assert : vérifie le résultat attendu.

Voici comment les mêmes scénarios peuvent être réécrits :

python
import unittest
from library_catalog import LibraryCatalog

class TestLibraryCatalog(unittest.TestCase):
    
    def test_consistent_with_unique_titles(self):
        # Arrange
        catalog = LibraryCatalog()
        catalog.add_book("The Great Gatsby")
        catalog.add_book("To Kill a Mockingbird")

        # Act
        result = catalog.is_consistent()

        # Assert
        self.assertTrue(result)

    def test_inconsistent_with_duplicate_titles(self):
        # Arrange
        catalog = LibraryCatalog()
        catalog.add_book("The Great Gatsby")
        catalog.add_book("The Great Gatsby")  # Duplicate title

        # Act
        result = catalog.is_consistent()

        # Assert
        self.assertFalse(result)

    def test_inconsistent_with_prefix_titles(self):
        # Arrange
        catalog = LibraryCatalog()
        catalog.add_book("The Great Gatsby")
        catalog.add_book("The")  # Title is a prefix of an existing title

        # Act
        result = catalog.is_consistent()

        # Assert
        self.assertFalse(result)
Bénéfices de la meilleure conception
  1. Clarté : chaque test se concentre clairement sur un scénario, ce qui rend facile de comprendre ce qui est testé et pourquoi.
  2. Retour immédiat : quand un test échoue, tu sais exactement quel scénario a causé le problème.
  3. Modularité : les tests peuvent être lancés indépendamment, et ils suivent la structure Arrange-Act-Assert pour la lisibilité.

Implémentation de la classe LibraryCatalog

Voici l'implémentation de la classe LibraryCatalog qui fonctionne avec les tests :

python
class LibraryCatalog:
    def __init__(self):
        # The catalog will be a dictionary with titles as keys
        self.catalog = {}

    def add_book(self, title):
        """Adds a book to the catalog."""
        if title in self.catalog:
            raise ValueError(f"Book '{title}' already exists in the catalog.")
        self.catalog[title] = True

    def update_book(self, old_title, new_title):
        """Updates the title of a book in the catalog."""
        if old_title not in self.catalog:
            raise KeyError(f"Book '{old_title}' not found in the catalog.")
        del self.catalog[old_title]
        self.catalog[new_title] = True

    def is_consistent(self):
        """Checks catalog consistency:
        - No duplicate titles.
        - No title is a prefix of another.
        """
        titles = list(self.catalog.keys())
        for i in range(len(titles)):
            for j in range(len(titles)):
                if i != j and (titles[i].startswith(titles[j]) or titles[j].startswith(titles[i])):
                    return False
        return True

Comment lancer les tests

  1. Enregistre l'implémentation de LibraryCatalog dans un fichier nommé library_catalog.py.
  2. Enregistre les tests dans un fichier nommé test_library_catalog.py dans le même dossier.
  3. Lance les tests avec la commande :
    bash
    python test_library_catalog.py
    

Sortie attendue

Tous les tests passent :

...
----------------------------------------------------------------------
Ran 3 tests in 0.002s

OK

Si un test échoue (par exemple, is_consistent a un bug) :

.F..
======================================================================
FAIL: test_inconsistent_with_duplicate_titles (test_library_catalog.TestLibraryCatalog)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_library_catalog.py", line 20, in test_inconsistent_with_duplicate_titles
    self.assertFalse(result)
AssertionError: True is not false

----------------------------------------------------------------------
Ran 3 tests in 0.003s

FAILED (failures=1)

Pourquoi le test unitaire compte

Le test unitaire est un aspect essentiel du développement logiciel qui offre de nombreux bénéfices aux développeurs et aux équipes. Il ne s'agit pas seulement d'écrire des tests : il s'agit d'améliorer ton code et ton processus de développement. Explorons quelques-unes des raisons clés d'investir dans le test unitaire :

  1. Clarifier les besoins :
    Écrire des tests unitaires t'oblige à définir ce que chaque unité de ton système est censée faire. Ce processus implique souvent une collaboration avec des parties prenantes comme des analystes métier, des testeurs ou des designers. Même en travaillant seul, créer des tests t'aide à affiner ta compréhension du problème, à repérer les manques et à t'assurer que ton code correspond à la fonctionnalité souhaitée.

  2. Documenter le système :
    Les tests unitaires servent de documentation détaillée du comportement du système. Des tests bien écrits illustrent comment les différentes parties du système devraient fonctionner, fournissant un contexte précieux pour les nouveaux développeurs qui rejoignent le projet. Comme les tests unitaires sont exécutables, ils restent à jour à mesure que le code évolue.

  3. Favoriser une meilleure conception :
    Le test unitaire encourage des conceptions modulaires et faiblement couplées. Le besoin de tester des unités de code précises indépendamment favorise une forte cohésion et un faible couplage, des principes du logiciel maintenable. Écrire des tests aide aussi à séparer l'interface (comment une unité est utilisée) de l'implémentation, ce qui aboutit à des conceptions plus propres et plus flexibles.

  4. Prévenir les régressions (régression : une fonctionnalité qui marchait et qui ne marche plus après un changement) :
    Une fois que tu as une suite de tests en place, elle sert de filet de sécurité. Quand tu modifies le code, les tests peuvent détecter rapidement les régressions : des fonctionnalités qui marchaient et qui ne marchent plus. Ça te permet de refactorer et d'étendre ton système en confiance.

  5. Faciliter le refactoring :
    Avec des tests complets, tu peux refactorer ton code en toute sécurité pour en améliorer la conception ou la performance. Si tes tests passent après les modifications, tu peux être confiant que le comportement de ton système reste intact.


Intégrer le test unitaire dans ton workflow

Différents développeurs ont des approches variées pour intégrer le test unitaire dans leur workflow (flux de travail : façon d'enchaîner ses étapes de travail). Voici trois méthodes courantes :

  1. Tests en dernier (Tests Last) :
    Dans cette approche, tu écris le code d'abord et ajoutes les tests ensuite. Même si ça te permet de finaliser la conception avant d'investir dans les tests, ça comporte des risques importants :

    • Conception non testable : découvrir tard des défauts de conception peut rendre difficile l'écriture de tests efficaces.
    • Détection de bugs tardive : les bugs peuvent n'apparaître que tard dans le processus, ce qui mène à des tests précipités ou sautés.
    • Retravail : tu pourrais devoir refactorer le code largement pour le rendre testable.
  2. Tests en premier (Tests First) :
    Ici, tu conçois une suite complète de cas de test en amont avant d'écrire le code. Ça aide à définir tôt une interface testable et garantit une bonne couverture de tests. Toutefois :

    • Compréhension incomplète : c'est dur d'anticiper tous les cas limites au départ.
    • Difficultés d'itération : des changements de conception peuvent demander de retravailler les tests, ce qui réduit l'efficacité.
  3. Test-Driven Development (TDD) (développement piloté par les tests : écrire le test avant le code, par petites itérations) :
    Le TDD est une approche itérative où tu écris un test pour un petit bout de fonctionnalité, implémentes juste assez de code pour faire passer le test, puis refactores. Répète ce processus pour construire le système petit à petit. Le TDD intègre test et développement, ce qui aboutit à :

    • Des conceptions de haute qualité et testables.
    • Une progression incrémentale avec moins de surprises.
    • Un workflow discipliné qui équilibre test et implémentation.

Défis et limites

Même si le test unitaire est bénéfique, il a ses limites :

  • Dépendances et couplage : tester du code fortement couplé peut être difficile. Ça révèle souvent des problèmes de conception plus profonds.
  • Effet « réalité virtuelle » : les tests peuvent passer dans un environnement contrôlé mais ne pas représenter fidèlement les scénarios du monde réel.
  • Tests d'intégration et non fonctionnels : les tests unitaires se concentrent sur les composants individuels et ne peuvent pas attraper les problèmes d'intégration ni évaluer la performance, le passage à l'échelle ou la sécurité. Compléter les tests unitaires par des tests d'intégration et d'autres méthodes est essentiel.

Conclusion

Le test unitaire est plus qu'une tâche technique : c'est un état d'esprit qui améliore ton code, ta conception et ton processus de développement. Que tu adoptes le tests-first, le tests-last ou le TDD, intégrer des tests automatisés dans ton workflow est essentiel pour livrer un logiciel de haute qualité et maintenable. Même s'il y a des défis, les bénéfices (meilleures conceptions, moins de bugs, refactoring plus facile) font du test unitaire un outil indispensable pour tout développeur.

Pytest est un framework de test unitaire très populaire en Python, connu pour sa simplicité, sa flexibilité et ses fonctionnalités puissantes. Comparé au module unittest, qui suit le motif du framework de test xUnit, pytest fournit une façon plus pythonique d'écrire et de gérer des tests. Plongeons dans les détails de comment travailler avec pytest.

1. Pourquoi utiliser Pytest ?

Pytest est souvent préféré à unittest grâce à :

  • Sa syntaxe concise : pas besoin de code répétitif, comme des classes ou l'héritage de TestCase pour écrire des tests.
  • Son reporting puissant : des messages d'échec de test détaillés et faciles à interpréter.
  • Son écosystème riche : des plugins (extensions qui ajoutent des fonctionnalités) pour divers besoins, comme générer des rapports HTML, tester la performance, etc.

2. Installer Pytest

Pour utiliser pytest, tu dois l'installer séparément, car il n'est pas inclus dans la bibliothèque standard de Python.

Installation :

Lance la commande suivante dans le terminal pour installer pytest avec pip :

bash
pip install pytest

Convention de nommage des fichiers :

  • Les fichiers de test doivent être nommés test_<quelquechose>.py pour être reconnus par pytest.
  • Les fonctions de test doivent commencer par test_, suivant la convention pour que pytest les identifie comme des tests.

3. Écrire des cas de test avec Pytest

Fonction de test basique

Avec pytest, tu n'as pas besoin de créer une classe de test. Une simple fonction qui commence par test_ suffit pour écrire un test. C'est une façon plus pythonique et plus simple de définir des tests comparé à unittest.

Exemple :

Testons une opération mathématique simple (l'addition) :

python
# test_math_operations.py

def test_addition():
    assert 2 + 3 == 5

def test_subtraction():
    assert 5 - 3 == 2
  • Assertions : tu utilises l'instruction assert pour vérifier qu'une condition est vraie. Si la condition est fausse, pytest marquera le test comme échoué.

  • Lancer les tests : tu peux lancer les tests simplement en appelant pytest depuis le terminal :

    bash
    pytest
    

Pytest découvrira automatiquement tous les fichiers qui correspondent à test_*.py et exécutera les fonctions de test qu'ils contiennent.


4. Comprendre les échecs de test

Quand un test échoue, pytest fournit une sortie détaillée qui peut t'aider à comprendre exactement ce qui a cloché.

Exemple de sortie :

E       AssertionError: assert 4 == 5
  • La ligne E indique une erreur.
  • AssertionError: assert 4 == 5 est le message d'erreur qui montre ce qui était attendu et ce qui a réellement été renvoyé.
  • Cette erreur te donne à la fois la valeur attendue (5) et la valeur réelle (4).

5. Les Test Fixtures dans Pytest

Une test fixture est une façon de mettre en place des préconditions pour un test et de nettoyer après, similaire aux méthodes setUp() et tearDown() de unittest. Les fixtures de pytest se définissent avec le décorateur @pytest.fixture.

Exemple de fixture simple :

python
import pytest

@pytest.fixture
def sample_data():
    return {"name": "Alice", "age": 30}

def test_data_retrieval(sample_data):
    assert sample_data["name"] == "Alice"
    assert sample_data["age"] == 30
  • La fonction sample_data() est une fixture qui renvoie un dictionnaire avec des données d'exemple.
  • La fonction de test test_data_retrieval() prend sample_data en argument. Pytest fournit automatiquement la fixture sample_data à la fonction de test.

6. Nettoyer après les tests avec les Fixtures

Les fixtures dans pytest sont très flexibles et te permettent d'ajouter aussi du code de nettoyage.

Exemple avec nettoyage :

Disons que tu as besoin de créer un fichier pour tes tests et de le supprimer après le test :

python
import pytest

@pytest.fixture
def temp_file(tmpdir):
    file = tmpdir.join("sample.txt")
    file.write("Hello, pytest!")
    return file

def test_file_creation(temp_file):
    assert temp_file.read() == "Hello, pytest!"
  • tmpdir est une fixture intégrée à pytest qui te donne un dossier temporaire pour le test. La fixture temp_file l'utilisera pour créer un fichier.
  • Une fois le test terminé, tmpdir supprime automatiquement le fichier et le dossier temporaires.

7. Gérer les exceptions dans Pytest

Dans certains cas, tu peux vouloir affirmer que certaines erreurs ou exceptions sont levées pendant l'exécution du test.

Exemple de test d'exceptions :

python
import pytest

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

def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)
  • pytest.raises(ZeroDivisionError) garantit que la ZeroDivisionError est levée quand on divise par zéro.
  • Si aucune exception n'est levée ou si une exception différente est levée, le test échouera.

8. Organiser le code de test avec Pytest

Dans les projets plus gros, organiser ton code de test est crucial pour la maintenabilité. Voici comment structurer ton projet :

Exemple de structure de projet :

my_project/
    src/
        phonebook.py      # Production code
    tests/
        __init__.py       # Marks this directory as a Python package
        test_phonebook.py # Test file
        conftest.py       # Test fixtures
    pytest.ini            # Pytest configuration
  • Code de production : le code source est placé dans le dossier src/.
  • Code de test : les tests sont situés dans le dossier tests/. Les fixtures de test peuvent être placées dans le fichier conftest.py, qui est un fichier spécial dans pytest. Il est découvert automatiquement et ses fixtures sont disponibles pour tous les fichiers de test du même dossier et des sous-dossiers.
  • Configuration : le fichier pytest.ini sert à configurer les réglages de pytest pour ton projet, comme les chemins de test, les marqueurs et d'autres options.

Exemple de pytest.ini :

ini
[pytest]
testpaths = tests

Cette configuration dit à pytest de chercher les tests uniquement dans le dossier tests.


9. Fixtures Autouse

Parfois, tu veux qu'une fixture s'applique automatiquement à chaque test sans la mentionner explicitement. Tu peux utiliser l'option autouse=True.

Exemple :

python
import pytest

@pytest.fixture(autouse=True)
def setup_and_teardown():
    print("Setting up test")
    yield
    print("Tearing down test")

def test_example_1():
    assert 1 + 1 == 2

def test_example_2():
    assert "hello".upper() == "HELLO"
  • Dans l'exemple ci-dessus, la fixture setup_and_teardown sera appliquée automatiquement aux deux fonctions de test, et elle affichera "Setting up test" avant chaque test et "Tearing down test" après.

10. Fixtures avancées : utiliser yield pour le nettoyage

Tu peux utiliser yield dans une fixture pour séparer le code de préparation et de nettoyage. Tout ce qui est avant le yield est la préparation, et tout ce qui est après est le nettoyage.

Exemple :

python
import pytest

@pytest.fixture
def phonebook():
    pb = Phonebook()  # setup
    yield pb
    pb.clear()  # teardown

def test_add_entry(phonebook):
    phonebook.add("Alice", "12345")
    assert phonebook.lookup("Alice") == "12345"
  • Le code avant yield initialise la fixture, et le code après yield effectue le nettoyage une fois le test terminé.

11. Utiliser les plugins Pytest

Pytest prend en charge une grande variété de plugins qui peuvent étendre ses fonctionnalités. Par exemple, tu peux générer des rapports HTML pour tes résultats de test avec le plugin pytest-html.

Exemple avec pytest-html :

  1. Installe le plugin :
    bash
    pip install pytest-html
    
  2. Génère un rapport HTML :
    bash
    pytest --html=report.html
    
  • Cette commande lancera les tests et générera un rapport HTML avec les résultats de test que tu peux consulter dans un navigateur.

Défi : Créer et améliorer des tests unitaires

Contexte :

Tu es chargé de tester une petite application Python qui gère des utilisateurs et leurs informations personnelles. L'application doit inclure les fonctionnalités suivantes :

  • Ajouter un utilisateur avec un nom et un âge.
  • Vérifier si un utilisateur existe d'après son nom.
  • Supprimer un utilisateur d'après son nom.

Objectifs :

  1. Écrire un test unitaire pour vérifier que l'ajout d'un utilisateur fonctionne correctement.
  2. Tester que la suppression d'un utilisateur fonctionne comme prévu.
  3. Tester les exceptions levées quand des actions incorrectes sont tentées (par exemple, essayer de supprimer un utilisateur inexistant).
  4. Appliquer une meilleure conception de test en utilisant la structure Arrange-Act-Assert.
  5. Appliquer une technique de skipping (fait d'ignorer volontairement certains tests) pour ignorer certains tests.

Instructions :

  1. Crée une classe UserManager pour gérer les utilisateurs.
  2. Implémente des méthodes pour :
    • Ajouter un utilisateur (add_user(name, age)).
    • Vérifier si un utilisateur existe (user_exists(name)).
    • Supprimer un utilisateur (delete_user(name)).
  3. Crée un fichier de test avec unittest pour tester la classe UserManager :
    • Tester l'ajout d'un utilisateur avec nom et âge.
    • Tester la suppression d'un utilisateur existant.
    • Tester la levée d'une exception quand on essaie de supprimer un utilisateur qui n'existe pas.
    • Utiliser Arrange-Act-Assert dans la conception de tes tests.
    • Utiliser assertRaises pour tester les exceptions.
    • Tester avec la fonctionnalité de skipping pour ignorer certains tests.

Code de l'application à tester :

python
class UserManager:
    def __init__(self):
        self.users = {}
    
    def add_user(self, name, age):
        if name in self.users:
            raise ValueError("User already exists")
        self.users[name] = age
    
    def user_exists(self, name):
        return name in self.users
    
    def delete_user(self, name):
        if name not in self.users:
            raise ValueError("User not found")
        del self.users[name]

Exemples de tests unitaires avec unittest :

Exercice 1 : Crée un test pour vérifier que l'ajout d'un utilisateur fonctionne correctement.

Exercice 2 : Crée un test pour vérifier que la suppression d'un utilisateur fonctionne correctement.

Exercice 3 : Crée un test qui vérifie qu'une ValueError est levée quand on essaie de supprimer un utilisateur qui n'existe pas.

Exercice 4 : Applique le motif Arrange-Act-Assert pour mieux organiser tes tests.

Exercice 5 : Crée un test qui est ignoré avec le décorateur @unittest.skip.

Solution :

Voici la solution de ce défi avec les tests unitaires nécessaires, suivie des bonnes pratiques.

Code des tests :

python
import unittest

class TestUserManager(unittest.TestCase):
    def setUp(self):
        """This method is called before each test. It prepares the test environment."""
        self.user_manager = UserManager()
        
    def test_add_user(self):
        """Test for adding a user"""
        # Arrange: prepare the input data
        name = "Alice"
        age = 30
        # Act: call the add_user method
        self.user_manager.add_user(name, age)
        # Assert: verify if the user exists
        self.assertTrue(self.user_manager.user_exists(name))
        
    def test_delete_user(self):
        """Test for deleting an existing user"""
        # Arrange
        name = "Bob"
        self.user_manager.add_user(name, 25)
        # Act
        self.user_manager.delete_user(name)
        # Assert
        self.assertFalse(self.user_manager.user_exists(name))
        
    def test_delete_non_existent_user(self):
        """Test to verify that an exception is raised when deleting a non-existent user"""
        # Arrange
        name = "Charlie"
        # Act & Assert
        with self.assertRaises(ValueError):
            self.user_manager.delete_user(name)
            
    def test_add_existing_user(self):
        """Test to verify that adding an already existing user raises an exception"""
        # Arrange
        name = "David"
        self.user_manager.add_user(name, 28)
        # Act & Assert
        with self.assertRaises(ValueError):
            self.user_manager.add_user(name, 30)
            
    @unittest.skip("This test is currently skipped.")
    def test_skip_example(self):
        """This test is skipped"""
        # Arrange
        name = "Eve"
        self.user_manager.add_user(name, 22)
        # Act
        self.user_manager.delete_user(name)
        # Assert
        self.assertFalse(self.user_manager.user_exists(name))
        
    def test_arrange_act_assert_pattern(self):
        """Example of organizing the test according to the Arrange-Act-Assert pattern"""
        # Arrange
        name = "Frank"
        age = 35
        # Act
        self.user_manager.add_user(name, age)
        # Assert
        self.assertTrue(self.user_manager.user_exists(name))
        self.assertEqual(self.user_manager.users[name], age)
        
if __name__ == "__main__":
    unittest.main()

Explication des tests :

  1. Test test_add_user :

    • Arrange : on définit le nom et l'âge de l'utilisateur.
    • Act : on appelle la méthode add_user pour ajouter un utilisateur.
    • Assert : on vérifie que l'utilisateur existe dans user_manager avec user_exists.
  2. Test test_delete_user :

    • Arrange : on ajoute d'abord un utilisateur.
    • Act : on supprime cet utilisateur.
    • Assert : on confirme que l'utilisateur n'existe plus.
  3. Test test_delete_non_existent_user :

    • Arrange : on définit un utilisateur qui n'existe pas.
    • Act : on tente de supprimer cet utilisateur.
    • Assert : on s'assure qu'une exception ValueError est levée quand on essaie de supprimer un utilisateur inexistant.
  4. Test test_add_existing_user :

    • Arrange : on ajoute un utilisateur.
    • Act : on tente d'ajouter le même utilisateur à nouveau, ce qui devrait lever une exception.
    • Assert : on vérifie qu'une exception ValueError est bien levée.
  5. Test test_skip_example :

    • Ce test est ignoré avec le décorateur @unittest.skip. Il montre comment ignorer un test précis.
  6. Test test_arrange_act_assert_pattern :

    • Ce test est structuré avec le motif Arrange-Act-Assert. Ça garantit que chaque phase du test est clairement séparée et facile à comprendre.

A unit test is a way to validate that individual components (or "units") of a program work as expected. In most programming languages, a unit is typically a single function, method, or class. By writing and running unit tests, you can ensure that specific parts of your code behave correctly, even as you make changes and add features.

Key Concepts of Unit Testing:

  • Isolated Testing: A unit test checks one piece of functionality without relying on external dependencies like databases, APIs, or other components. This ensures tests are focused and reliable.
  • Automation: Unit tests are automated, meaning they can be run repeatedly with minimal effort. This is especially useful during development to catch bugs early.
  • Small and Fast: Unit tests are quick to write and execute, making them a lightweight way to verify code.
  • Regression Prevention: Unit tests help ensure that new changes don't accidentally break existing functionality.

Python provides powerful tools for unit testing, including the built-in unittest module, as well as third-party libraries like pytest.


First Example Unit Test

Let’s write a simple unit test to demonstrate the concept. Consider the following Python function that adds two numbers:

python
def add_numbers(a, b):
    return a + b

We want to test this function to ensure it behaves as expected.

Steps to Write a Unit Test:

  1. Organize the Codebase:
    Save the function in a Python file, such as my_module.py.

    python
    # File: my_module.py
    def add_numbers(a, b):
        return a + b
    
  2. Create a Test File:
    Create a new Python file for the test, typically named test_<something>.py. For this example, let’s call it test_math_operations.py.

  3. Import the Function:
    Use the from <module> import <function> syntax to bring the function into the test file. For our example:

    python
    from my_module import add_numbers  # Assume the function is in a file called my_module.py
    
  4. Define a Test Class:
    Write a test class that inherits from unittest.TestCase, and define methods to test specific scenarios.


Here’s the complete code for the test file:

python
import unittest
from my_module import add_numbers

class TestMathOperations(unittest.TestCase):

    def test_add_positive_numbers(self):
        result = add_numbers(2, 3)
        self.assertEqual(result, 5)

    def test_add_negative_numbers(self):
        result = add_numbers(-1, -4)
        self.assertEqual(result, -5)

    def test_add_zero(self):
        result = add_numbers(0, 5)
        self.assertEqual(result, 5)

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

Explanation of the Steps:

  1. Save the Function: Store the function in my_module.py. This ensures the test file can import it.
    python
    from my_module import add_numbers
    
  2. Write Tests: Define a class with test methods, each testing a specific behavior.
  3. Assertions: Use self.assertEqual to check expected vs. actual results.
  4. Run the Tests: Use unittest.main() to discover and execute tests.

Running the Test:

  1. Save the test file as test_math_operations.py.
  2. Run it in the terminal:
    bash
    python test_math_operations.py
    
  3. Observe the output:
    • If tests pass:
      ...
      ----------------------------------------------------------------------
      Ran 3 tests in 0.001s
      
      OK
      
    • If a test fails, you’ll see detailed error information.

When discussing unit testing, two critical components are the Test Case and the Test Runner. These concepts are foundational to writing and running reliable tests in Python. Let’s break them down with precision and expand our example.


What Is a Test Case?

A Test Case is a structured test designed to validate a specific aspect of your code. Each test case focuses on a particular scenario, such as verifying a function's output for given inputs.

Key Characteristics of a Test Case:

  • Focused: Each test case targets one specific behavior or functionality.
  • Independent: Test cases should not rely on shared state or data.
  • Repeatable: A test case should always produce the same results under the same conditions.

What Is a Test Runner?

A Test Runner is a tool that automates the discovery, execution, and reporting of test cases. Python’s unittest module provides a built-in test runner, which works seamlessly from the command line or within IDEs like PyCharm.

Test Runner Capabilities:

  • Discovers all test cases in the specified files or folders.
  • Executes each test independently and in isolation.
  • Reports results clearly, identifying passed and failed tests.

Expanded Example: Testing add_numbers

Let’s revisit the example with an intentional failure to understand the feedback provided by the test runner.

Step 1: The Function Under Test

Suppose we have the following function in a file named my_module.py. For demonstration purposes, this version contains a deliberate error:

python
# File: my_module.py
def add_numbers(a, b):
    return None  # Deliberate bug for demonstration

This implementation always returns None, regardless of the inputs, which will cause the tests to fail.

Step 2: Writing the Test Cases

We create the test file, test_math_operations.py, and write test cases using Python’s unittest framework:

python
import unittest
from my_module import add_numbers

class TestMathOperations(unittest.TestCase):

    def test_add_positive_numbers(self):
        result = add_numbers(2, 3)
        self.assertEqual(result, 5)

    def test_add_negative_numbers(self):
        result = add_numbers(-1, -4)
        self.assertEqual(result, -5)

    def test_add_zero(self):
        result = add_numbers(0, 5)
        self.assertEqual(result, 5)

    def test_add_large_numbers(self):
        result = add_numbers(12345, 67890)
        self.assertEqual(result, 80235)  # Testing with large inputs

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

Step 3: Running the Tests

From the command line, navigate to the folder containing both my_module.py and test_math_operations.py, then execute:

bash
python test_math_operations.py

Expected Output:

Since the function add_numbers is faulty (it always returns None), all tests will fail. The output will look like this:

FFFF
======================================================================
FAIL: test_add_positive_numbers (__main__.TestMathOperations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_operations.py", line 7, in test_add_positive_numbers
    self.assertEqual(result, 5)
AssertionError: None != 5

======================================================================
FAIL: test_add_negative_numbers (__main__.TestMathOperations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_operations.py", line 11, in test_add_negative_numbers
    self.assertEqual(result, -5)
AssertionError: None != -5

======================================================================
FAIL: test_add_zero (__main__.TestMathOperations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_operations.py", line 15, in test_add_zero
    self.assertEqual(result, 5)
AssertionError: None != 5

======================================================================
FAIL: test_add_large_numbers (__main__.TestMathOperations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_operations.py", line 19, in test_add_large_numbers
    self.assertEqual(result, 80235)
AssertionError: None != 80235

----------------------------------------------------------------------
Ran 4 tests in 0.001s

FAILED (failures=4)

Explanation of the Output:

  • F: Each F represents a failed test case.
  • Details: For each failure, the output provides:
    • The test case name (e.g., test_add_positive_numbers).
    • The assertion error (None != 5), indicating what went wrong.
    • The file and line number where the failure occurred, helping locate the problem.

Fixing the Function

Now, let’s correct the function and rerun the tests:

python
# File: my_module.py
def add_numbers(a, b):
    return a + b  # Fixed implementation

After fixing, running the tests again will produce:

....
----------------------------------------------------------------------
Ran 4 tests in 0.001s

OK

This confirms that all tests now pass.

Unit testing in Python is about more than just checking the expected outputs—it’s also important to ensure your code handles unexpected situations correctly, raising the appropriate exceptions when necessary. Additionally, as your project grows, you’ll need to organize your tests into Test Suites to run them in a structured and efficient way.


Part 1: Exception Checking with assertRaises

One of the most important aspects of error handling is making sure that your code raises the appropriate exceptions when things go wrong. The unittest framework provides the assertRaises method for this purpose.

assertRaises allows you to verify that a specific exception is raised when you call a function or run a block of code.


Example: Testing Division by Zero

Let’s say you have a function that performs division, and you want to check if it raises a ZeroDivisionError when dividing by zero.

Here is the implementation of the function under test:

python
# File: my_calculator.py
def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("division by zero")
    return a / b

In this case, we expect a ZeroDivisionError if the second argument (b) is zero. If b is not zero, the function should return the result of the division.


Writing the Test Case

Next, we’ll write a test case using assertRaises to ensure that the division function raises the correct exception when dividing by zero.

python
import unittest
from my_calculator import divide

class TestCalculator(unittest.TestCase):

    def test_divide_valid(self):
        result = divide(10, 2)
        self.assertEqual(result, 5)  # 10 divided by 2 should be 5

    def test_divide_by_zero(self):
        # Check that ZeroDivisionError is raised when dividing by zero
        with self.assertRaises(ZeroDivisionError):
            divide(10, 0)

How assertRaises Works

  • Context Manager (with): The code you expect to raise an exception is wrapped in a with block.
  • Test Outcome: If the specified exception (ZeroDivisionError) is raised during execution, the test passes. If no exception or a different exception is raised, the test fails.

Running the Tests

Now, if you run the test file from the command line:

bash
python test_calculator.py

You will see the following output (if the code works correctly):

..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

If the exception wasn’t raised or a different exception occurred, you would get a failure message, such as:

..
======================================================================
FAIL: test_divide_by_zero (__main__.TestCalculator)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_calculator.py", line 13, in test_divide_by_zero
    divide(10, 0)
  File "my_calculator.py", line 4, in divide
    raise ZeroDivisionError("division by zero")
ZeroDivisionError: division by zero

Part 2: What Is a Test Suite?

As your tests grow in number, it becomes important to group related tests together. A Test Suite is simply a collection of test cases that are run together by a test runner. Test suites help organize and execute tests more efficiently.

A Test Suite can consist of:

  • Multiple Test Classes: Grouping related tests from different classes.
  • Multiple Test Cases: Even within the same class, tests can be grouped into a suite.

Here’s how you can create a Test Suite combining the actual test files (test_math_operations.py and test_calculator.py) you provided:


Why Use a Test Suite?

  1. Organization: Group related tests together to improve code readability and maintainability.
  2. Execution Control: Easily run a group of tests or focus on specific subsets of tests.
  3. Scalability: As your project grows, manage tests across multiple modules in a structured way.

Creating a Test Suite

You have two test files:

  • test_math_operations.py for testing the add_numbers function.
  • test_calculator.py for testing the divide function.

We will now create a Test Suite to combine and execute the tests from both files.


Test Suite Implementation

python
import unittest
from test_math_operations import TestMathOperations
from test_calculator import TestCalculator

# Create a test suite combining multiple test classes
def suite():
    loader = unittest.TestLoader()
    suite = unittest.TestSuite()
    suite.addTests(loader.loadTestsFromTestCase(TestMathOperations))  # Add TestMathOperations
    suite.addTests(loader.loadTestsFromTestCase(TestCalculator))  # Add TestCalculator
    return suite

if __name__ == '__main__':
    # Run the test suite
    runner = unittest.TextTestRunner()
    runner.run(suite())

How It Works

  1. Import the Test Classes:

    • The test classes (TestMathOperations and TestCalculator) are imported from their respective files.
  2. Add Tests to the Suite:

    • loader.loadTestsFromTestCase(TestMathOperations): Loads all test methods from the TestMathOperations class.
    • loader.loadTestsFromTestCase(TestCalculator): Loads all test methods from the TestCalculator class.
  3. Execute the Suite:

    • The unittest.TextTestRunner() runs all tests in the suite and reports the results.

Running the Test Suite

Save the above code in a file named test_suite.py. To run the suite, execute the following command in the terminal:

bash
python test_suite.py

Example Output

If all tests pass:

......
----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

If any test fails (e.g., add_numbers or divide has an issue):

.F....
======================================================================
FAIL: test_add_positive_numbers (test_math_operations.TestMathOperations)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_math_operations.py", line 7, in test_add_positive_numbers
    self.assertEqual(result, 6)  # Intentional failure for demonstration
AssertionError: 5 != 6

----------------------------------------------------------------------
Ran 6 tests in 0.002s

FAILED (failures=1)

Why Skip a Test Case?

Sometimes, while developing or refactoring code, certain tests may not be ready to execute. The unittest module in Python allows you to skip such tests. This is useful when:

  1. A test case is incomplete (e.g., under development or work in progress).
  2. The functionality being tested is not implemented yet.
  3. A test case is temporarily irrelevant (e.g., related to a feature being revised).

The @unittest.skip decorator allows you to mark such tests.


Example: Skipping a Test

Let’s write an example where one test case is marked to be skipped:

python
import unittest
from my_module import add_numbers

class TestMathOperations(unittest.TestCase):

    def test_add_positive_numbers(self):
        result = add_numbers(2, 3)
        self.assertEqual(result, 5)

    @unittest.skip("Work in progress")
    def test_add_large_numbers(self):
        result = add_numbers(12345, 67890)
        self.assertEqual(result, 80235)  # Test is skipped

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

Output When Skipping a Test

.s
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK (skipped=1)

Here, the s indicates the skipped test, and the reason "Work in progress" is displayed.


Explanation of Test Fixtures

A Test Fixture refers to the setup required for your test cases to run reliably and independently. The unittest framework provides two key methods for managing test fixtures:

  1. setUp: Executed before each test method to initialize resources.
  2. tearDown: Executed after each test method to clean up resources.

Fixtures ensure that test cases remain independent of each other by setting up a fresh state for each test.


Example: Using Test Fixtures

Suppose we want to test a function multiply from my_module.py. Instead of initializing the same setup in every test method, we use setUp for preparation.

python
import unittest
from my_module import multiply

class TestMathOperations(unittest.TestCase):

    def setUp(self):
        """Set up test fixtures."""
        self.a = 6
        self.b = 7

    def tearDown(self):
        """Clean up after test methods."""
        print("Cleaning up resources...")

    def test_multiply_positive_numbers(self):
        result = multiply(self.a, self.b)
        self.assertEqual(result, 42)

    def test_multiply_with_zero(self):
        result = multiply(self.a, 0)
        self.assertEqual(result, 0)

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

Execution Order of Fixture Methods

  1. setUp is called before each test method.
  2. The test method is executed.
  3. tearDown is called after the test method, even if an exception occurs during the test.

Example Output

Cleaning up resources...
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

If an exception occurs during a test, tearDown still executes to clean up resources. However, if an exception occurs in setUp, the test and tearDown are skipped.

Here's the complete rewritten section for Example of Poor Unit Test Design and Better Design with Arrange-Act-Assert, with a concrete example based on a LibraryCatalog module.


Example of Poor Unit Test Design and Better Design with Arrange-Act-Assert

Poor Unit Test Design

Let’s start with an example of poor unit test design. Consider the LibraryCatalog class, which tracks book titles in a library. It has a method is_consistent that checks for duplicate or prefix relationships among book titles.

Here’s an example of poor unit test design:

python
import unittest
from library_catalog import LibraryCatalog

class TestLibraryCatalogPoorDesign(unittest.TestCase):
    def test_is_consistent(self):
        catalog = LibraryCatalog()

        # Add a book and check consistency
        catalog.add_book("The Great Gatsby")
        self.assertTrue(catalog.is_consistent())

        # Add another book and check consistency
        catalog.add_book("To Kill a Mockingbird")
        self.assertTrue(catalog.is_consistent())

        # Add a duplicate and check consistency
        catalog.add_book("The Great Gatsby")  # Duplicate
        self.assertFalse(catalog.is_consistent())

        # Add a book with a prefix relationship and check consistency
        catalog.add_book("The")  # Prefix of "The Great Gatsby"
        self.assertFalse(catalog.is_consistent())
Problems with This Design
  1. Execution Stops on Failure: If the test fails at any assert, the remaining steps in the test are skipped. This means later issues might go unnoticed.
  2. Ambiguous Results: When this test fails, it's unclear which specific scenario caused the failure.
  3. Poor Readability: Mixing multiple scenarios into a single test makes it harder to understand the intent of the test or diagnose issues.

Better Design with Arrange-Act-Assert

To address the issues above, we can rewrite the test case into smaller, focused tests following the Arrange-Act-Assert pattern:

  1. Arrange: Set up the conditions for the test.
  2. Act: Execute the behavior being tested.
  3. Assert: Verify the expected outcome.

Here’s how the same scenarios can be rewritten:

python
import unittest
from library_catalog import LibraryCatalog

class TestLibraryCatalog(unittest.TestCase):
    
    def test_consistent_with_unique_titles(self):
        # Arrange
        catalog = LibraryCatalog()
        catalog.add_book("The Great Gatsby")
        catalog.add_book("To Kill a Mockingbird")

        # Act
        result = catalog.is_consistent()

        # Assert
        self.assertTrue(result)

    def test_inconsistent_with_duplicate_titles(self):
        # Arrange
        catalog = LibraryCatalog()
        catalog.add_book("The Great Gatsby")
        catalog.add_book("The Great Gatsby")  # Duplicate title

        # Act
        result = catalog.is_consistent()

        # Assert
        self.assertFalse(result)

    def test_inconsistent_with_prefix_titles(self):
        # Arrange
        catalog = LibraryCatalog()
        catalog.add_book("The Great Gatsby")
        catalog.add_book("The")  # Title is a prefix of an existing title

        # Act
        result = catalog.is_consistent()

        # Assert
        self.assertFalse(result)
Benefits of Better Design
  1. Clarity: Each test clearly focuses on one scenario, making it easy to understand what is being tested and why.
  2. Immediate Feedback: When a test fails, you know exactly which scenario caused the issue.
  3. Modularity: Tests can be run independently, and they follow the Arrange-Act-Assert structure for readability.

Implementation of the LibraryCatalog Class

Here is the LibraryCatalog class implementation that works with the tests:

python
class LibraryCatalog:
    def __init__(self):
        # The catalog will be a dictionary with titles as keys
        self.catalog = {}

    def add_book(self, title):
        """Adds a book to the catalog."""
        if title in self.catalog:
            raise ValueError(f"Book '{title}' already exists in the catalog.")
        self.catalog[title] = True

    def update_book(self, old_title, new_title):
        """Updates the title of a book in the catalog."""
        if old_title not in self.catalog:
            raise KeyError(f"Book '{old_title}' not found in the catalog.")
        del self.catalog[old_title]
        self.catalog[new_title] = True

    def is_consistent(self):
        """Checks catalog consistency:
        - No duplicate titles.
        - No title is a prefix of another.
        """
        titles = list(self.catalog.keys())
        for i in range(len(titles)):
            for j in range(len(titles)):
                if i != j and (titles[i].startswith(titles[j]) or titles[j].startswith(titles[i])):
                    return False
        return True

How to Run the Tests

  1. Save the LibraryCatalog implementation in a file named library_catalog.py.
  2. Save the tests in a file named test_library_catalog.py in the same directory.
  3. Run the tests using the command:
    bash
    python test_library_catalog.py
    

Expected Output

All Tests Pass:

...
----------------------------------------------------------------------
Ran 3 tests in 0.002s

OK

If One Test Fails (e.g., is_consistent has a bug):

.F..
======================================================================
FAIL: test_inconsistent_with_duplicate_titles (test_library_catalog.TestLibraryCatalog)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_library_catalog.py", line 20, in test_inconsistent_with_duplicate_titles
    self.assertFalse(result)
AssertionError: True is not false

----------------------------------------------------------------------
Ran 3 tests in 0.003s

FAILED (failures=1)

Why Unit Testing Matters

Unit testing is a critical aspect of software development that offers numerous benefits to developers and teams. It’s not just about writing tests—it’s about improving your code and your development process. Let’s explore some of the key reasons for investing in unit testing:

  1. Clarifying Requirements:
    Writing unit tests forces you to define what each unit of your system is supposed to do. This process often involves collaboration with stakeholders like business analysts, testers, or designers. Even when working alone, creating tests helps you refine your understanding of the problem, identify gaps, and ensure your code aligns with the desired functionality.

  2. Documenting the System:
    Unit tests serve as detailed documentation of how the system behaves. Well-written tests illustrate how different parts of the system should work, providing valuable context for new developers who join the project. Since unit tests are executable, they remain up-to-date as the code evolves.

  3. Driving Better Design:
    Unit testing encourages modular, loosely coupled designs. The need to test specific units of code independently promotes high cohesion and low coupling—principles of maintainable software. Writing tests also helps separate the interface (how a unit is used) from the implementation, resulting in cleaner and more flexible designs.

  4. Preventing Regressions:
    Once you have a test suite in place, it serves as a safety net. When you make changes to the code, the tests can quickly detect regressions—features that used to work but no longer do. This allows you to confidently refactor and extend your system.

  5. Facilitating Refactoring:
    With comprehensive tests, you can safely refactor your code to improve its design or performance. If your tests pass after the changes, you can be confident that your system’s behavior remains intact.


Incorporating Unit Testing into Your Workflow

Different developers have varying approaches to integrating unit testing into their workflow. Here are three common methods:

  1. Tests Last:
    In this approach, you write the code first and add tests later. While this allows you to finalize the design before investing in tests, it comes with significant risks:

    • Untestable Design: Late discovery of design flaws can make it difficult to write effective tests.
    • Delayed Bug Detection: Bugs may only surface late in the process, leading to rushed testing or skipped tests.
    • Rework: You might need to refactor the code extensively to make it testable.
  2. Tests First:
    Here, you design a complete suite of test cases upfront before writing the code. This helps define a testable interface early and ensures good test coverage. However:

    • Incomplete Understanding: It’s hard to anticipate all edge cases at the start.
    • Iteration Challenges: Changes to the design might require reworking tests, reducing efficiency.
  3. Test-Driven Development (TDD):
    TDD is an iterative approach where you write a test for a small piece of functionality, implement just enough code to pass the test, and then refactor. Repeat this process to gradually build the system. TDD integrates testing and development, resulting in:

    • High-quality, testable designs.
    • Incremental progress with fewer surprises.
    • A disciplined workflow that balances testing and implementation.

Challenges and Limitations

While unit testing is beneficial, it has limitations:

  • Dependencies and Coupling: Testing tightly coupled code can be difficult. This often reveals deeper design issues.
  • Virtual Reality Effect: Tests may pass in a controlled environment but fail to represent real-world scenarios accurately.
  • Integration and Nonfunctional Testing: Unit tests focus on individual components and cannot catch integration issues or evaluate performance, scalability, or security. Complementing unit tests with integration tests and other methods is essential.

Conclusion

Unit testing is more than just a technical task; it’s a mindset that improves your code, your design, and your development process. Whether you adopt tests-first, tests-last, or TDD, incorporating automated tests into your workflow is critical for delivering high-quality, maintainable software. While there are challenges, the benefits—better designs, fewer bugs, and easier refactoring—make unit testing an indispensable tool for any developer.

Pytest is a very popular unit testing framework in Python, known for its simplicity, flexibility, and powerful features. Compared to the unittest module, which follows the xUnit testing framework pattern, pytest provides a more Pythonic way to write and manage tests. Let’s dive into the details of how to work with pytest.

1. Why Use Pytest?

Pytest is often preferred over unittest due to its:

  • Concise syntax: No need for boilerplate code, such as classes or inheritance from TestCase for writing tests.
  • Powerful reporting: Detailed and easily interpretable test failure messages.
  • Rich ecosystem: Plugins for various needs, such as generating HTML reports, testing performance, etc.

2. Setting Up Pytest

To use pytest, you need to install it separately, as it is not included in the Python standard library.

Installation:

Run the following command in the terminal to install pytest using pip:

bash
pip install pytest

File Naming Convention:

  • Test files should be named test_<something>.py to be recognized by pytest.
  • Test functions should start with test_, following the convention for pytest to identify them as tests.

3. Writing Test Cases with Pytest

Basic Test Function

With pytest, you don't need to create a test class. A simple function that starts with test_ will suffice for writing a test. This is a more Pythonic and simpler way to define tests compared to unittest.

Example:

Let’s test a simple mathematical operation (addition):

python
# test_math_operations.py

def test_addition():
    assert 2 + 3 == 5

def test_subtraction():
    assert 5 - 3 == 2
  • Assertions: You use the assert statement to check that a condition is true. If the condition is false, pytest will mark the test as failed.

  • Running the tests: You can run the tests by simply calling pytest from the terminal:

    bash
    pytest
    

Pytest will automatically discover all files that match test_*.py and execute the test functions within them.


4. Understanding Test Failures

When a test fails, pytest provides detailed output that can help you understand exactly what went wrong.

Example Output:

E       AssertionError: assert 4 == 5
  • The line E indicates an error.
  • AssertionError: assert 4 == 5 is the error message showing what was expected and what was actually returned.
  • This error provides you with both the expected value (5) and the actual value (4).

5. Test Fixtures in Pytest

A test fixture is a way to set up preconditions for a test and clean up afterward, similar to the setUp() and tearDown() methods in unittest. Pytest fixtures are defined using the @pytest.fixture decorator.

Example of a Simple Fixture:

python
import pytest

@pytest.fixture
def sample_data():
    return {"name": "Alice", "age": 30}

def test_data_retrieval(sample_data):
    assert sample_data["name"] == "Alice"
    assert sample_data["age"] == 30
  • The function sample_data() is a fixture that returns a dictionary with sample data.
  • The test function test_data_retrieval() takes sample_data as an argument. Pytest automatically provides the sample_data fixture to the test function.

6. Cleaning Up After Tests with Fixtures

Fixtures in pytest are very flexible and allow you to add cleanup code as well.

Example with Cleanup:

Let’s say you need to create a file for your tests and delete it after the test:

python
import pytest

@pytest.fixture
def temp_file(tmpdir):
    file = tmpdir.join("sample.txt")
    file.write("Hello, pytest!")
    return file

def test_file_creation(temp_file):
    assert temp_file.read() == "Hello, pytest!"
  • tmpdir is a built-in fixture in pytest that gives you a temporary directory for the test. The temp_file fixture will use it to create a file.
  • After the test is complete, tmpdir automatically deletes the temporary file and directory.

7. Exception Handling in Pytest

In some cases, you may want to assert that certain errors or exceptions are raised during test execution.

Example of Testing Exceptions:

python
import pytest

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

def test_zero_division():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)
  • pytest.raises(ZeroDivisionError) ensures that the ZeroDivisionError is raised when dividing by zero.
  • If no exception is raised or if a different exception is raised, the test will fail.

8. Organizing Test Code with Pytest

In larger projects, organizing your test code is crucial for maintainability. Here’s how to structure your project:

Example Project Structure:

my_project/
    src/
        phonebook.py      # Production code
    tests/
        __init__.py       # Marks this directory as a Python package
        test_phonebook.py # Test file
        conftest.py       # Test fixtures
    pytest.ini            # Pytest configuration
  • Production Code: The source code is placed inside the src/ folder.
  • Test Code: The tests are located in the tests/ folder. Test fixtures can be placed in the conftest.py file, which is a special file in pytest. It is automatically discovered and its fixtures are available to all the test files in the same folder and subfolders.
  • Configuration: The pytest.ini file is used to configure pytest settings for your project, such as test paths, markers, and other options.

Example pytest.ini:

ini
[pytest]
testpaths = tests

This configuration tells pytest to look for tests only in the tests folder.


9. Autouse Fixtures

Sometimes you want a fixture to automatically apply to every test without explicitly mentioning it. You can use the autouse=True option.

Example:

python
import pytest

@pytest.fixture(autouse=True)
def setup_and_teardown():
    print("Setting up test")
    yield
    print("Tearing down test")

def test_example_1():
    assert 1 + 1 == 2

def test_example_2():
    assert "hello".upper() == "HELLO"
  • In the above example, the fixture setup_and_teardown will automatically be applied to both test functions, and it will print "Setting up test" before each test and "Tearing down test" after.

10. Advanced Fixtures: Using yield for Cleanup

You can use yield in a fixture to separate setup and teardown code. Everything before the yield is the setup, and everything after it is the teardown.

Example:

python
import pytest

@pytest.fixture
def phonebook():
    pb = Phonebook()  # setup
    yield pb
    pb.clear()  # teardown

def test_add_entry(phonebook):
    phonebook.add("Alice", "12345")
    assert phonebook.lookup("Alice") == "12345"
  • The code before yield initializes the fixture, and the code after yield runs cleanup when the test is done.

11. Using Pytest Plugins

Pytest supports a wide variety of plugins that can extend its functionality. For example, you can generate HTML reports for your test results using the pytest-html plugin.

Example with pytest-html:

  1. Install the plugin:
    bash
    pip install pytest-html
    
  2. Generate an HTML report:
    bash
    pytest --html=report.html
    
  • This command will run the tests and generate an HTML report with test results that you can view in a browser.

Challenge: Creating and Improving Unit Tests

Context:

You are tasked with testing a small Python application that manages users and their personal information. The application must include the following features:

  • Add a user with a name and an age.
  • Check if a user exists based on their name.
  • Delete a user based on their name.

Objectives:

  1. Write a unit test to verify that adding a user works correctly.
  2. Test that deleting a user works as expected.
  3. Test for exceptions raised when incorrect actions are attempted (e.g., trying to delete a non-existent user).
  4. Apply a better test design using the Arrange-Act-Assert structure.
  5. Apply a skipping technique to ignore certain tests.

Instructions:

  1. Create a UserManager class to manage users.
  2. Implement methods to:
    • Add a user (add_user(name, age)).
    • Check if a user exists (user_exists(name)).
    • Delete a user (delete_user(name)).
  3. Create a test file using unittest to test the UserManager class:
    • Test adding a user with name and age.
    • Test deleting an existing user.
    • Test raising an exception when trying to delete a user that doesn't exist.
    • Use Arrange-Act-Assert in your test design.
    • Use assertRaises to test exceptions.
    • Test using the skipping feature to ignore certain tests.

Application Code to Test:

python
class UserManager:
    def __init__(self):
        self.users = {}
    
    def add_user(self, name, age):
        if name in self.users:
            raise ValueError("User already exists")
        self.users[name] = age
    
    def user_exists(self, name):
        return name in self.users
    
    def delete_user(self, name):
        if name not in self.users:
            raise ValueError("User not found")
        del self.users[name]

Sample Unit Tests using unittest:

Exercise 1: Create a test to verify that adding a user works correctly.

Exercise 2: Create a test to verify that deleting a user works correctly.

Exercise 3: Create a test that checks that a ValueError is raised when trying to delete a user who doesn't exist.

Exercise 4: Apply the Arrange-Act-Assert pattern to better organize your tests.

Exercise 5: Create a test that is skipped using the @unittest.skip decorator.

Solution:

Here is the solution for this challenge with the necessary unit tests, followed by best practices.

Code des tests :

python
import unittest

class TestUserManager(unittest.TestCase):
    def setUp(self):
        """This method is called before each test. It prepares the test environment."""
        self.user_manager = UserManager()
        
    def test_add_user(self):
        """Test for adding a user"""
        # Arrange: prepare the input data
        name = "Alice"
        age = 30
        # Act: call the add_user method
        self.user_manager.add_user(name, age)
        # Assert: verify if the user exists
        self.assertTrue(self.user_manager.user_exists(name))
        
    def test_delete_user(self):
        """Test for deleting an existing user"""
        # Arrange
        name = "Bob"
        self.user_manager.add_user(name, 25)
        # Act
        self.user_manager.delete_user(name)
        # Assert
        self.assertFalse(self.user_manager.user_exists(name))
        
    def test_delete_non_existent_user(self):
        """Test to verify that an exception is raised when deleting a non-existent user"""
        # Arrange
        name = "Charlie"
        # Act & Assert
        with self.assertRaises(ValueError):
            self.user_manager.delete_user(name)
            
    def test_add_existing_user(self):
        """Test to verify that adding an already existing user raises an exception"""
        # Arrange
        name = "David"
        self.user_manager.add_user(name, 28)
        # Act & Assert
        with self.assertRaises(ValueError):
            self.user_manager.add_user(name, 30)
            
    @unittest.skip("This test is currently skipped.")
    def test_skip_example(self):
        """This test is skipped"""
        # Arrange
        name = "Eve"
        self.user_manager.add_user(name, 22)
        # Act
        self.user_manager.delete_user(name)
        # Assert
        self.assertFalse(self.user_manager.user_exists(name))
        
    def test_arrange_act_assert_pattern(self):
        """Example of organizing the test according to the Arrange-Act-Assert pattern"""
        # Arrange
        name = "Frank"
        age = 35
        # Act
        self.user_manager.add_user(name, age)
        # Assert
        self.assertTrue(self.user_manager.user_exists(name))
        self.assertEqual(self.user_manager.users[name], age)
        
if __name__ == "__main__":
    unittest.main()

Explanation of the Tests:

  1. Test test_add_user:

    • Arrange: We define the user's name and age.
    • Act: We call the add_user method to add a user.
    • Assert: We verify that the user exists in user_manager using user_exists.
  2. Test test_delete_user:

    • Arrange: We first add a user.
    • Act: We delete that user.
    • Assert: We confirm that the user no longer exists.
  3. Test test_delete_non_existent_user:

    • Arrange: We define a user that does not exist.
    • Act: We attempt to delete this user.
    • Assert: We ensure that a ValueError exception is raised when trying to delete a non-existent user.
  4. Test test_add_existing_user:

    • Arrange: We add a user.
    • Act: We attempt to add the same user again, which should raise an exception.
    • Assert: We verify that a ValueError exception is indeed raised.
  5. Test test_skip_example:

    • This test is skipped using the @unittest.skip decorator. It demonstrates how to skip a specific test.
  6. Test test_arrange_act_assert_pattern:

    • This test is structured using the Arrange-Act-Assert pattern. This ensures that each phase of the test is clearly separated and easy to understand.