Les tests unitaires automatisés avec unittest

Un test automatisé est un programme qui se découpe en trois étapes dites AAA pour Arrange, Act, Assert.

Arrange

La mise en place de l’environnement : création et initialisation des objets nécessaires à l’exécution du test.

Act

Le test proprement dit.

Assert

La vérification des résultats obtenus par le test.

Le sous-système (l’ensemble des objets) éprouvé par le test est parfois appelé SUT (System Under Test).

On distingue différentes catégories de tests :

  • Tests unitaires : testent une partie (une unité) d’un système afin de s’assurer qu’il fonctionne correctement (build the system right)

  • Tests d’acceptation : testent le système afin de s’assurer qu’il est conforme aux besoins (build the right system)

  • Tests d’intégration : testent le système sur une plate-forme proche de la plate-forme cible

  • Tests de sécurité : testent que l’application ne contient pas de failles de sécurité connues (injection de code, attaque XSS, …)

  • Tests de robustesse : testent le comportement de l’application au limite des ressources disponibles (mémoire, CPU, …) sur la plate-forme

Pour réaliser des tests unitaires, unittest est le framework de test intégré dans la bibliothèque standard Python.

Structure d’une classe de test

Les tests sont regroupés dans des classes de test. Généralement, on groupe dans une classe les tests ayant la même classe ou le même module comme point d’entrée. La classe de test doit impérativement héritée de unittest.TestCase. Les tests sont représentés par des méthodes dont le nom commence par test.

Exemple d’une classe de test
import unittest


class UneClasseDeTest(unittest.TestCase):

    def test_simple(self):
        self.assertTrue(True)

Pour exécuter une classe de test, il faut utiliser la fonction unittest.main(). Pour permettre de rendre le module de test exécutable, on ajoute donc dans le fichier :

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

Il est possible d’exécuter des instructions avant et après chaque test pour allouer et désallouer des ressources nécessaires à l’exécution des tests. On redéfinit pour cela les méthodes setUp() et tearDown().

import unittest


class UneClasseDeTest(unittest.TestCase):

    def setUp(self):
        print("Avant le test")

    def tearDown(self):
        print("Après le test")

    def test_simple(self):
        self.assertTrue(True)


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

Pour réaliser les assertions, une classe de test hérite de unittest.TestCase des méthodes d’assertion.

Méthode

vérifie

assertEqual(a, b)

a == b

assertNotEqual(a, b)

a != b

assertTrue(x)

bool(x) is True

assertFalse(x)

bool(x) is False

assertIs(a, b)

a is b

assertIsNot(a, b)

a is not b

assertIsNone(x)

x is None

assertIsNotNone(x)

x is not None

assertIn(a, b)

a in b

assertNotIn(a, b)

a not in b

assertIsInstance(a, b)

isinstance(a, b)

assertNotIsInstance(a, b)

not isinstance(a, b)

Toutes ces méthodes acceptent le paramètre optionnel msg pour passer un message d’erreur à afficher si l’assertion échoue.

Exécution des tests

En rendant chaque module de test exécutable, il suffit de lancer le module lui-même pour produire un rapport de test.

le fichier test_str.py
import unittest


class ChaineDeCaractereTest(unittest.TestCase):

    def test_reversed(self):
        resultat = reversed("abcd")
        self.assertEqual("dcba", "".join(resultat))

    def test_sorted(self):
        resultat = sorted("dbca")
        self.assertEqual(['a', 'b', 'c', 'd'], resultat)

    def test_upper(self):
        resultat = "hello".upper()
        self.assertEqual("HELLO", resultat)


if __name__ == '__main__':
    unittest.main()
$ python3 test_str.py

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

Il est également possible de lancer directement le module unittest grâce à l’option -m en passant comme paramètre les modules qui définissent les tests (le nom des fichiers sans l’extension .py). On peut ainsi lancer plusieurs modules de test comme une suite :

$ python3 -m unittest test_str

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

Exercice - Tests unitaires de abs()

Écrire les tests unitaires pour la fonction abs().

Note

L’exercice précédent propose de tester une méthode sans effet de bord (ce que l’on appelle également une fonction pure). Les tests sur ce type de méthodes sont faciles à écrire. Ils restent cependant l’exception lorsqu’on utilise la programmation orientée objet. En effet, l’appel d’une méthode sur un objet modifie le plus souvent son état et provoque généralement des effets de bord en sollicitant d’autres objets avec lesquels l’objet entretient des dépendances.

Tester des exceptions

Les tests unitaires automatisés sont utiles pour vérifier qu’une fonction (ou une méthode) produit le résultat attendu si l’appel est correct mais ils sont également très utiles pour vérifier qu’elle produit l’erreur attendue dans certains cas.

Pour tester qu’une exception survient, on peut utiliser la méthode TestCase.assertRaises conjointement avec une structure with.

import unittest


class AbsTest(unittest.TestCase):

    def test_abs_n_accepte_pas_une_chaine_de_caracteres(self):
        with self.assertRaises(TypeError):
            abs("a")


if __name__ == '__main__':
    unittest.main()
$ python3 test_abs.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Utilisation de doublure

Parfois, il est utile de contrôler l’environnement de test d’un objet ou d’une collaboration d’objets. Pour cela, on peut faire appel à des doublures qui vont se substituer lors des tests aux objets réellement utilisés lors de l’exécution de l’application dans un environnement de production.

Simulateur

Un simulateur fournit une implémentation alternative d’un sous-système. Un simulateur remplace un sous-système qui n’est pas disponible pour l’environnement de test. Par exemple, on peut remplacer un système de base de données par une implémentation simplifiée en mémoire.

Fake object

Un fake object permet de remplacer un sous-système dont il est difficile de garantir le comportement. Le comportement du fake object est défini par le test et est donc déterministe. Par exemple, si un objet dépend des informations retournées par un service Web, il est souhaitable de remplacer pour les tests l’implémentation du client par une implémentation qui retournera une réponse déterminée par le test lui-même.

Mock object

Un objet mock est proche d’un fake object sauf qu’un objet mock est également capable de faire des assertions sur les méthodes qui sont appelées et les paramètres qui sont transmis à ces méthodes.

Utilisation d’un objet mock

Le module unittest.mock permet de créer facilement des objets mock. Ce module fournit les classes Mock et MagicMock (cette dernière est une extension Mock pour le support automatique des méthodes spéciales ou dunders). On peut programmer le comportement des méthodes d’un objet mock et vérifier ensuite qu’il a bien été utilisé comme attendu au cours du test.

Supposons que nous voulions tester la fonction suivante :

from pathlib import Path


def is_sourcefile(path):
    """Retourne True si le fichier est un fichier source Python"""
    if not path.is_file():
        raise Exception("Fichier indisponible")
    return path.suffix == ".py"

Nous pouvons utiliser un mock à la place du paramètre path pour contrôler l’appel de la méthode is_file() :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import unittest
from unittest.mock import Mock


class FonctionTest(unittest.TestCase):

    def test_is_sourcefile_when_sourcefile(self):
        path = Mock()
        path.is_file.return_value = True
        path.suffix = ".py"

        resultat = is_sourcefile(path)

        self.assertTrue(resultat)
        path.is_file.assert_called()

    def test_is_sourcefile_when_file_does_not_exist(self):
        path = Mock()
        path.is_file.return_value = False

        with self.assertRaises(Exception):
            is_sourcefile(path)

        path.is_file.assert_called()

    def test_is_sourcefile_when_not_expected_suffix(self):
        path = Mock()
        path.is_file.return_value = True
        path.suffix = ".txt"

        resultat = is_sourcefile(path)

        self.assertFalse(resultat)
        path.is_file.assert_called()


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

À la ligne 8, nous créons un mock et nous pouvons prédéfinir la valeur qui sera retournée par la méthode is_file() ainsi que la valeur de l’attribut suffix.

Puis à la ligne 15, nous contrôlons que la méthode is_file() a bien était appelée pendant l’exécution du test.