Parametrisierung von Tests

pytest ermöglicht die einfache Parametrisierung von Testfunktionen. Für grundlegende Dokumentation siehe Wie man Fixtures und Testfunktionen parametrisiert.

Im Folgenden finden Sie einige Beispiele, die die eingebauten Mechanismen verwenden.

Generieren von Parameterkombinationen, abhängig von der Kommandozeile

Nehmen wir an, wir möchten einen Test mit verschiedenen Berechnungsparametern ausführen und der Parameterbereich soll durch ein Kommandozeilenargument bestimmt werden. Schreiben wir zuerst einen einfachen (nichts tuenden) Berechnungstest

# content of test_compute.py


def test_compute(param1):
    assert param1 < 4

Nun fügen wir eine Testkonfiguration wie diese hinzu

# content of conftest.py


def pytest_addoption(parser):
    parser.addoption("--all", action="store_true", help="run all combinations")


def pytest_generate_tests(metafunc):
    if "param1" in metafunc.fixturenames:
        if metafunc.config.getoption("all"):
            end = 5
        else:
            end = 2
        metafunc.parametrize("param1", range(end))

Das bedeutet, dass wir nur 2 Tests ausführen, wenn wir --all nicht übergeben

$ pytest -q test_compute.py
..                                                                   [100%]
2 passed in 0.12s

Wir führen nur zwei Berechnungen durch, daher sehen wir zwei Punkte. Führen wir den vollen Umfang aus

$ pytest -q --all
....F                                                                [100%]
================================= FAILURES =================================
_____________________________ test_compute[4] ______________________________

param1 = 4

    def test_compute(param1):
>       assert param1 < 4
E       assert 4 < 4

test_compute.py:4: AssertionError
========================= short test summary info ==========================
FAILED test_compute.py::test_compute[4] - assert 4 < 4
1 failed, 4 passed in 0.12s

Wie erwartet, erhalten wir bei der Ausführung des gesamten Bereichs von param1 Werten einen Fehler beim letzten.

Unterschiedliche Optionen für Test-IDs

pytest erstellt für jeden Wertesatz in einem parametrisierten Test eine Zeichenkette, die die Test-ID ist. Diese IDs können mit -k verwendet werden, um bestimmte Fälle auszuwählen, und sie identifizieren auch den spezifischen Fall, wenn einer fehlschlägt. Wenn Sie pytest mit --collect-only ausführen, werden die generierten IDs angezeigt.

Zahlen, Zeichenketten, Booleans und None verwenden ihre übliche Zeichenkettendarstellung in der Test-ID. Für andere Objekte erstellt pytest eine Zeichenkette basierend auf dem Argumentnamen

# content of test_time.py

from datetime import datetime, timedelta

import pytest

testdata = [
    (datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1)),
    (datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1)),
]


@pytest.mark.parametrize("a,b,expected", testdata)
def test_timedistance_v0(a, b, expected):
    diff = a - b
    assert diff == expected


@pytest.mark.parametrize("a,b,expected", testdata, ids=["forward", "backward"])
def test_timedistance_v1(a, b, expected):
    diff = a - b
    assert diff == expected


def idfn(val):
    if isinstance(val, (datetime,)):
        # note this wouldn't show any hours/minutes/seconds
        return val.strftime("%Y%m%d")


@pytest.mark.parametrize("a,b,expected", testdata, ids=idfn)
def test_timedistance_v2(a, b, expected):
    diff = a - b
    assert diff == expected


@pytest.mark.parametrize(
    "a,b,expected",
    [
        pytest.param(
            datetime(2001, 12, 12), datetime(2001, 12, 11), timedelta(1), id="forward"
        ),
        pytest.param(
            datetime(2001, 12, 11), datetime(2001, 12, 12), timedelta(-1), id="backward"
        ),
    ],
)
def test_timedistance_v3(a, b, expected):
    diff = a - b
    assert diff == expected

In test_timedistance_v0 lassen wir pytest die Test-IDs generieren.

In test_timedistance_v1 haben wir ids als Liste von Zeichenketten angegeben, die als Test-IDs verwendet wurden. Diese sind prägnant, können aber mühsam zu warten sein.

In test_timedistance_v2 haben wir ids als Funktion angegeben, die eine Zeichenkettendarstellung generieren kann, um einen Teil der Test-ID zu bilden. Daher verwenden unsere datetime-Werte das von idfn generierte Label, aber da wir kein Label für timedelta-Objekte generiert haben, verwenden diese immer noch die Standarddarstellung von pytest

$ pytest test_time.py --collect-only
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 8 items

<Dir parametrize.rst-211>
  <Module test_time.py>
    <Function test_timedistance_v0[a0-b0-expected0]>
    <Function test_timedistance_v0[a1-b1-expected1]>
    <Function test_timedistance_v1[forward]>
    <Function test_timedistance_v1[backward]>
    <Function test_timedistance_v2[20011212-20011211-expected0]>
    <Function test_timedistance_v2[20011211-20011212-expected1]>
    <Function test_timedistance_v3[forward]>
    <Function test_timedistance_v3[backward]>

======================== 8 tests collected in 0.12s ========================

In test_timedistance_v3 haben wir pytest.param verwendet, um die Test-IDs zusammen mit den eigentlichen Daten anzugeben, anstatt sie separat aufzulisten.

Ein schneller Port von "testscenarios"

Hier ist ein schneller Port, um Tests auszuführen, die mit testscenarios konfiguriert sind, einem Add-on von Robert Collins für das Standard-unittest-Framework. Wir müssen nur ein wenig arbeiten, um die richtigen Argumente für Metafunc.parametrize von pytest zu konstruieren

# content of test_scenarios.py


def pytest_generate_tests(metafunc):
    idlist = []
    argvalues = []
    for scenario in metafunc.cls.scenarios:
        idlist.append(scenario[0])
        items = scenario[1].items()
        argnames = [x[0] for x in items]
        argvalues.append([x[1] for x in items])
    metafunc.parametrize(argnames, argvalues, ids=idlist, scope="class")


scenario1 = ("basic", {"attribute": "value"})
scenario2 = ("advanced", {"attribute": "value2"})


class TestSampleWithScenarios:
    scenarios = [scenario1, scenario2]

    def test_demo1(self, attribute):
        assert isinstance(attribute, str)

    def test_demo2(self, attribute):
        assert isinstance(attribute, str)

Dies ist ein vollständig in sich geschlossenes Beispiel, das Sie ausführen können mit

$ pytest test_scenarios.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 4 items

test_scenarios.py ....                                               [100%]

============================ 4 passed in 0.12s =============================

Wenn Sie nur Tests sammeln, sehen Sie auch schön 'advanced' und 'basic' als Varianten für die Testfunktion

$ pytest --collect-only test_scenarios.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 4 items

<Dir parametrize.rst-211>
  <Module test_scenarios.py>
    <Class TestSampleWithScenarios>
      <Function test_demo1[basic]>
      <Function test_demo2[basic]>
      <Function test_demo1[advanced]>
      <Function test_demo2[advanced]>

======================== 4 tests collected in 0.12s ========================

Beachten Sie, dass wir metafunc.parametrize() mitgeteilt haben, dass Ihre Szenario-Werte als klassenbezogen betrachtet werden sollen. Mit pytest-2.3 führt dies zu einer ressourcenbasierten Reihenfolge.

Verzögertes Setup von parametrisierten Ressourcen

Die Parametrisierung von Testfunktionen erfolgt zur Sammelzeit. Es ist eine gute Idee, teure Ressourcen wie DB-Verbindungen oder Subprozesse erst dann einzurichten, wenn der eigentliche Test ausgeführt wird. Hier ist ein einfaches Beispiel, wie Sie dies erreichen können. Dieser Test erfordert eine db Objekt-Fixture

# content of test_backends.py

import pytest


def test_db_initialized(db):
    # a dummy test
    if db.__class__.__name__ == "DB2":
        pytest.fail("deliberately failing for demo purposes")

Wir können nun eine Testkonfiguration hinzufügen, die zwei Aufrufe der Funktion test_db_initialized generiert und auch eine Fabrik implementiert, die ein Datenbankobjekt für die tatsächlichen Testaufrufe erstellt

# content of conftest.py
import pytest


def pytest_generate_tests(metafunc):
    if "db" in metafunc.fixturenames:
        metafunc.parametrize("db", ["d1", "d2"], indirect=True)


class DB1:
    "one database object"


class DB2:
    "alternative database object"


@pytest.fixture
def db(request):
    if request.param == "d1":
        return DB1()
    elif request.param == "d2":
        return DB2()
    else:
        raise ValueError("invalid internal test config")

Lassen Sie uns zuerst sehen, wie es zur Sammelzeit aussieht

$ pytest test_backends.py --collect-only
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 2 items

<Dir parametrize.rst-211>
  <Module test_backends.py>
    <Function test_db_initialized[d1]>
    <Function test_db_initialized[d2]>

======================== 2 tests collected in 0.12s ========================

Und dann, wenn wir den Test ausführen

$ pytest -q test_backends.py
.F                                                                   [100%]
================================= FAILURES =================================
_________________________ test_db_initialized[d2] __________________________

db = <conftest.DB2 object at 0xdeadbeef0001>

    def test_db_initialized(db):
        # a dummy test
        if db.__class__.__name__ == "DB2":
>           pytest.fail("deliberately failing for demo purposes")
E           Failed: deliberately failing for demo purposes

test_backends.py:8: Failed
========================= short test summary info ==========================
FAILED test_backends.py::test_db_initialized[d2] - Failed: deliberately f...
1 failed, 1 passed in 0.12s

Der erste Aufruf mit db == "DB1" war erfolgreich, während der zweite mit db == "DB2" fehlgeschlagen ist. Unsere db Fixture-Funktion hat jeden der DB-Werte während der Setup-Phase instanziiert, während pytest_generate_tests während der Sammelphase zwei entsprechende Aufrufe an test_db_initialized generiert hat.

Indirekte Parametrisierung

Die Verwendung des Parameters indirect=True bei der Parametrisierung eines Tests ermöglicht es, einen Test mit einer Fixture zu parametrisieren, die die Werte empfängt, bevor sie an einen Test weitergegeben werden

import pytest


@pytest.fixture
def fixt(request):
    return request.param * 3


@pytest.mark.parametrize("fixt", ["a", "b"], indirect=True)
def test_indirect(fixt):
    assert len(fixt) == 3

Dies kann beispielsweise verwendet werden, um teurere Setups zur Laufzeit im Fixture durchzuführen, anstatt diese Setup-Schritte zur Sammelzeit ausführen zu müssen.

Anwenden von indirekter Parametrisierung auf bestimmte Argumente

Sehr oft verwendet die Parametrisierung mehr als einen Argumentnamen. Es besteht die Möglichkeit, den Parameter indirect auf bestimmte Argumente anzuwenden. Dies kann durch Übergabe einer Liste oder eines Tupels von Argumentnamen an indirect erfolgen. Im folgenden Beispiel gibt es eine Funktion test_indirect, die zwei Fixtures verwendet: x und y. Hier geben wir an indirect die Liste, die den Namen der Fixture x enthält. Der indirekte Parameter wird nur auf dieses Argument angewendet und der Wert a wird an die entsprechende Fixture-Funktion übergeben

# content of test_indirect_list.py

import pytest


@pytest.fixture(scope="function")
def x(request):
    return request.param * 3


@pytest.fixture(scope="function")
def y(request):
    return request.param * 2


@pytest.mark.parametrize("x, y", [("a", "b")], indirect=["x"])
def test_indirect(x, y):
    assert x == "aaa"
    assert y == "b"

Das Ergebnis dieses Tests wird erfolgreich sein

$ pytest -v test_indirect_list.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 1 item

test_indirect_list.py::test_indirect[a-b] PASSED                     [100%]

============================ 1 passed in 0.12s =============================

Parametrisierung von Testmethoden durch klasseninterne Konfiguration

Hier ist eine pytest_generate_tests Funktion, die ein Parametrisierungsschema implementiert, ähnlich dem des Michael Foord’schen unittest parametrisers, aber mit viel weniger Code

# content of ./test_parametrize.py
import pytest


def pytest_generate_tests(metafunc):
    # called once per each test function
    funcarglist = metafunc.cls.params[metafunc.function.__name__]
    argnames = sorted(funcarglist[0])
    metafunc.parametrize(
        argnames, [[funcargs[name] for name in argnames] for funcargs in funcarglist]
    )


class TestClass:
    # a map specifying multiple argument sets for a test method
    params = {
        "test_equals": [dict(a=1, b=2), dict(a=3, b=3)],
        "test_zerodivision": [dict(a=1, b=0)],
    }

    def test_equals(self, a, b):
        assert a == b

    def test_zerodivision(self, a, b):
        with pytest.raises(ZeroDivisionError):
            a / b

Unser Testgenerator sucht nach einer klassenweiten Definition, die angibt, welche Argumentsets für jede Testfunktion verwendet werden sollen. Lassen Sie uns ihn ausführen

$ pytest -q
F..                                                                  [100%]
================================= FAILURES =================================
________________________ TestClass.test_equals[1-2] ________________________

self = <test_parametrize.TestClass object at 0xdeadbeef0002>, a = 1, b = 2

    def test_equals(self, a, b):
>       assert a == b
E       assert 1 == 2

test_parametrize.py:21: AssertionError
========================= short test summary info ==========================
FAILED test_parametrize.py::TestClass::test_equals[1-2] - assert 1 == 2
1 failed, 2 passed in 0.12s

Parametrisierung mit mehreren Fixtures

Hier ist ein vereinfachtes Beispiel aus der Praxis für die Verwendung von parametrisierten Tests zum Testen der Serialisierung von Objekten zwischen verschiedenen Python-Interpretern. Wir definieren eine Funktion test_basic_objects, die mit verschiedenen Argumentsets für ihre drei Argumente ausgeführt werden soll

  • python1: erster Python-Interpreter, ausgeführt, um ein Objekt in eine Datei zu pickle-dumpen

  • python2: zweiter Interpreter, ausgeführt, um ein Objekt aus einer Datei zu pickle-laden

  • obj: zu dumpendes/ladendes Objekt

"""Module containing a parametrized tests testing cross-python serialization
via the pickle module."""

from __future__ import annotations

import shutil
import subprocess
import textwrap

import pytest


pythonlist = ["python3.11", "python3.12", "python3.13"]


@pytest.fixture(params=pythonlist)
def python1(request, tmp_path):
    picklefile = tmp_path / "data.pickle"
    return Python(request.param, picklefile)


@pytest.fixture(params=pythonlist)
def python2(request, python1):
    return Python(request.param, python1.picklefile)


class Python:
    def __init__(self, version, picklefile):
        self.pythonpath = shutil.which(version)
        if not self.pythonpath:
            pytest.skip(f"{version!r} not found")
        self.picklefile = picklefile

    def dumps(self, obj):
        dumpfile = self.picklefile.with_name("dump.py")
        dumpfile.write_text(
            textwrap.dedent(
                rf"""
                import pickle
                f = open({str(self.picklefile)!r}, 'wb')
                s = pickle.dump({obj!r}, f, protocol=2)
                f.close()
                """
            )
        )
        subprocess.run((self.pythonpath, str(dumpfile)), check=True)

    def load_and_is_true(self, expression):
        loadfile = self.picklefile.with_name("load.py")
        loadfile.write_text(
            textwrap.dedent(
                rf"""
                import pickle
                f = open({str(self.picklefile)!r}, 'rb')
                obj = pickle.load(f)
                f.close()
                res = eval({expression!r})
                if not res:
                    raise SystemExit(1)
                """
            )
        )
        print(loadfile)
        subprocess.run((self.pythonpath, str(loadfile)), check=True)


@pytest.mark.parametrize("obj", [42, {}, {1: 3}])
def test_basic_objects(python1, python2, obj):
    python1.dumps(obj)
    python2.load_and_is_true(f"obj == {obj}")

Das Ausführen führt zu einigen Überspringungen, wenn wir nicht alle Python-Interpreter installiert haben, und andernfalls werden alle Kombinationen ausgeführt (3 Interpreter mal 3 Interpreter mal 3 zu serialisierende/deserialisierende Objekte)

. $ pytest -rs -q multipython.py
ssssssssssss......sss......                                          [100%]
========================= short test summary info ==========================
SKIPPED [15] multipython.py:67: 'python3.11' not found
12 passed, 15 skipped in 0.12s

Parametrisierung optionaler Implementierungen/Importe

Wenn Sie die Ergebnisse mehrerer Implementierungen einer gegebenen API vergleichen möchten, können Sie Testfunktionen schreiben, die die bereits importierten Implementierungen empfangen und übersprungen werden, falls die Implementierung nicht importierbar/verfügbar ist. Nehmen wir an, wir haben eine "Basis"-Implementierung und die anderen (möglicherweise optimierten) müssen ähnliche Ergebnisse liefern

# content of conftest.py

import pytest


@pytest.fixture(scope="session")
def basemod(request):
    return pytest.importorskip("base")


@pytest.fixture(scope="session", params=["opt1", "opt2"])
def optmod(request):
    return pytest.importorskip(request.param)

Und dann eine Basisimplementierung einer einfachen Funktion

# content of base.py
def func1():
    return 1

Und eine optimierte Version

# content of opt1.py
def func1():
    return 1.0001

Und schließlich ein kleines Testmodul

# content of test_module.py


def test_func1(basemod, optmod):
    assert round(basemod.func1(), 3) == round(optmod.func1(), 3)

Wenn Sie dies mit aktivierter Berichterstattung für Überspringungen ausführen

$ pytest -rs test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 2 items

test_module.py .s                                                    [100%]

========================= short test summary info ==========================
SKIPPED [1] test_module.py:3: could not import 'opt2': No module named 'opt2'
======================= 1 passed, 1 skipped in 0.12s =======================

Sie werden sehen, dass wir kein opt2 Modul haben und daher der zweite Testlauf unserer test_func1 übersprungen wurde. Ein paar Anmerkungen

  • Die Fixture-Funktionen in der Datei conftest.py sind "session-scoped", da wir nicht mehr als einmal importieren müssen

  • Wenn Sie mehrere Testfunktionen und einen übersprungenen Import haben, sehen Sie in der Berichterstattung eine Erhöhung des Zählers [1]

  • Sie können die Parametrisierung im Stil von @pytest.mark.parametrize auf die Testfunktionen anwenden, um auch Eingabe-/Ausgabewerte zu parametrisieren.

Setzen von Markierungen oder Test-IDs für einzelne parametrisierte Tests

Verwenden Sie pytest.param, um Markierungen anzuwenden oder die Test-ID für einzelne parametrisierte Tests festzulegen. Zum Beispiel

# content of test_pytest_param_example.py
import pytest


@pytest.mark.parametrize(
    "test_input,expected",
    [
        ("3+5", 8),
        pytest.param("1+7", 8, marks=pytest.mark.basic),
        pytest.param("2+4", 6, marks=pytest.mark.basic, id="basic_2+4"),
        pytest.param(
            "6*9", 42, marks=[pytest.mark.basic, pytest.mark.xfail], id="basic_6*9"
        ),
    ],
)
def test_eval(test_input, expected):
    assert eval(test_input) == expected

In diesem Beispiel haben wir 4 parametrisierte Tests. Mit Ausnahme des ersten Tests markieren wir die restlichen drei parametrisierten Tests mit der benutzerdefinierten Markierung basic, und für den vierten Test verwenden wir auch die eingebaute Markierung xfail, um anzuzeigen, dass dieser Test fehlschlagen wird. Zur Verdeutlichung setzen wir Test-IDs für einige Tests.

Führen Sie dann pytest mit verbose-Modus und nur mit der Markierung basic aus

$ pytest -v -m basic
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-9.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 24 items / 21 deselected / 3 selected

test_pytest_param_example.py::test_eval[1+7-8] PASSED                [ 33%]
test_pytest_param_example.py::test_eval[basic_2+4] PASSED            [ 66%]
test_pytest_param_example.py::test_eval[basic_6*9] XFAIL             [100%]

=============== 2 passed, 21 deselected, 1 xfailed in 0.12s ================

Als Ergebnis

  • Vier Tests wurden gesammelt

  • Ein Test wurde deselektiert, da er die Markierung basic nicht hat.

  • Drei Tests mit der Markierung basic wurden ausgewählt.

  • Der Test test_eval[1+7-8] war erfolgreich, aber der Name ist automatisch generiert und verwirrend.

  • Der Test test_eval[basic_2+4] war erfolgreich.

  • Der Test test_eval[basic_6*9] sollte fehlschlagen und tat dies auch.

Parametrisierung von bedingtem Auslösen

Verwenden Sie pytest.raises() mit dem Decorator pytest.mark.parametrize, um parametrisierte Tests zu schreiben, bei denen einige Tests Ausnahmen auslösen und andere nicht.

contextlib.nullcontext kann verwendet werden, um Fälle zu testen, bei denen keine Ausnahmen ausgelöst werden sollen, die aber zu einem Wert führen sollten. Der Wert wird als Parameter enter_result übergeben, der als Ziel der with-Anweisung (im folgenden Beispiel e) verfügbar ist.

Zum Beispiel

from contextlib import nullcontext

import pytest


@pytest.mark.parametrize(
    "example_input,expectation",
    [
        (3, nullcontext(2)),
        (2, nullcontext(3)),
        (1, nullcontext(6)),
        (0, pytest.raises(ZeroDivisionError)),
    ],
)
def test_division(example_input, expectation):
    """Test how much I know division."""
    with expectation as e:
        assert (6 / example_input) == e

Im obigen Beispiel sollten die ersten drei Testfälle ohne Ausnahmen ausgeführt werden, während der vierte eine ZeroDivisionError Ausnahme auslösen sollte, die von pytest erwartet wird.