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:
assertvon reinem Python. Sie benötigen keineassertEqualoder separate Assertion-Bibliothek. pytest schreibtassertum, um detaillierte Fehler zu melden.pytest.raisesüberprüft, ob ein Code-Block eine Exception des richtigen Typs wirft. Der Parametermatchist 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.fixturemarkiert 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
orderempfängt die Fixturecustomer. 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
yieldauftritt, 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 odertry/finallyverwenden, 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
conftestnicht 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_pathist einpathlib.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. Nebensetenvverwenden Sie häufigmonkeypatch.setattr(obj, "name", value).capsyserfasst die Ausgabe zustdoutundstderr.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 FehlerFixture "customer" called directlyaus. Deklarieren Sie sie als Parameter. - Breiter Scope mit veränderlichen Daten. Eine
Ordermitmodule-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 Scopefunctionwie 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
ordervoncustomerabhängt,customervonaddressabhängt,addressvoncountryabhä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
assertin 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.