Tests¶

Pour tester une variable on peut faire un if ou plus simplement un assert s'il s'agit seulement de tester et de bloquer si le test ne passe pas.

Assert¶

Une assertion permet de vérifier une condition booléenne. Si elle n'est pas réalisée le programme remonte une AssertionError sans plus de détail. C'est pratique pour développer mais c'est problématique si cela arrive dans un code en production (on préfère les exceptions comme dans le code suivant).

In [1]:
def factorial(n):
    assert type(n) == int
    assert n >= 0
    res = 1
    for i in range(2,n+1):
        res *= i
    return res

factorial(-1.2)
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
<ipython-input-1-3ed0ba4ef4ca> in <module>
      7     return res
      8 
----> 9 factorial(-1.2)

<ipython-input-1-3ed0ba4ef4ca> in factorial(n)
      1 def factorial(n):
----> 2     assert type(n) == int
      3     assert n >= 0
      4     res = 1
      5     for i in range(2,n+1):

AssertionError: 

Tests unitaires¶

Un test unitaire vérifie qu'une unité (une fonction) retourne bien le résultat attendu.

C'est un point important à ne pas sous-estimer. En effet, lorsqu'on écrit une bibliothèque avec plein de fonctions, il faut vérifier que toute modification sur des fonctions utilisées par d'autres, ne vont pas générer des effets innatendus et donc des bugs. Aussi il faut d'avoir des tests pour chaque fonction, tests qu'on relance tous après chaque modification de la bibliothèque.

Vérifier le résultat d'une fonction peut se faire dans les commentaires qui l'accompagne (docstring) ou dans des fichiers dédiés aux tests.

Doctest¶

C'est une facon simple et efficace d'améliorer la documentation tout en effectuant des test. Son usage est assez naturel, il suffit d'indiquer dans le bloc de commentaires initial :

  • l'appel à la fonction voire quelques lignes de codes précédées de >>>
  • la sortie attendue avec la possibilité d'utiliser ... pour attraper des lignes. Si on désire attraper des caractères dans une ligne, il faut ajouter l'option # doctest: +ELLIPSIS.

Si on désire qu'un test ne soit plus appliqué, il suffit d'ajouter l'option SKIP.

Pour plus d'information, en particulier les options possibles, voir https://docs.python.org/3/library/doctest.html

In [4]:
def factorial(n) -> int:
    """
    Return n! 
    
    Factorial(n) or n! = n * (n-1) * (n-2) * ... * 1 and
    0! = 1  (since n! = (n+1)! / (n+1))
     
    Args:
      n (int): a positive integer

    Returns:
      int
        
    Raises:
      TypeError: if n is not an int
      ValueError: if n < 0
      
    Here are some tests. Note that tests also show how to use the function.
        >>> [factorial(n) for n in range(6)]
        [1, 1, 2, 6, 24, 120]
        >>> factorial(-2)
        Traceback (most recent call last):
        ...
        ValueError: n should be positive
        >>> factorial(3.1)                 # doctest: +ELLIPSIS
        Traceback (most recent call last):
        ...
        TypeError:...
        >>> import numpy                  
        >>> factorial(numpy.pi)            # doctest: +SKIP
        euh
    """
    if type(n) != int:
        raise TypeError('Not an integer')
    if n < 0:
        raise ValueError('n should be positive')
    res = 1
    for i in range(2,n+1):
        res *= i
    return res
    
    
import doctest
doctest.testmod(verbose=False)  # try True
Out[4]:
TestResults(failed=0, attempted=4)

Pour écrire le résultat des exceptions, il est conseillé de lancer la fonction pour voir le message d'erreur puis de le recopier (partiellement). Vous pouvez changer un message attendu pour voir le résultat lorsqu'un test ne passe pas.

Tester un fichier¶

La facon usuelle de travailler est d'avoir la fonction dans un fichier example.py et d'executer en dans un terminal (ou dans un Makefile) :

python -m doctest -v example.py

L'option -v pour verbose ajoute des informations, comme le mode verbose = True ci-dessus.

Si la construction des tests est trop compliquée ou s'il y a trop de tests différents à faire, alors Doctest n'est peut-être pas la bonne solution.

PyTest¶

PyTest permet d'écrire des séries de tests plus complexes. Il fait parti de la même famille d'enviromment de test qu'unittest et nose.

Il ne s'agit plus maintenant d'avoir des tests écrits dans les fonctions mais de faire des fichiers de test qui seront éxecutés à la demande.

In [5]:
%%writefile code/test_factorial.py

import pytest
from factorial import factorial

def test_fact():
    assert factorial(0) == 1                          # a regular assert
    
def test_value_fact():
    with pytest.raises(ValueError):                   # expect an exception
        factorial(-5)
        
def test_type_fact():
    with pytest.raises(TypeError, match=r".*int.*"):  # we can define a regex to match the exception
        factorial(0.5)
Overwriting code/test_factorial.py

La commande magique %%writefile a permis de sauver la cellule :

In [6]:
!ls code/
factorial.py  __pycache__  test_factorial.py
In [7]:
!pytest
============================= test session starts ==============================
platform linux -- Python 3.8.5, pytest-6.2.4, py-1.10.0, pluggy-0.13.1
rootdir: /home/ricou/enseignement/python/python-big-data/notebooks/lesson2 Deeper in Python
collecting ... 
collected 3 items                                                              

code/test_factorial.py ...                                               [100%]

============================== 3 passed in 0.04s ===============================

On voit la simplicité, il suffit d'écrire

  • des fichiers de test qui commencent par test_ ou finissent par _test.py
  • et dans ces fichiers d'avoir des fonctions qui commencent par test ou des classes qui commencent par Test avec des méthodes qui commencent par test.

Ensuite on lance pytest et il se charge de trouver les tests dans le répertoire courant et ses sous-répertoires, sous-sous-répertoires... Cela étant pour s'y retrouver il est conseillé d'avoir un répertoire test/.

On voit qu'il est possible d'intercepter les exceptions. Parfois le code ne passe pas et c'est connu pourtant on désire garder le test. Aussi on marque la fonction de test concernée avec @pytest.mark.xfail (il s'agit d'un décorateur qui agit sur la fonction qui suit, on verra cela dans le chapitre 3).

In [ ]:
@pytest.mark.xfail(sys.platform == "win32", reason="bug in a 3rd party library")
def test_function():
    ...

On peut aussi indiquer des fonctions qui échouent à cause d'exception :

In [ ]:
@pytest.mark.xfail(raises=ValueError)
def test_value_fact():
    factorial(-5)

Comparer des réels¶

Lorsqu'on compare des réels on peut avoir des surprises dues aux erreurs d'arrondi :

In [8]:
0.1 + 0.2 == 0.3
Out[8]:
False

Aussi PyTest propose la fonction approx qui permet de faire des comparaisons approximatives (avec une erreur relative de 1E-6 par défaut) et donc de passer les tests sur des réels sans mauvaise surprise :

In [9]:
import pytest

0.1 + 0.2 == pytest.approx(0.3)
Out[9]:
True

On trouvera les options d'approx et d'autres fonctions utiles de PyTest sur https://docs.pytest.org/en/6.2.x/reference.html.

Tests de haut niveau¶

Un test de haut niveau fait intervenir plusieurs composants et vise à valider une fonctionnalité d'un logiciel, un ensemble de fonctions qui interargissent. En pratique on utilise PyTest pour ce type de test.

Si notre bibliothèque génère des cartes aléatoires dans un fichier GeoJSON alors on peut avoir un test qui vérifie que l'on regénère toujours le même fichier si on utilise les mêmes paramètres et la même graine pour la génération des valeurs aléatoires. Bien sûr la création de la carte va faire intervenir de nombreuses fonctions, d'où le fait qu'il s'agisse d'un test de haut niveau.

Notons que le test peut échouer car on a amélioré certains points de la bibliothèque. Dans ce cas c'est le test qui doit être mis à jour.

Note : filecmp et difflib permettent de comparer des fichiers.

Tester différentes configurations¶

Lorsqu'on distribue son programme, il est préférable de vérifier qu'il fonctionne avec différentes versions de Python voire différentes versions de bibliothèques qu'il utilise. Une facon simple de passer tous nos tests sur les différentes configurations retenues est Nox.

Avec Nox il suffit de créer un fichier noxfile.py à la racine du projet pour qu'il génère les environnements de test et lance les tests. Le fichier de base est

import nox

@nox.session(python=["2.7", "3.6", "3.7"])
def tests(session):
    session.install('pytest')
    session.run('pytest')

Ainsi le programme sera testé avec Python 2,7, 3.6 et Python 3.7 (attention à ce que votre configuration dans pyproject.toml soit compatible avec ces versions). Notons que comme la séquence de test est écrite en Python, cela offre une grande liberté d'action.

Nox est aussi utilisé pour analyser le code (lint) et gérérer la documentation.

Nox + Poetry¶

Puisqu'à la feuille précédente on a choisit d'utiliser Poetry pour créer les environnements virtuel, utilisons le aussi avec Nox. Nox appelle alors poetry et noxfile.py devient

import nox

@nox.session(python=["2.7", "3.6", "3.7"])
def tests(session):                                      
    session.run("poetry", "install", external=True)        # install  -> rend le projet visible par les tests 
    session.run("poetry", "run", "pytest", external=True)  # external -> utilise poetry du système

Bien sûr on peut aussi vouloir tester différentes versions d'une bibliothèque :

import nox

@nox.session(python=["3.6", "3.8"])
@nox.parametrize("numpy", ["^1.18.4", "^1.17.5"])
def tests(session, numpy):
    session.run('poetry', 'add', f"numpy@{numpy}", external=True)
    session.run("poetry", "install", external=True)
    session.run("poetry", "run", "pytest", external=True)

ce qui va tester les 4 combinaisons possibles :

nox -s tests

...
nox > Ran multiple sessions:
nox > * tests-3.6(numpy='^1.18.4'): success
nox > * tests-3.6(numpy='^1.17.5'): success
nox > * tests-3.8(numpy='^1.18.4'): success
nox > * tests-3.8(numpy='^1.17.5'): success

et avec doctest¶

Pour effectué les tests unitaires inclus dans les commentaires, il suffit d'ajouter ces lignes à son noxfile.py :

@nox.session(python=["3.8"])
def xdoctest(session):
    """Run examples with xdoctest."""
    args = session.posargs or ["all"]
    session.run("poetry", "install", "--no-dev", external=True)
    session.run("poetry", "add", "xdoctest", "pygments", external = True)
    session.run("poetry", "run", "python", "-m", "xdoctest", "src/projet3", external = True)

puis de lancer nox -s xdoctest (ou avec -r pour aller plus vite et ne pas recharger les paquets : nox -rs xdoctest)

{{ PreviousNext("90 Project.ipynb", "92 Documentation.ipynb") }}

In [ ]: