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).
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
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
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.
%%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 :
!ls code/
factorial.py __pycache__ test_factorial.py
!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 parTest
avec des méthodes qui commencent partest
.
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).
@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 :
@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 :
0.1 + 0.2 == 0.3
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 :
import pytest
0.1 + 0.2 == pytest.approx(0.3)
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") }}