11  Les tests en Python

Les tests font partie intégrante du développement logiciel. Le test le plus proche du développeur est le test unitaire. Cette partie traitera que des tests unitaires.

11.1 Pourquoi tester ?

Les tests ne font pas parti du code fonctionnel. Ils sont toutefois fondamentaux dans le développement logiciel. Les tests  permettent de

  • Montrer que le code fonctionne
  • Montrer que le code répond aux attentes
  • Illustrer l’usage du code
  • Montrer que le code fonctionne toujours

Python propose deux outils pour les tests : doctest et unittest. L’intérêt de ces deux outils est qu’ils sont présents dans la distribution standard de Python. Cependant, une bibliothèque tierce rend en Python les tests beaucoup plus simples : PyTest.

11.2 Démarche générale pour le test

Tester consiste à s’assurer que le système a le comportement attendu en fonction d’une condition donnée. Tester se fait en 3 étapes, les 3A :

  • Arrange : Initialiser le système dans un état de départ. Ce système est le système testé habituellement appelé SUT (System Under Test).
  • Act : Exécuter le code que nous souhaitons tester.
  • Assert : Observer le système et s’assurer qu’il est dans l’état attendu. ## Notions de tests unitaires

Le code relatif à cette partie est à voir dans les sources avec le projet.

Tester un programme fait parti du processus de réalisation du programme. Vous testez naturellement vos programmes en vérifiant leur fonctionnement. Python en particulier permet de vérifier des parties de code grâce à l’interpréteur interactif.

Si cette pratique permet de valider le bon comportement du code, elle a le défaut d’être manuelle et donc ne nécessiter un effort d’analyse du résultat de vos instructions. Le principe des tests n’est pas très différent de votre pratique et va surtout permettre l’automatisation.

11.2.1 Glossaire

Dans la partie suivante, le code fonctionnel fera référence au code destiné à la production. C’est le code qui répond à votre besoin. Le code de test correspond au code destiné à tester le code fonctionnel. 

11.2.2 Principe des tests

L’automatisation des tests consiste à écrire du code de code qui comporte deux parties :

  • Du code qui exécute le code fonctionnel 
  • Du code qui valide le comportement du code fonctionnel. 

La première partie, vous l’écrivez déjà lorsque vous écrivez du code de validation manuelle. Nous allons devoir ajouter du code de validation. Une validation consistera en une assertion d’état. Les bibliothèques de tests fournissent un ensemble d’assertions que nous pourrons utiliser. 

Ainsi, nous pourrons de manière automatique jouer et re-jouer ces tests. Cette automatisation a 4 intérêts :

  • Elle permet de montrer que le code fonctionne
  • Elle permet de montrer que le code répond aux attentes
  • Elle permet d’illustrer l’usage du code
  • Elle permet de montrer que le code fonctionne toujours

Ce dernier point est important. Un code peut ne plus fonctionner suite à une modification. On appelle cela une régression.

Pour éviter la régression, nous devons avoir une stratégie de tests afin d’identifier le plus rapidement et le plus précisément toute régression. C’est pour cela que les tests unitaires ont été créés.

11.2.3 Principe des tests unitaires

Le principe des tests unitaires consiste à tester de manière isolée les plus petites parties possible du programme afin d’identifier le plus précisément les régressions ou les défaillances.

Tester de manière isolée doit permettre de s’assurer un comportement déterministe, c’est à dire que le test produira toujours le même résultat. Un test en échec doit mettre en évidence une erreur du code et non une influence extérieur.

Exemple : lorsque vous testez la récupération d’une base de données réseau, vous n’êtes pas isolé. Votre attente sera de récupérer certaines données que vous ne récupérerez pas si votre code est mal écrit, mais aussi si le réseau est absent, que le serveur n’est pas accessible, que la connexion à la base est impossible…

11.3 Doctest, la documentation auto-testée

Doctest est un test inclus dans la documentation proposé par le module doctest. Le principe est d’illustrer par un code simple le composant documenté. 

def add(a, b):
    """
        :Example:
 
        >>> add(1, 1)
        2
        >>> add(2.1, 3.4)
        5.5
 
    """
    return a + b
 
if __name__ == "__main__":
    import doctest
    doctest.testmod()

Dans cet exemple, l’exécution du module exécutera doctest qui recherchera tout code dans la documentation, l’exécutera et comparera le retour obtenu avec le retour attendu. Doctest ne signale que les erreurs.

Doctest est un outil pratique permettant d’illustrer l’usage mais limité à des exemples simples. N’oubliez pas que ces exemples vont se trouver dans la documentation.

11.4 Les tests unitaires avec unittest

Unittest, aussi appelé PyUnit, est la version Python de JUnit.

Junit est le framework de test créé par Kent Beck et Erich Gamma pour le langage Java en 1997. Il est lui même inspiré de SUnit créé par Kent Beck en 1994 et est considéré comme le framework fondateur des framework de type xUnit.

La documentation complète est disponible à l’adresse : https://docs.python.org/3/library/unittest.html 

11.4.1 Mise en œuvre de Unittest

Le principe des tests unitaires est de tester un comportement et un seul. Pour cela, le système testé doit être isolé au maximum afin qu’aucun composant externe n’influe sur le résultat du test.

Unittest permet l’isolation et l’indépendance des tests.

Le principe de mise en œuvre est le suivant :

  • On crée une classe héritant d’unittest par fonctionnalité testée
  • Au sein de cette classe, on crée une méthode de test par cas testé. Le nom de ces méthodes doit commencer par test, unittest détermine par introspection si une méthode est une méthode de test.
  • Unittest met à disposition deux méthodes, setup() et teardown() qui sont exécutées respectivement avant et après chaque méthode de test et qui sont destinés à préparer et à nettoyer l’environnement de test.

Un test (implanté dans une méthode de test) consiste à exécuter un appel à la fonctionnalité et à faire une assertion sur l’état après cet appel. 

Classes et méthodes doivent avoir un nom sans ambiguïté sur leur comportement. En cas d’échec d’un test, le reporting indiquera le nom de ces classes et méthodes. Un nom adapté facilitera la correction. 

Les méthodes setup() et teardown() vous permettent de garantir l’indépendance des tests. La première doit s’assurer de la préparation de l’environnement pour le test. La seconde s’assure de nettoyer l’environnement pour le test suivant. N’oubliez pas que vous ne pouvez faire aucune spéculation sur l’ordre d’exécution des tests.

11.4.2 Cas des tests simples

Le code suivant test la création d’une formation. Nous testons les différents cas sans oublier le comportement lorsque la durée ou le nombre de places est d’un autre type. 

import unittest
import academy.training as tr

class TestCreateTraining(unittest.TestCase):

    def testCreateTraining(unittest.TestCase):
        training = tr.Training('Python, fondamentaux', 5)
        self.assertEqual('Python, fondamentaux', training.subject)
        self.assertEqual(5, training.duration)
        self.assertEqual(12, training.seats)

    def testCreateTrainingWithSeats(unittest.TestCase):
        training = tr.Training('Python, fondamentaux', 5, 8)
        self.assertEqual(5, training.duration)
        self.assertEqual(8, training.seats)

    def testCreateTrainingWithValuesAsStrings(unittest.TestCase):
        training = tr.Training('Python, fondamentaux', '5', '8')
        self.assertEqual(5, training.duration)
        self.assertEqual(8, training.seats)

11.4.3 Cas des tests modifiant l’état

Le code suivant l’ajout de participants. Tester un ajout de participant nécessite l’existence d’un objet de type formation, donc sa création qui est réalisée dans la méthode setUp(). Cet objet étant modifié par l’appel à la méthode .add_student(), il est détruit par la méthode tearDown(). 

class TestNewStudents(unittest.TestCase):

    def setUp(self):
        self.training = tr.Training('Python, fondamentaux', 5)

    def testAddFirstStudent(self):
        self.training.add_student('Eliott Alderson')
        self.assertEqual(1, len(self.training.students)

    def tearDown(self):
        del self.training

Le bon fonctionnement de la méthode .add_student() nécessite que l’objet soit correctement créé. La bonne création de l’objet est assurée par les tests précédents.

11.4.4 Les assertions

Les principales assertions sont :

Methode Vérifie que
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)

Dans le cas de notre formation, ajouter un doublon (représenté par le même nom) conduit à la levée d’une exception. Il existe une assertion spécifique pour ce cas, assertRaises() qui prend en paramètre l’exception attendue. Elle est utilisée de la manière suivante. 

def testDuplicateStudent(self):
    self.training.add_student('Eliott Alderson')

    with self.assertRaises(ValueError):
        self.training.add_student('Eliott Alderson')

Pour discriminer l’origine de la levée de l’exception, nous nous baserons sur les arguments et paramètres de l’exception. La source de l’exception doit pouvoir être identifiée non pas pour des questions de testabilité mais de facilité de maintenance. 

def testDuplicateStudent(self):
    self.training.add_student('Eliott Alderson')

    with self.assertRaises(ValueError) as context:
        self.training.add_student('Eliott Alderson')
        self.assertTrue('Duplicate student' in context.exception)       

11.5 Les tests unitaires avec pytest

Pytest, ou Py.Test, est une bibliothèque qui se veut plus Pythonique que unittest avec une philosophie « we-don’t-need-no-stinking-API unit test suite ». L’objectif est de simplifier l’écriture des tests sans en perdre la puissance des libs xUnit. Ainsi, PyTest n’utilise pour les tests que de simples fonctions et assertions. Le premier exemple de tests unittest se transpose en Pytest de la manière suivante : 

import academy.training as tr

def test_create_training(self):
    training = tr.Training('Python, fondamentaux', 5)
    assert 'Python, fondamentaux', training.subject
    assert training.duration == 5
    assert training.seats == 12

def test_create_training_with_seats(self):
    training = tr.Training('Python, fondamentaux', 5, 8)
    assert training.duration == 5
    assert training.seats == 8

def test_create_training_with_values_as_strings(self):
    training = tr.Training('Python, fondamentaux', '5', '8')
    assert training.duration == 5
    assert training.seats == 8

Avec pytest, les tests ne sont que de simples fonctions et l’assertion est vérifiée avec la directive native de Python assert. Vous pouvez remarquer que le code précédent se suffit à lui même. Il n’y besoin d’aucune dépendance à la bibliothèque de tests.

Pour exécuter les tests, il suffit d’exécuter l’instruction py.test sur l’invite de commande à la racine du projet. Pytest suit les conventions de découverte des tests et exécutera donc tous les tests de l’arborescence. Il évidemment possible de fournir un chemin en paramètre afin de spécifier un répertoire ou un module pour cette exécution ou même de le configurer dans le pyproject.toml.

11.5.1 Tester la levée d’exceptions

Il est indispensable de tester que certaines exceptions sont bien levées dans des conditions spécifiques. Afin de conserver un code concis et lisible, Pytest fournit un context manager. En déclarant ce context manager, il faut indiquer quelle exception doit être levée. 

import academy.training as tr
import pytest

def test_duplicate_student_must_raise(create_account):
    training = tr.Training('Python, fondamentaux', 5)
    training.add_student('Eliott Alderson')
    
    with pytest.raises(ValueError):
        training.add_student('Eliott Alderson')

Le test de l’appel de la méthode deposit() nécessite une instance d’objet. Cependant, nous aurons besoin de tester ce même système dans différentes conditions. Il sera donc nécessaire de pouvoir accéder à cet objet de manière uniforme.

11.5.2 Une base de tests avec les test fixtures 

Les test fixtures offrent une base fixe afin de répéter de manière fiable un contexte de tests. L’usage le plus simple est de créer un objet lorsque l’on veut tester un appel de méthode.

Un objet doit être initialisé de la même manière pour l’ensemble des tests de la fonctionnalité. Une fixture permet de s’assurer que nous aurons toujours le même objet dans les différents tests. Nous pouvons donc reprendre le test de l’ajout de participants dans l’ensemble de tests ci-dessous.

from academy.training as tr
import pytest

@pytest.fixture()
def training:
    return tr.Training('Python, fondamentaux', 5)

def test_add_student(training):
    training.add_student('Eliott Alderson')
    
    assert len(training.students) == 1
    assert training.students[-1] == "Eliott Alderson"

def test_duplicate_student_must_raise(training):
    training.add_student('Eliott Alderson')
    
    with pytest.raises(ValueError):
        training.add_student('Eliott Alderson')

Vous remarquerez qu’une fixture se présente comme un paramètre de la fonction. Il est donc possible de passer plusieurs fixtures pour un même test.

Le mécanisme des fixtures est de l’injection de dépendances.

11.6 L’approche test-first

Il est indispensable pour la qualité du code que celui-ci soit accompagné de tests. La démarche habituelle consiste à écrire son code en fonction des spécifications puis les tests. Cette démarche à plusieurs défauts :

  • Reportés à la fin de la tâche, les tests ne sont finalement pas écrits. Après tout, le code fonctionne et il est urgent de passer à la tâche suivante.
  • Les tests sont écrits en fonction du code existant et montrent surtout que le code répond aux attentes.
  • Cette stratégie confirme le reproche fait à l’écriture des tests qui est que cela prends du temps.

L’idéal serait d’écrire les tests en même temps que le code en diminuant au maximum le temps nécessaire à leur écriture. C’est ce que promet l’approche TDD pour Test Driven Development. Ce document n’a pas l’ambition d’être une une formation à TDD (qui mérite une formation dédiée). Mais en résumé, si vous souhaitez mettre en œuvre cette approche, vous avez 3 règles à respecter scrupuleusement :

  1. Vous ne pouvez écrire du code de production que pour corriger un test en échec
  2. Vous ne devez écrire qu’un minimum de code de test pour que celui-ci soit en échec
  3. Vous ne devez pas écrire plus de code de production que pour faire passer un test en échec

Vous y verrez que

  • Les intentions sont écrites avant leur réalisation
  • Les intentions sont exhaustives
  • Les tests tendent à être plus exhaustifs comparé à leur écriture à postériori
  • Le temps passé à écrire les tests est compensé par l’absence d’écriture de code inutile.