Les tests avec pytest

Françoise CONIL

Présentation

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.

Pourquoi faire des tests ?

  • Vérifier que le code fait ce qu’il doit faire
  • Vérifier que le code continue à fonctionner comme prévu après des modifications, facilite le refactoring
  • Continuer à s’assurer que le code fonctionne même longtemps après
  • Augmente la confiance dans le logiciel
  • Plus un défaut est détecté tard, plus son impact peut être important et difficile à corriger
  • Peut aider à l’écriture du code (TDD)

ATTENTION

Les tests doivent être indépendants et pouvoir s’exécuter dans n’importe quel ordre.

Tests en Python

doctest

doctest est un module de tests intégré à la librairie standard.

Il permet d’intégrer des tests à l’intérieur des docstrings.

unittest

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

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)

Installation de pytest

L’installation 1 est classique : python -m pip install pytest 2

Utilisation basique

Test d’une fonction add() définie dans le fichier tests/ex1/test_add.py.

def add(n):
    return n + 1

def test_add_one():
    # Expect a function adding 1 to the number passed
    assert add(3) == 4

def test_add_two():
    # Expect a function adding 2 to the number passed
    assert add(3) == 5

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.

Exécution de pytest

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

Règles de découverte des tests

  • Les fichiers de test doivent se nommer test_xxxx.py ou xxxx_test.py
  • Les méthodes et les fonctions de test doivent se nommer test_xxxx
  • Si les tests ont été regroupés dans une classe, la classe doit se nommer TestXxxx

Résultat des tests

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é du résultat

Tester la conformité aux résultats attendus est très simple avec pytest.

assert

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.

Ne pas lancer les tests directement avec Python

Inutile de reprendre les mécanismes unittest

if __name__ == "__main__":
    test_add_one()
    test_add_two()

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 :

Tester la survenue d’une exception

pytest.raises() permet de tester qu’une Exception attendue se produit effectivement.

def test_change_video_speed_no_video():
    with pytest.raises(OSError):
        change_video_speed("samples/pong.mp4", "output/pong_speed.mp4", 2)

Structurer les fonctions de test

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.

  • Given : mettre en place le contexte pour exécuter le test
  • When : exécuter le code que l’on veut tester
  • Then : vérifier que l’exécution produit le résultat attendu
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)

Grouper les tests dans des classes

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.

Quelques options pour le debug

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

Fixtures

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

  • les fixtures sont exécutées avant les tests qui les référencent
  • le code avant le yield est le code de setup (mise en place du contexte)
  • le yield passe la main au code de test
  • après le test le code de teardown, situé après le yield, 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

Fixture scope

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.

conftest.py

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.

Utiliser les fixtures

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

Visualiser l’exécution du test

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

Utiliser plusieurs fixtures

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

À savoir

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

Markers

Les 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.

Visualiser les markers

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 ============================
  • un test marqué xfail qui échoue est affiché XFAIL / x
  • un test marqué xfail qui passe est affiché XPASS / X
  • un test marqué xfail avec le paramètre strict=True et qui passe est affiché FAILED / F

Marker custom

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 ============================

pytest.ini

Le fichier pytest.ini permet d’enregistrer des options d’exécution de pytest, dont les markers. 2

[pytest]
markers =
    slow: tests that take some time to execute

Paramétrisation

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” :

  • les fonctions de test avec le décorateur @pytest.mark.parametrize
  • les fixtures avec le paramètre params du décorateur @pytest.fixtures
  • ou d’utiliser le hook pytest_generate_tests 2

Paramétrisation des fonctions de test

La paramétrisation des fonctions de test est faite avec le décorateur @pytest.mark.parametrize

  • le premier paramètre du décorateur contient la liste des noms de paramètres, sous forme de chaine de caractères (V1) ou de liste (V2)
  • le second paramètre du décorateur est la liste des “jeux de données” qui peut être une liste des tuples (V1) ou une liste de listes (V2)

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.

Paramétrisation des fixtures

La paramétrisation des fonctions de test est faite via le paramètre params du décorateur @pytest.fixture 1

# conftest.py

@pytest.fixture(
    params=[
        "samples/video1.mp4",
        "samples/video2.mp4",
        "samples/video3.mp4",
    ])
def video_input(request):
    return request.param
# test_fixture_parametrization.py

def get_video_duration(video):
    return 20

def test_get_video_duration(video_input):
    video = video_input
    assert get_video_duration(video) == 20

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.

Paramétrisation via un hook

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

# conftest.py

def pytest_addoption(parser):
    parser.addoption(
        "--video_path",
        action="append",
        default=[],
        help="list of video path to pass to test functions",
    )

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'

Arborescence des tests

Code source

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.

Pour aller plus loin

  • builtin fixtures
  • Faker, un package qui permet de générer des données fictives
  • Hypothesis permet de faire du property-based testing pour ne pas se contenter de tests sur des données fournies par les développeurs et faire des tests sur des valeurs pseudo-aléatoire