Tests mit pytest und Fixtures in Python schreiben

1. Übersicht

Wenn die Anzahl der Tests zunimmt, werden die Datenvorbereitung (Erstellen von Kunden, Bestellungen, Datenbankverbindungen…) normalerweise überall wiederholt. pytest löst dieses Problem mit Fixtures: eine Funktion, die Daten oder Ressourcen für Tests bereitstellt und diese automatisch aufräumt, nachdem der Test beendet ist.

Dieser Artikel führt Sie vom ersten pytest-Test bis zu Fixtures mit yield, Scope, conftest.py, eingebauten Fixtures und Parametrisierung. Nach dem Lesen können Sie Ihre Python-Test-Suite organisiert, lesbar und mit weniger Code-Wiederholung aufbauen.

2. Installation von pytest

Sie sollten pytest in einer virtuellen Umgebung installieren:

python -m venv .venv
source .venv/bin/activate
pip install pytest

Die Projektstruktur, die in diesem Artikel verwendet wird, sieht wie folgt aus:

shop/
├── shop.py
└── tests/
    ├── conftest.py
    ├── test_order.py
    └── test_repository.py

pytest findet automatisch Dateien mit dem Namen test_*.py oder *_test.py. In jeder Datei werden Funktionen ausgeführt, die mit test_ beginnen.

Um shop aus dem Verzeichnis tests zu importieren, fügen Sie die Konfiguration zu pyproject.toml hinzu:

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]

3. Erster Test

Dies ist der Geschäftscode in shop.py. Der Preis wird in VND (Typ int) berechnet, um Fehler bei float zu vermeiden:

from dataclasses import dataclass, field


@dataclass
class Customer:
    name: str
    is_vip: bool = False


@dataclass
class Order:
    customer: Customer
    items: list[tuple[str, int, int]] = field(default_factory=list)

    def add_item(self, sku: str, quantity: int, price: int) -> None:
        if quantity <= 0:
            raise ValueError("Menge muss positiv sein")
        self.items.append((sku, quantity, price))

    def total(self) -> int:
        subtotal = sum(qty * price for _, qty, price in self.items)
        return subtotal * 90 // 100 if self.customer.is_vip else subtotal

VIP-Kunden erhalten einen Rabatt von 10%. Test für diese Logik in tests/test_order.py:

import pytest

from shop import Customer, Order


def test_total_for_regular_customer():
    order = Order(Customer("An"))
    order.add_item("BOOK-01", 2, 50_000)

    assert order.total() == 100_000


def test_add_item_rejects_zero_quantity():
    order = Order(Customer("An"))

    with pytest.raises(ValueError, match="Menge muss positiv sein"):
        order.add_item("BOOK-01", 0, 50_000)

Sie brauchen nur zwei Dinge:

  • assert von reinem Python. Sie benötigen keine assertEqual oder separate Assertion-Bibliothek. pytest schreibt assert um, um detaillierte Fehler zu melden.
  • pytest.raises überprüft, ob ein Code-Block eine Exception des richtigen Typs wirft. Der Parameter match ist ein Regex zum Vergleich mit der Exception-Nachricht.

Test ausführen:

pytest -v
tests/test_order.py::test_total_for_regular_customer PASSED              [ 50%]
tests/test_order.py::test_add_item_rejects_zero_quantity PASSED          [100%]

============================== 2 passed in 0.01s ===============================

Wenn Sie den erwarteten Wert absichtlich in 90_000 ändern, gibt pytest beide Seiten aus:

>       assert order.total() == 90_000
E       assert 100000 == 90000
E        +  where 100000 = total()

4. Grundlegende Fixtures

4.1. Deklaration und Verwendung von Fixtures

Beide Tests oben erstellen Customer("An") und Order. Bei Dutzenden von Tests wird dieser Code viel wiederholt. Wir verschieben ihn zu einer Fixture:

import pytest

from shop import Customer, Order


@pytest.fixture
def customer():
    return Customer("An")


@pytest.fixture
def order(customer):
    return Order(customer)


def test_total_for_regular_customer(order):
    order.add_item("BOOK-01", 2, 50_000)
    assert order.total() == 100_000


def test_empty_order_has_zero_total(order):
    assert order.total() == 0

So funktioniert es:

  • @pytest.fixture markiert eine Funktion als Fixture.
  • Der Test deklariert einen Parameter mit dem gleichen Namen wie die Fixture. pytest ruft die Fixture auf und übergibt den zurückgegebenen Wert an den Test. Dies ist Dependency Injection nach Name.
  • Die Fixture order empfängt die Fixture customer. Fixtures können von anderen Fixtures abhängen, pytest löst die Reihenfolge automatisch auf.

Standardmäßig erhält jeder Test eine neue Instanz. Daher ändert add_item im ersten Test nicht die order des zweiten Tests.

4.2. Factory-Fixtures

Manchmal benötigt ein Test mehrere Bestellungen mit unterschiedlichen Parametern. Anstatt ein Objekt zurückzugeben, kann eine Fixture eine Fabrikfunktion zurückgeben:

@pytest.fixture
def make_order():
    def _make(name="An", is_vip=False, items=()):
        order = Order(Customer(name, is_vip))
        for sku, qty, price in items:
            order.add_item(sku, qty, price)
        return order
    return _make


def test_vip_gets_ten_percent_discount(make_order):
    regular = make_order(items=[("BOOK-01", 1, 200_000)])
    vip = make_order(name="Binh", is_vip=True, items=[("BOOK-01", 1, 200_000)])

    assert regular.total() == 200_000
    assert vip.total() == 180_000

Dieses Pattern hält die Vorbereitung sauber, während der Test die Daten selbst bestimmen kann.

5. Setup und Teardown mit yield

Fixtures sind wirklich nützlich, wenn Sie mit Ressourcen arbeiten, die aufgeräumt werden müssen, wie Datenbankverbindungen, Dateien oder Mock-Server. Fügen Sie zu shop.py ein Repository mit SQLite hinzu:

import sqlite3


class OrderRepository:
    def __init__(self, conn: sqlite3.Connection):
        self.conn = conn

    def save(self, customer_name: str, total: int) -> int:
        cur = self.conn.execute(
            "INSERT INTO orders (customer, total) VALUES (?, ?)",
            (customer_name, total),
        )
        return cur.lastrowid

    def count(self) -> int:
        return self.conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0]

Fixtures verwenden yield statt return:

import sqlite3

import pytest

from shop import OrderRepository


@pytest.fixture
def db_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
    yield conn
    conn.close()


@pytest.fixture
def repository(db_connection):
    return OrderRepository(db_connection)


def test_save_order_returns_id(repository):
    order_id = repository.save("An", 100_000)

    assert order_id == 1
    assert repository.count() == 1

Der Teil vor yield ist das Setup. Der Wert nach yield wird an den Test übergeben. Der Teil nach yield ist das Teardown und läuft immer, auch wenn der Test fehlschlägt. Sie brauchen kein try/finally.

Das Flag --setup-show zeigt die Reihenfolge deutlich:

pytest tests/test_repository.py --setup-show
tests/test_repository.py
        SETUP    F db_connection
        SETUP    F repository (fixtures used: db_connection)
        tests/test_repository.py::test_save_order_returns_id (fixtures used: db_connection, repository).
        TEARDOWN F repository
        TEARDOWN F db_connection

Der Buchstabe F bedeutet Scope function. Teardown wird in umgekehrter Reihenfolge zum Setup ausgeführt.

Hinweis: Wenn eine Exception vor yield auftritt, wird das Teardown-Teil nicht ausgeführt. Der Grund ist, dass die Fixture noch nicht vollständig initialisiert wurde. Sie sollten das Setup kurz halten oder try/finally verwenden, wenn das Setup mehrere fehlerträchtige Schritte hat.

6. Fixture-Scope

Das Erstellen teurer Ressourcen (echte Datenbankverbindungen, Container starten…) für jeden Test macht die Test-Suite langsam. Der Parameter scope bestimmt, wie lange eine Fixture lebt:

Scope Neu erstellt wenn
function (Standard) jeder Test
class jede Test-Klasse
module jede Test-Datei
package jedes Package
session einmal pro Testlauf

Beispiel: Eine Verbindung für die ganze Datei freigeben, aber Daten nach jedem Test löschen:

@pytest.fixture(scope="module")
def db_connection():
    conn = sqlite3.connect(":memory:")
    conn.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, customer TEXT, total INTEGER)")
    yield conn
    conn.close()


@pytest.fixture
def repository(db_connection):
    yield OrderRepository(db_connection)
    db_connection.execute("DELETE FROM orders")


def test_first_save(repository):
    repository.save("An", 100_000)
    assert repository.count() == 1


def test_second_save_starts_clean(repository):
    repository.save("Binh", 50_000)
    assert repository.count() == 1

db_connection wird nur einmal erstellt. repository hat Scope function und räumt die Tabelle nach jedem Test auf. Dadurch sieht der zweite Test die Tabelle immer noch leer.

Es gibt eine wichtige Regel: Eine Fixture darf nur Fixtures mit gleichem oder größerem Scope verwenden. Wenn eine session-Fixture eine function-Fixture nutzt, gibt pytest einen Fehler aus:

ScopeMismatch: You tried to access the function scoped fixture repository
with a session scoped request object.

7. Freigabe von Fixtures über conftest.py

Die Fixtures customer, make_order sind nützlich für viele Test-Dateien. Sie legen sie in tests/conftest.py ab:

import pytest

from shop import Customer, Order


@pytest.fixture
def customer():
    return Customer("An")


@pytest.fixture
def order(customer):
    return Order(customer)

pytest lädt conftest.py automatisch. Alle Tests in diesem Verzeichnis und Unterverzeichnissen können diese Fixtures verwenden, ohne zu importieren. Sie können conftest.py in mehreren Verzeichnisebenen platzieren. Fixtures auf einer niedrigeren Ebene überschreiben Fixtures mit gleichem Namen auf einer höheren Ebene.

Hinweis: Importieren Sie conftest nicht in Test-Dateien. Dies kann dazu führen, dass Fixtures zweimal registriert werden und zu unerwartetem Verhalten führt.

Um zu sehen, welcher Test welche Fixture verwendet und wo diese definiert ist, führen Sie aus:

pytest --fixtures-per-test tests/test_order.py

8. Eingebaute Fixtures

pytest bietet viele praktische eingebaute Fixtures. Die drei am häufigsten verwendeten sind tmp_path, monkeypatch und capsys.

Angenommen, shop.py hat zwei weitere Funktionen:

import os
from pathlib import Path


def export_orders_csv(rows: list[tuple[str, int]], path: Path) -> None:
    lines = ["customer,total"] + [f"{name},{total}" for name, total in rows]
    path.write_text("\n".join(lines), encoding="utf-8")


class PaymentService:
    def __init__(self):
        self.api_key = os.environ["PAYMENT_API_KEY"]

    def charge(self, order: "Order") -> str:
        print(f"Zahle {order.total()} VND")
        return "SUCCESS"

Tests dafür:

from shop import PaymentService, export_orders_csv


def test_export_orders_csv(tmp_path):
    output = tmp_path / "orders.csv"

    export_orders_csv([("An", 100_000), ("Binh", 50_000)], output)

    assert output.read_text(encoding="utf-8").splitlines() == [
        "customer,total",
        "An,100000",
        "Binh,50000",
    ]


def test_payment_service_charges_order(monkeypatch, capsys, order):
    monkeypatch.setenv("PAYMENT_API_KEY", "test-key")
    order.add_item("BOOK-01", 1, 75_000)

    result = PaymentService().charge(order)

    assert result == "SUCCESS"
    assert capsys.readouterr().out == "Zahle 75000 VND\n"
  • tmp_path ist ein pathlib.Path, der auf ein temporäres Verzeichnis verweist, das für jeden Test eindeutig ist. Der Test verschmutzt nicht das Projektverzeichnis.
  • monkeypatch ändert Umgebungsvariablen, Attribute oder Funktionen im Scope eines Tests. Alle Änderungen werden automatisch rückgängig gemacht, wenn der Test endet. Neben setenv verwenden Sie häufig monkeypatch.setattr(obj, "name", value).
  • capsys erfasst die Ausgabe zu stdout und stderr. readouterr() gibt zurück, was vom Anfang des Tests oder vom letzten Aufruf aus gedruckt wurde.

9. Parametrisierung von Tests

9.1. @pytest.mark.parametrize

Wenn dieselbe Logik mit mehreren Datensätzen getestet werden muss, sollten Sie Tests nicht kopieren. Verwenden Sie parametrize:

import pytest


@pytest.mark.parametrize(
    ("is_vip", "price", "expected"),
    [
        (False, 100_000, 100_000),
        (True, 100_000, 90_000),
        (True, 15, 13),
    ],
    ids=["regular", "vip", "vip-rounding-down"],
)
def test_total_by_customer_type(make_order, is_vip, price, expected):
    order = make_order(is_vip=is_vip, items=[("SKU", 1, price)])
    assert order.total() == expected
tests/test_order.py::test_total_by_customer_type[regular] PASSED
tests/test_order.py::test_total_by_customer_type[vip] PASSED
tests/test_order.py::test_total_by_customer_type[vip-rounding-down] PASSED

Jeder Parametersatz ist ein separater Test mit lesbarer ID dank ids. Beachten Sie, dass Sie Fixtures (make_order) mit Parametern kombinieren können. Der Fall vip-rounding-down dokumentiert klar das Abrundungsverhalten des //-Operators.

9.2. Parametrisierte Fixtures

Wenn Sie möchten, dass alle Tests, die eine Fixture verwenden, mit mehreren Varianten ausgeführt werden, parametrisieren Sie die Fixture selbst:

@pytest.fixture(params=[False, True], ids=["regular", "vip"])
def any_customer(request):
    return Customer("An", is_vip=request.param)


def test_total_is_never_negative(any_customer):
    order = Order(any_customer)
    order.add_item("BOOK-01", 3, 10_000)
    assert order.total() >= 0

request ist eine spezielle Fixture. request.param enthält den aktuellen Wert. Der Test oben wird automatisch zweimal ausgeführt.

10. Häufige Fehler und wann Sie Fixtures nicht verwenden sollten

  • Falscher Fixture-Name. Ein Parameter-Name, der keiner Fixture entspricht, verursacht einen Fehler fixture 'oder' not found. pytest listet die verfügbaren Fixtures direkt darunter auf, lesen Sie sorgfältig, um den Fehler zu beheben.
  • Fixture wie eine normale Funktion aufrufen. Das Schreiben von customer() im Test gibt den Fehler Fixture "customer" called directly aus. Deklarieren Sie sie als Parameter.
  • Breiter Scope mit veränderlichen Daten. Eine Order mit module-Scope, der von einem Test ein Item hinzugefügt wird, kann einen anderen Test falsch machen. Das Ergebnis hängt von der Ausführungsreihenfolge ab, sehr schwer zu debuggen. Verwenden Sie Scope breit nur für teure und zustandslose Ressourcen oder räumen Sie den Status in einer Fixture mit Scope function wie in Abschnitt 6 auf.
  • Missbrauch von autouse=True. Die Fixture wird automatisch auf alle Tests angewendet, bequem, weil keine Deklaration erforderlich ist, aber der Test-Leser sieht sie nicht. Verwenden Sie dies nur für wirklich globale Dinge, z.B. Netzwerk blockieren.
  • Zu tiefe Fixture-Ketten. Wenn order von customer abhängt, customer von address abhängt, address von country abhängt…, muss der Leser viele Dateien durchsuchen, um den Test zu verstehen. Bei einfachen Daten ist es oft einfacher zu lesen, sie direkt im Test zu erstellen oder eine Factory-Fixture zu verwenden.

Hinweis: Fixtures sind Tools zur Umgebungsvorbereitung, nicht zum Speichern von Prüflogik. Halten Sie assert in der Test-Funktion, damit jeder Test beschreibt, was er testet.

11. Fazit

Sie haben die Kernteile von pytest durchlaufen. Reines assert und pytest.raises werden zum Überprüfen von Ergebnissen verwendet. Fixture-Injection nach Name hilft bei der Beseitigung wiederholter Vorbereitungscode. yield bietet sicheres Setup/Teardown, während scope zwischen Geschwindigkeit und Isolation ausgleicht. conftest.py teilt Fixtures zwischen mehreren Dateien. Eingebaute Fixtures wie tmp_path, monkeypatch, capsys verarbeiten Dateien, Umgebung und Ausgabe. Schließlich helfen parametrize und Fixtures mit params, viele Fälle abzudecken, ohne Tests zu kopieren.

Wenn Ihre Test-Suite wächst, sollten Sie mit Fixture-Scope function beginnen. Erweitern Sie den Scope nur, wenn Sie messen können, dass die Geschwindigkeit ein echtes Problem ist.

pythonpytesttestingfixtureunit-test