Le test est l’exécution ou l’évaluation d’un système ou d’un composant par des moyens automatiques ou manuels, pour vérifier qu’il répond à ses spécifications ou identifier les différences entre les résultats attendus et les résultats obtenus (IEEE)
Tester peut révéler la présence d’erreurs mais ne garantit pas leur absence.
ATTENTION
Les tests doivent être indépendants et pouvoir s’exécuter dans n’importe quel ordre.
doctest est un module de tests intégré à la librairie standard.
Il permet d’intégrer des tests à l’intérieur des docstrings.
unittest est un framework de tests intégré à la librairie standard.
Il faut écrire ses tests en tant que méthodes de classes héritant de unittest.TestCase
.
pytest est le framework de test de référence depuis plusieurs années.
Il est apprécié pour la simplicité d’écriture des tests, pour sa souplesse d’utilisation, la richesse des possibilités offertes et pour la lisibilité des rapports affichés.
Livre de l’auteur de la librairie, beaucoup plus clair et complet : Python Testing with pytest, Second Edition (2022)
L’installation 1 est classique : python -m pip install pytest
2
Test d’une fonction add()
définie dans le fichier tests/ex1/test_add.py
.
pytest va exécuter tous les tests qu’il détecte dans le dossier ex1
.
L’option -v
affiche plus de détails dont le nom des tests exécutés et le contexte.
pytest affiche des précisions pour chaque test en erreur et termine par un résumé des tests.
Commande | Description |
---|---|
pytest |
sans argument, pytest va exécuter tous les tests qu’il découvre à partir du répertoire courant, récursivement |
pytest tests/ex1 |
pytest va exécuter tous les tests du dossier tests/ex1 et récursivement |
pytest tests/ex1/test_add.py |
pytest va exécuter tous les tests du fichier tests/ex1/test_add.py |
pytest tests/ex1/test_add.py::test_add_two |
pytest va exécuter le test test_add_two du fichier tests/ex1/test_add.py |
test_xxxx.py
ou xxxx_test.py
test_xxxx
TestXxxx
Résultat | Lettre | Description |
---|---|---|
PASSED |
. |
Le test est passé avec succès |
FAILED |
F |
Le test a échoué |
SKIPPED |
s |
Le test n’a pas été exécuté |
XFAIL |
x |
Le test était supposé échouer, il a effectivement échoué |
XPASS |
X |
Le test était supposé échouer, mais il est passé |
ERROR |
E |
Une exception s’est produite pendant l’exécution d’une fixture / d’un hook |
Les lettres sont affichées pour chaque test hors mode verbose, -v
.
$ pytest tests/ex1/test_add.py tests/ex5/test_skip.py
=============================== test session starts ================================
tests/ex1/test_add.py .F [ 50%]
tests/ex5/test_skip.py ss [100%]
===================================== FAILURES =====================================
...
============================= short test summary info ==============================
FAILED tests/ex1/test_add.py::test_add_two - assert 4 == 5
====================== 1 failed, 1 passed, 2 skipped in 0.15s ======================
Tester la conformité aux résultats attendus est très simple avec pytest.
Le test de conformité repose principalement sur l’instruction Python assert avec une expression quelconque qui sera évaluée comme vraie ou fausse.
assert <expression>
Avec unittest, il faut employer différentes méthodes suivant le test que l’on veut réaliser :
pytest | unittest |
---|---|
assert a == b | AssertEqual(a, b) |
assert a != b | AssertNotEqual(a, b) |
assert a >= b | AssertGreaterEqual(a, b) |
assert a is None | AssertIsNone(a) |
pytest intercepte les appels à assert
pour afficher une explication détaillée de l’échec de l’assertion.
Inutile de reprendre les mécanismes unittest
L’exécution directe par Python fait perdre des informations :
$ python tests/ex1/test_add.py
Traceback (most recent call last):
File "/home/fconil/Progs/python/pytest/les-tests-avec-pytest/tests/ex1/test_add.py", line 22, in <module>
test_add_two()
File "/home/fconil/Progs/python/pytest/les-tests-avec-pytest/tests/ex1/test_add.py", line 17, in test_add_two
assert add(3) == 5
AssertionError
que pytest affiche via l’interception des assertions :
pytest.raises()
permet de tester qu’une Exception attendue se produit effectivement.
Bonne pratique : découper les fonctions de test en 3 parties.
La formulation Given/When/Then proposée par le BDD 1 aide à identifier ces 3 parties.
def test_speeding_up_video():
# Given an input video and a specification of an output video path
video_file_in = "samples/ping.mp4"
video_file_out = "output/ping_speed.mp4"
# When you speed up the video
change_video_speed(video_file_in, video_file_out, 2)
# Then the new video has less frames than the original one
video_in = VideoFileClip(video_file_in)
video_out = VideoFileClip(video_file_out)
assert (video_in.fps * video_in.duration) > (video_out.fps * video_out.duration)
Il est possible de grouper les tests dans des classes pour les exécuter ensemble et définir des méthodes utilitaires associées.
class TestExtractFrames:
def test_extract_first_frame(self):
pass
def test_extract_last_frame(self):
pass
def test_extract_nth_frame(self):
pass
def def test_extract_first_frame_no_video(self):
pass
Warning
L’auteur de la librairie l’utilise peu et ne le conseille pas, l’héritage de classe de test risque de se retourner contre vous.
pytest --pdb
: permet de deboguer à l’endroit où se produit l’erreur
pytest --trace
: permet de deboguer au démarrage du test
pytest --tb=[auto/long/short/line/native/no]
: contrôle le style de la stacktrace
pytest -l
: permet de lister les variables locales avec la stacktrace
pytest --capture=no
/ pytest -s
: afficher le contenu de stdout
et strerr
qui sont capturés par pytest 1 par défaut
Les fixtures sont essentielles pour structurer le code de test.
Elles servent à mettre en place le contexte d’exécution et éventuellement à faire quelques opérations de nettoyage après l’exécution du test 1
Elles améliorent la lisibilité du test et permettent de factoriser la gestion du contexte.
Une fixture est une fonction à laquelle on applique le décorateur @pytest.fixture()
.
Un test déclare l’utilisation d’une fixture en mettant le nom de la fixture dans sa liste de paramètres.
Une fixture peut retourner des données avec return
ou yield
.
def get_index(list_dict, key, value):
"""helper to get index of a key in a json file"""
for i in range(len(list_dict)):
if list_dict[i][key] == value:
return i
@pytest.fixture()
def list_sports():
return [
{"sport": "swimming", "nb_videos": 100},
{"sport": "ping pong", "nb_videos": 50},
{"sport": "synchronized swimming", "nb_videos": 15},
]
def test_get_index(list_sports):
index = get_index(list_sports, "sport", "ping pong")
assert index == 1
yield
est le code de setup (mise en place du contexte)yield
passe la main au code de testyield
, est exécuté (nettoyage du contexte)def get_nb_sport(conn):
req = conn.execute("SELECT count(*) FROM sports")
res = req.fetchone()
return res if res is None else res[0]
@pytest.fixture()
def sport_db():
with TemporaryDirectory() as db_dir:
db_path = Path(db_dir) / "sports.db"
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE sports(id integer primary key autoincrement, sport, nb_videos integer)")
yield conn
conn.close()
def test_empty_db(sport_db):
nb_sports = get_nb_sport(sport_db)
assert nb_sports == 0
Le scope permet de déterminer la fréquence et l’ordre dans lequel les fixtures vont être exécutées.
Scope | Lettre | Description |
---|---|---|
scope='function' |
F |
La fixture est exécutée pour chaque test (défaut) |
scope='class' |
C |
La fixture est exécutée une fois pour tous les tests de la classe |
scope='module' |
M |
La fixture est exécutée une fois pour tous les tests du module |
scope='package' |
P |
La fixture est exécutée une fois pour tous les tests du package ou du dossier de test |
scope='session' |
S |
La fixture est exécutée une fois pour tous les tests de la session de test |
Par exemple, on peut choisir de ne créer une base de données de test qu’une fois par session de test.
Pour que les fixtures soient utilisables par différents fichiers de test, il faut les placer dans un fichier conftest.py
dans le même dossier ou dans un dossier parent.
@pytest.fixture(scope="session")
def sport_db():
with TemporaryDirectory() as db_dir:
db_path = Path(db_dir) / "sports.db"
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE sports(id integer primary key autoincrement, sport, nb_videos integer)")
yield conn
conn.close()
@pytest.fixture(scope="function")
def sport_not_empty_db(sport_db):
data = [("swimming", 100), ("ping pong", 50), ("synchronized swimming", 15)]
sport_db.executemany("INSERT INTO sports (sport, nb_videos) VALUES (?, ?)", data)
sport_db.commit()
yield sport_db
sport_db.execute("DELETE FROM sports")
Ici la fixture sport_not_empty_db
se base sur la fixture sport_db
qui crée la base de données temporaire.
ATTENTION
Une fixture ne peut se baser que sur une fixture de même scope ou de scope plus large.
def get_nb_sport(conn):
req = conn.execute("SELECT count(*) FROM sports")
res = req.fetchone()
return res if res is None else res[0]
def get_index(conn, value):
req = conn.execute("SELECT id FROM sports WHERE sport = ?", (value,))
res = req.fetchone()
return res if res is None else res[0]
def test_get_index(sport_not_empty_db):
index = get_index(sport_not_empty_db, "ping pong")
assert index == 2
def test_empty_db(sport_db):
nb_sports = get_nb_sport(sport_db)
assert nb_sports == 0
L’option --fixtures-per-test
liste les fixtures utilisées par un test :
$ pytest --fixtures-per-test tests/ex4/test_sqlite_db.py::test_get_index
-------------------------------------- fixtures used by test_get_index --------------------------------------
------------------------------------- (tests/ex4/test_sqlite_db.py:31) --------------------------------------
sport_db -- tests/ex4/conftest.py:8
no docstring available
sport_not_empty_db -- tests/ex4/conftest.py:23
no docstring available
L’option --setup-show
permet de suivre l’ordre d’exécution des fixtures et des tests.
$ pytest --setup-show tests/ex4/test_sqlite_db.py
======================================= test session starts =======================================
platform linux -- Python 3.10.6, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/fconil/Progs/python/pytest/les-tests-avec-pytest
collected 2 items
tests/ex4/test_sqlite_db.py
SETUP S sport_db
SETUP F sport_not_empty_db (fixtures used: sport_db)
tests/ex4/test_sqlite_db.py::test_get_index (fixtures used: sport_db, sport_not_empty_db).
TEARDOWN F sport_not_empty_db
tests/ex4/test_sqlite_db.py::test_empty_db (fixtures used: sport_db).
TEARDOWN S sport_db
======================================== 2 passed in 0.04s ========================================
L’option --fixtures
liste toutes les fixtures utilisables (avec builtins):
$ pytest --fixtures tests/ex4
cache -- .venv/lib/python3.10/site-packages/_pytest/cacheprovider.py:528
Return a cache object that can persist state between testing sessions.
...
-------------------------- fixtures defined from conftest --------------------------
sport_not_empty_db -- tests/ex4/conftest.py:23
no docstring available
sport_db [session scope] -- tests/ex4/conftest.py:8
no docstring available
def add_sport(conn, sport, nb_videos):
conn.execute("INSERT INTO sports (sport, nb_videos) VALUES (?, ?)", (sport, nb_videos))
conn.commit()
@pytest.fixture() # Le scope par défaut est "function"
def sports_list():
return [
{"sport": "swimming", "nb_videos": 100},
{"sport": "ping pong", "nb_videos": 50},
{"sport": "synchronized swimming", "nb_videos": 15},
]
def test_add_sport(sport_db, sports_list):
for sport in sports_list:
add_sport(sport_db, sport["sport"], sport["nb_videos"])
assert get_nb_sport(sport_db) == 3
FAILED / ERROR
Une exception qui se produit dans le code de la fonction de test aboutit à un test FAILED.
Une exception qui se produit dans le code d’une fixture aboutit à un test ERROR.
Évoquer
autouse
fixtureLes markers sont des décorateurs dont la syntaxe est @pytest.mark.<nom-du-marker>
.
pytest inclut un certain nombre de markers builtin qui modifient son comportement dans l’exécution des tests.
pytest permet la définition de markers custom, qui sont analogues à des tags et facilitent la sélection de sous-ensemble de tests à exécuter.
pytest --markers
permet de visualiser la liste des markers disponibles. Par défaut, on obtient la liste des markers builtins.
Marker | Description |
---|---|
@pytest.mark.filterwarnings(warning) |
add a warning filter to the given test |
@pytest.mark.skip(reason=None) |
skip the given test function with an optional reason |
@pytest.mark.skipif(condition, ..., *, reason=...) |
skip the given test function if any of the conditions evaluate to True |
@pytest.mark.xfail(condition, ..., *, reason=..., run=True, raises=None, strict=xfail_strict) |
mark the test function as an expected failure if any of the conditions evaluate to True |
@pytest.mark.parametrize(argnames, argvalues) |
call a test function multiple times passing in different arguments in turn |
@pytest.mark.usefixtures(fixturename1, fixturename2, ...) |
mark tests as needing all of the specified fixtures |
pytest.mark.skip
Ce marker permet de ne pas exécuter un test 1:
@pytest.mark.skip(reason="Function is not coded yet")
def test_no_sound_gap():
# Given a reference video, compute the gap with a given video using sound
video_ref = "samples/video_ref.mp4"
video_new = "samples/video_new.mp4"
# When we compute the gap between the video using the starter sound
gap = sound_gap_measure(video_ref, video_new)
# Then there should be no difference
assert gap == 0
Utiliser -v
pour afficher la raison configurée pour le saut de ce test.
pytest.mark.skipif
Ce marker permet de ne pas exécuter un test si une condition n’est pas remplie :
@pytest.mark.skipif(os.cpu_count() < 8, reason="Your computer is not powerfull enough")
def test_extract_frame():
# GIVEN a video
video = Path("samples/video.mp4")
frame = Path("output/frame.jpg")
# WHEN we extract a frame from the video
extract_frame(video, frame)
# THEN the frame should exist after the extraction
assert frame.exists()
Les options -ra
permettent d’afficher un bilan en fin de test avec les raisons pour les tests qui ne sont pas PASSED
.
$ pytest -ra tests/ex5/test_skip.py
=============================== test session starts ================================
platform linux -- Python 3.10.6, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/fconil/Progs/python/pytest/les-tests-avec-pytest
collected 2 items
tests/ex5/test_skip.py ss [100%]
============================= short test summary info ==============================
SKIPPED [1] tests/ex5/test_skip.py:11: Function is not coded yet
SKIPPED [1] tests/ex5/test_skip.py:29: Your computer is not powerfull enough
================================ 2 skipped in 0.01s ================================
pytest.mark.xfail
Les tests marqués par skip
et skipif
ne sont pas exécutés. xfail
sert à marquer des tests qui seront exécutés mais dont on sait qu’ils doivent échouer.
def get_windows_version():
return 10
def get_system():
return sys.platform() # linux on my computer
@pytest.mark.xfail(reason="I do not have a windows system")
def test_run_on_windows():
platform = get_system()
assert platform == "win32"
@pytest.mark.xfail(reason="We should required Windows 11")
def test_windows_version():
version = get_windows_version()
assert version >= 10
@pytest.mark.xfail(reason="We should required Windows 11", strict=True)
def test_windows_version_strict():
version = get_windows_version()
assert version >= 10
$ pytest -ra tests/ex5/test_xfail.py
=============================== test session starts ======================================
platform linux -- Python 3.10.6, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/fconil/Progs/python/pytest/les-tests-avec-pytest
collected 3 items
tests/ex5/test_xfail.py xXF [100%]
===================================== FAILURES ===========================================
___________________________ test_windows_version_strict __________________________________
[XPASS(strict)] We should required Windows 11
============================= short test summary info ====================================
XFAIL tests/ex5/test_xfail.py::test_run_on_windows - I do not have a windows system
XPASS tests/ex5/test_xfail.py::test_windows_version We should required Windows 11
FAILED tests/ex5/test_xfail.py::test_windows_version_strict
===================== 1 failed, 1 xfailed, 1 xpassed in 0.05s ============================
xfail
qui échoue est affiché XFAIL
/ x
xfail
qui passe est affiché XPASS
/ X
xfail
avec le paramètre strict=True
et qui passe est affiché FAILED
/ F
On peut définir nos propres markers pour sélectionner / éliminer des tests.
def extract_swimmer(video):
time.sleep(2)
return {"x": 12, "y": 34, "pos": 5, "meta_1": "val_1"}
def get_video_duration(video):
return 20
@pytest.mark.slow
def test_extract_swimmer():
video = "samples/video.mp4"
swimmer = extract_swimmer(video)
assert swimmer == {"x": 12, "y": 34, "pos": 5, "meta_1": "val_1"}
def test_get_video_duration():
video = "samples/video.mp4"
duration = get_video_duration(video)
assert duration == 20
Exécuter les tests correspondant à un marker donné avec l’option -m
.
Enregistrer les markers custom dans pytest.ini
pour éviter le warning. 1
$ pytest -v -m slow tests/ex5/test_custom.py
======================================= test session starts =======================================
collected 2 items / 1 deselected / 1 selected
tests/ex5/test_custom.py::test_extract_swimmer PASSED [100%]
======================================== warnings summary =========================================
tests/ex5/test_custom.py:27
/home/fconil/Progs/python/pytest/les-tests-avec-pytest/tests/ex5/test_custom.py:27: PytestUnknownMarkWarning:
Unknown pytest.mark.slow - is this a typo? You can register custom marks to avoid this warning
@pytest.mark.slow
=========================== 1 passed, 1 deselected, 1 warning in 2.02s ============================
Le fichier pytest.ini
permet d’enregistrer des options d’exécution de pytest, dont les markers. 2
La paramétrisation 1 des tests consiste à ajouter des paramètres aux fonctions de tests et à passer plusieurs jeux de tests en argument, permettant ainsi de multiplier les cas de tests.
Il est possible de “paramétrer” :
@pytest.mark.parametrize
params
du décorateur @pytest.fixtures
pytest_generate_tests
2La paramétrisation des fonctions de test est faite avec le décorateur @pytest.mark.parametrize
V1
def get_nb_frames(video, duration):
return duration * 24
@pytest.mark.parametrize(
"video, duration, nb_frames",
[
("samples/video1.mp4", 100, 2400),
("samples/video2.mp4", 15, 360),
("samples/video3.mp4", 5, 120),
])
def test_get_nb_frames(video, duration, nb_frames):
nbf = get_nb_frames(video, duration)
assert nbf == nb_frames
V2
def get_nb_frames(video, duration):
return duration * 24
@pytest.mark.parametrize(
["video", "duration", "nb_frames"],
[
["samples/video1.mp4", 100, 2400],
["samples/video2.mp4", 15, 360],
["samples/video3.mp4", 5, 120],
])
def test_get_nb_frames(video, duration, nb_frames):
nbf = get_nb_frames(video, duration)
assert nbf == nb_frames
pytest va exécuter test_get_nb_frames
pour les 3 jeux de test et faire un rapport séparé.
L’exécution avec -v
affiche les paramètres transmis par chaque jeu de données.
La paramétrisation des fonctions de test est faite via le paramètre params
du décorateur @pytest.fixture
1
Chaque fonction de test qui dépend de la fixture video_input
sera appelé pour chaque élément de params
.
La documentation n’indique pas comment passer un jeu de données de plusieurs paramètres à la fixture … recherche sur https://github.com/search
# conftest.py
@pytest.fixture(
params=[
{"video": "samples/video1.mp4", "duration": 100, "frames": 2400},
{"video": "samples/video2.mp4", "duration": 15, "frames": 360},
{"video": "samples/video3.mp4", "duration": 5, "frames": 120},
])
def video_frames_input(request):
return request.param
# test_fixture_parametrization.py
def get_nb_frames(video, duration):
return duration * 24
def test_get_nb_frames(video_frames_input):
nbf = get_nb_frames(video_frames_input["video"], video_frames_input["duration"])
assert nbf == video_frames_input["frames"]
pytest génère des identifiants de tests qui ne sont pas parlants.
Par contre, la documentation en ligne indique comment spécifier les identifiants de tests. 1
# conftest.py
@pytest.fixture(
params=[{"video": "samples/video1.mp4", "duration": 100, "frames": 2400},
{"video": "samples/video2.mp4", "duration": 15, "frames": 360},
{"video": "samples/video3.mp4", "duration": 5, "frames": 120}],
ids=["video1_d-100_f-2400", "video1_d-15_f-360", "video1_d-5_f-120"])
def video_frames_input_with_ids(request):
return request.param
# test_fixture_parametrization.py
def test_get_nb_frames_with_ids(video_frames_input_with_ids):
nbf = get_nb_frames(video_frames_input_with_ids["video"], video_frames_input_with_ids["duration"])
assert nbf == video_frames_input_with_ids["frames"]
Cela permet de mieux identifier les jeux de données utilisés.
Cette troisième possibilité utilise la fonction hook pytest_generate_tests
qui permet une adaptation plus fine et donne la possibilité de déterminer dynamiquement les paramètres ou le scope. 1
D’abord, on définit un paramètre supplémentaire, --video_path
, pour la ligne de commande pytest 2
On utilise pytest_generate_tests
pour définir dynamiquement le contenu de la fixture video_input
, qui n’a pas été définie au préalable.
# test_hook.py
def get_video_duration(video):
return 20
def pytest_generate_tests(metafunc):
if "video_input" in metafunc.fixturenames:
metafunc.parametrize("video_input", metafunc.config.getoption("video_path"))
def test_get_dynamic_video_duration(video_input):
video = video_input
assert get_video_duration(video) == 20
Exécution avec 2 vidéos en paramètre :
$ pytest -v --no-header --video_path="samples/video4.mp4" --video_path="samples/video5.mp4" tests/ex7/test_hook.py
======================================= test session starts =======================================
tests/ex7/test_hook.py::test_get_dynamic_video_duration[samples/video4.mp4] PASSED [ 50%]
tests/ex7/test_hook.py::test_get_dynamic_video_duration[samples/video5.mp4] PASSED [100%]
======================================== 2 passed in 0.01s ========================================
Si l’on ne passe aucun paramètre, le test n’est pas exécuté :
$ pytest -v --no-header tests/ex7/test_hook.py
======================================= test session starts =======================================
tests/ex7/test_hook.py::test_get_dynamic_video_duration[video_input0] SKIPPED (got empty parameter set ['video_input'], function test_get_dynamic_video_duration at /ho...) [100%]
======================================= 1 skipped in 0.01s ========================================
ATTENTION
@pytest.fixture(params=...)
et pytest_generate_tests
sont mutuellement exclusifs, cf issue 2726
Si on définit video_input
en tant que fixture paramétrée dans conftest.py
:
@pytest.fixture(
params=[
"samples/video1.mp4",
"samples/video2.mp4",
"samples/video3.mp4",
]
)
def video_input(request):
return request.param
On a une exception ValueError: duplicate 'video_type'
quand on exécute le test.
$ pytest -v --no-header --video_path="samples/video4.mp4" tests/ex6/test_hook.py
../../.venv/lib/python3.10/site-packages/_pytest/fixtures.py:1570: in pytest_generate_tests
metafunc.parametrize(
../../.venv/lib/python3.10/site-packages/_pytest/python.py:1347: in parametrize
newcallspec = callspec.setmulti(
../../.venv/lib/python3.10/site-packages/_pytest/python.py:1152: in setmulti
raise ValueError(f"duplicate {arg!r}")
E ValueError: duplicate 'video_type'
tests
├── pytest.ini
├── ex2
│ └── test_sport_list.py
├── ex3
│ ├── test_empty_db.py
│ └── test_not_empty_db.py
├── ex4
│ ├── conftest.py
│ └── test_sqlite_db.py
├── ex5
│ ├── test_custom.py
│ ├── test_skip.py
│ └── test_xfail.py
├── ex6
│ ├── conftest.py
│ ├── test_fixture_parametrization.py
│ ├── test_hook.py
│ ├── test_parametrize_1.py
│ └── test_parametrize_2.py
└── ex7
├── conftest.py
└── test_hook.py
Tip
L’auteur conseille de se limiter à un fichier conftest.py
pour retrouver ses fixtures plus facilement.