5. Tests🔗

Les tests sont une étape importante dans l'écriture d'un code robuste et fiable. Ils doivent permettre de s'assurer que les différentes parties d'un programme – fonctions, classes, méthodes, etc. – retournent les valeurs justes, se comportent conformément aux attentes et interagissent correctement. En outre, ils doivent permettre d'identifier rapidement les évolutions négatives du code (régression): ainsi, lorsqu'une nouvelle fonctionnalité ne passe pas les tests qui y sont associés, il doit être évident que l'erreur provient de ce développement récent et non des fonctions ou objets que cette partie de code utilise.

On distingue hiérarchiquement trois types de tests:

  1. Les tests unitaires vérifient individuellement chacune des fonctions, méthodes, etc.;

  2. Les tests d'intégration évaluent les interactions entre différentes unités du programmes;

  3. Les tests système assurent le bon fonctionnement du programme dans sa globalité.

5.1. Tests unitaires🔗

Nous nous concentrerons dans ce cours sur les tests unitaires, qui doivent contrôler le comportement individuel des différentes parties, non seulement a. le résultat retourné selon les paramètres d'entrée (validité et précision d'un calcul, cas limites, etc.), mais également b. le comportement vis-à-vis des paramètres non conformes (p.ex. de type incorrect) ou c. des situations anormales (p.ex. l'absence d'un fichier).

Il existe plusieurs façons de rédiger les tests unitaires d'une fonction, méthode ou classe.

  • Un doctest est un exemple (assez simple) d'exécution de code directement inclus dans la docstring de la fonction ou méthode:

    def mean_power(alist, power=1):
        r"""
        Retourne la racine `power` de la moyenne des éléments de `alist` à
        la puissance `power`:
    
        .. math:: \mu = (\frac{1}{N}\sum_{i=0}^{N-1} x_i^p)^{1/p}
    
        `power=1` correspond à la moyenne arithmétique, `power=2` au *Root
        Mean Squared*, etc.
    
        Exemples:
        >>> mean_power([1, 2, 3])
        2.0
        >>> mean_power([1, 2, 3], power=2)
        2.160246899469287
        """
    
        # *mean* = (somme valeurs**power / nb valeurs)**(1/power)
        mean = (sum( val ** power for val in alist ) / len(alist)) ** (1 / power)
    
        return mean
    

    Les doctests se limitent en général à des tests simples et pédagogiques, qui illustrent la documentation. Ils peuvent être exécutés de différentes façons:

    • avec le module standard doctest:

      $ python -m doctest -v code.py
      
    • avec le module externe pytest [1]:

      $ pytest --doctest-modules -v code.py
      
  • Les fonctions de test dédiées permettent d'effectuer des tests plus poussés que les doctests, généralement dans un fichier séparé du code à tester (p.ex. dans un répertoire dédié tests/). P.ex.:

    def test_init():
        """
        Vérifie qu'une instanciation avec des arguments de type correct
        produit les bons résultats.
        """
    
        youki = Animal('Youki', 600)
        assert youki.masse == 600
        assert youki.vivant
        assert not youki.empoisonne
    
    
    def test_wrong_init():
        """
        Vérifie qu'une instanciation avec des arguments de type incorrect
        produit une exception.
        """
    
        with pytest.raises(ValueError):
            Animal('Youki', 'lalala')
    

L'écriture de ces fonctions dépend de la librairie de test utilisée. Nous utiliserons la librairie pytest, qui s'avère relativement simple à mettre en oeuvre (toutes les fonctions dont le nom commence par test_ et contenant des assert sont automatiquement détectées par pytest) tout en étant assez riche. Dans ce cas, les tests sont exécutés via la commande:

$ pytest programme.py

ou juste:

$ pytest

dans la racine du projet (voir pyyc).

Tout comportement décrit dans la documentation doit faire l'objet d'un test, et inversement. En outre, dans l'idéal, chaque ligne de code, a priori nécessaire, doit être testée pour s'assurer de sa pertinence et de son utilité (taux de couverture maximal).

L'écriture de tests unitaires impose de fait de bien respecter le principe de responsabilité unique (voir Principes de conception logicielle): chaque élément de code (classe, méthode, fonction) ne doit avoir qu'une unique raison d'exister, avec une interface bien définie et une « utilisabilité » circonscrite à même d'être facilement testées.

Il est très utile de transformer toutes les fonctions ou procédures de vérification écrites au cours du développement et du débogage en tests, ce qui permet de les réutiliser lorsque l'on veut compléter ou améliorer une partie du code. Si le nouveau code passe toujours les anciens tests, on est alors sûr de ne pas avoir cassé les fonctionnalités précédentes (régressions).

Pour un exemple d'utilisation des tests unitaires, voir pyyc.

5.2. Développement piloté par les tests🔗

Le Test Driven Development (TDD) est une méthode de programmation qui permet d'éviter des bogues a priori plutôt que de les résoudre a posteriori. Ce n'est pas une méthode propre à Python, elle est largement utilisée par les programmeurs professionnels.

Le cycle préconisé par TDD comporte cinq étapes:

  1. écrire un premier test;

  2. vérifier qu'il échoue (puisque le code qu'il teste n'existe pas encore), afin de s'assurer que le test est valide et exécuté;

  3. écrire un code minimal pour passer le test;

  4. vérifier que le test passe alors correctement;

  5. éventuellement « réusiner » le code (refactoring), c'est-à-dire l'améliorer (rapidité, lisibilité) tout en gardant les mêmes fonctionnalités.

Notes de bas de page