Unit Tests mit pytest
Lerne, wie du mit pytest automatisierte Tests schreibst, um die Qualität deines Python-Codes sicherzustellen.
Unit Tests mit pytest
Du hast eine Funktion geschrieben und sie “funktioniert” — aber tut sie das auch in allen Fällen? Was passiert bei leerer Eingabe? Bei negativen Zahlen? Bei riesigen Datenmengen? Automatisierte Tests geben dir Sicherheit. In diesem Tutorial lernst du, wie du mit pytest professionelle Tests schreibst.
Warum testen?
1. Qualität sicherstellen
# Du denkst, die Funktion funktioniert...
def ist_schaltjahr(jahr):
return jahr % 4 == 0
# Aber was ist mit 1900? (kein Schaltjahr!)
# Und 2000? (doch ein Schaltjahr!)
# Tests decken solche Fehler auf.
2. Vertrauen beim Ändern
Stell dir vor, du änderst eine Funktion, die an 20 Stellen verwendet wird. Ohne Tests: Angst, etwas kaputtzumachen. Mit Tests: Du änderst den Code und führst die Tests aus — grün bedeutet alles okay!
3. Refactoring ermöglichen
Tests sind wie ein Sicherheitsnetz beim Klettern. Du kannst mutig umbauen, weil du weißt, dass die Tests dich auffangen.
4. Dokumentation durch Tests
Gut geschriebene Tests zeigen, wie eine Funktion verwendet werden soll:
def test_addiere_positive_zahlen():
assert addiere(2, 3) == 5
def test_addiere_negative_zahlen():
assert addiere(-1, -1) == -2
def test_addiere_mit_null():
assert addiere(5, 0) == 5
pytest installieren
# pytest installieren
pip install pytest
# Überprüfen, ob es funktioniert
pytest --version
Erste Tests schreiben
pytest hat zwei einfache Regeln:
- Testdateien heißen
test_*.pyoder*_test.py - Testfunktionen beginnen mit
test_
Beispiel: Eine einfache Rechenfunktion testen
Erstelle zuerst die Datei rechner.py:
# rechner.py
def addiere(a, b):
return a + b
def subtrahiere(a, b):
return a - b
def multipliziere(a, b):
return a * b
def dividiere(a, b):
if b == 0:
raise ValueError("Division durch Null!")
return a / b
Dann erstelle test_rechner.py:
# test_rechner.py
from rechner import addiere, subtrahiere, multipliziere, dividiere
def test_addiere():
assert addiere(2, 3) == 5
def test_addiere_negative():
assert addiere(-1, -1) == -2
def test_addiere_float():
assert addiere(0.1, 0.2) == pytest.approx(0.3)
def test_subtrahiere():
assert subtrahiere(10, 3) == 7
def test_multipliziere():
assert multipliziere(4, 5) == 20
def test_multipliziere_mit_null():
assert multipliziere(100, 0) == 0
def test_dividiere():
assert dividiere(10, 2) == 5.0
def test_dividiere_durch_null():
import pytest
with pytest.raises(ValueError):
dividiere(10, 0)
assert-Anweisungen
assert ist das Herzstück von pytest. Es prüft, ob eine Bedingung wahr ist:
# Gleichheit prüfen
assert ergebnis == erwartet
# Ungleichheit prüfen
assert ergebnis != falsch
# Wahrheitswert prüfen
assert ist_gueltig
assert not ist_leer
# Enthaltensein prüfen
assert "Anna" in namen_liste
assert "xyz" not in namen_liste
# Typ prüfen
assert isinstance(ergebnis, int)
# Größenvergleiche
assert laenge > 0
assert alter >= 18
assert preis <= 100.0
# None prüfen
assert ergebnis is not None
assert fehler is None
Aussagekräftige Fehlermeldungen
pytest zeigt bei fehlgeschlagenen Tests automatisch nützliche Details:
def test_begruessung():
name = "Anna"
ergebnis = begruesse(name)
assert ergebnis == "Hallo Anna!"
# Bei Fehler zeigt pytest:
# AssertionError: assert 'Hallo, Anna!' == 'Hallo Anna!'
# ^ ^
# Unterschied wird markiert!
Fließkommazahlen vergleichen
Fließkommazahlen können nie exakt verglichen werden:
import pytest
def test_fliesskomma():
# FALSCH - kann fehlschlagen!
# assert 0.1 + 0.2 == 0.3
# RICHTIG - mit Toleranz vergleichen
assert 0.1 + 0.2 == pytest.approx(0.3)
assert 1/3 == pytest.approx(0.333, abs=0.001)
pytest ausführen
# Alle Tests im aktuellen Verzeichnis ausführen
pytest
# Mit ausführlicher Ausgabe
pytest -v
# Nur eine Datei testen
pytest test_rechner.py
# Nur einen bestimmten Test ausführen
pytest test_rechner.py::test_addiere
# Tests mit bestimmtem Namensmuster
pytest -k "addiere"
# Beim ersten Fehler stoppen
pytest -x
# Die letzten fehlgeschlagenen Tests erneut ausführen
pytest --lf
# Ausgabe von print()-Anweisungen anzeigen
pytest -s
Ausgabe verstehen
==================== test session starts ====================
collected 8 items
test_rechner.py ........ [100%]
==================== 8 passed in 0.03s ====================
# Symbole:
# . = Test bestanden (passed)
# F = Test fehlgeschlagen (failed)
# E = Fehler im Test selbst (error)
# s = Test übersprungen (skipped)
# x = Erwarteter Fehler (xfail)
Teststruktur: Arrange-Act-Assert
Das AAA-Muster (Arrange-Act-Assert) hilft dir, Tests klar zu strukturieren:
def test_benutzer_vollstaendiger_name():
# Arrange (Vorbereiten)
benutzer = Benutzer(vorname="Max", nachname="Mustermann")
# Act (Ausführen)
voller_name = benutzer.voller_name()
# Assert (Überprüfen)
assert voller_name == "Max Mustermann"
def test_warenkorb_gesamtpreis():
# Arrange
warenkorb = Warenkorb()
warenkorb.hinzufuegen(Artikel("Buch", 19.99))
warenkorb.hinzufuegen(Artikel("Stift", 2.50))
# Act
gesamt = warenkorb.gesamtpreis()
# Assert
assert gesamt == pytest.approx(22.49)
def test_passwort_zu_kurz():
# Arrange
validator = PasswortValidator(min_laenge=8)
passwort = "kurz"
# Act
ist_gueltig = validator.pruefen(passwort)
# Assert
assert ist_gueltig is False
Parametrisierte Tests
Statt viele ähnliche Tests zu schreiben, nutze @pytest.mark.parametrize:
import pytest
# Ohne Parametrisierung - viel Wiederholung
def test_ist_gerade_2():
assert ist_gerade(2) is True
def test_ist_gerade_4():
assert ist_gerade(4) is True
def test_ist_gerade_3():
assert ist_gerade(3) is False
# Mit Parametrisierung - viel eleganter!
@pytest.mark.parametrize("zahl,erwartet", [
(2, True),
(4, True),
(0, True),
(-2, True),
(1, False),
(3, False),
(7, False),
])
def test_ist_gerade(zahl, erwartet):
assert ist_gerade(zahl) is erwartet
Komplexere Parametrisierung
@pytest.mark.parametrize("eingabe,erwartet", [
("hallo", "Hallo"),
("WELT", "Welt"),
("pYtHoN", "Python"),
("a", "A"),
("", ""),
])
def test_titel_format(eingabe, erwartet):
assert titel_format(eingabe) == erwartet
# Mit IDs für bessere Ausgabe
@pytest.mark.parametrize("a,b,erwartet", [
pytest.param(2, 3, 5, id="positiv"),
pytest.param(-1, -1, -2, id="negativ"),
pytest.param(0, 0, 0, id="null"),
pytest.param(100, -50, 50, id="gemischt"),
], ids=str)
def test_addiere(a, b, erwartet):
assert addiere(a, b) == erwartet
Parametrisierte Tests für Fehlerfälle
@pytest.mark.parametrize("ungueltige_eingabe", [
"",
None,
" ",
"ab", # zu kurz
])
def test_benutzername_ungueltig(ungueltige_eingabe):
with pytest.raises((ValueError, TypeError)):
benutzername_validieren(ungueltige_eingabe)
Fixtures
Fixtures bereiten Testdaten vor und räumen danach auf. Sie vermeiden Wiederholung:
import pytest
# --- Einfache Fixture ---
@pytest.fixture
def beispiel_benutzer():
"""Erstellt einen Testbenutzer."""
return {
"name": "Anna",
"email": "anna@example.de",
"alter": 25,
}
def test_benutzer_name(beispiel_benutzer):
assert beispiel_benutzer["name"] == "Anna"
def test_benutzer_ist_erwachsen(beispiel_benutzer):
assert beispiel_benutzer["alter"] >= 18
# --- Fixture mit Setup und Teardown ---
@pytest.fixture
def temporaere_datei(tmp_path):
"""Erstellt eine temporäre Testdatei."""
datei = tmp_path / "testdaten.txt"
datei.write_text("Zeile 1\nZeile 2\nZeile 3\n", encoding="utf-8")
yield datei # Test wird hier ausgeführt
# Aufräumen (optional, tmp_path räumt automatisch auf)
print(f"Temporäre Datei aufgeräumt: {datei}")
def test_datei_lesen(temporaere_datei):
inhalt = temporaere_datei.read_text(encoding="utf-8")
assert "Zeile 1" in inhalt
assert inhalt.count("\n") == 3
# --- Fixture die andere Fixtures verwendet ---
@pytest.fixture
def benutzer_datenbank(beispiel_benutzer):
"""Erstellt eine Testdatenbank mit einem Benutzer."""
db = DatenbankMock()
db.benutzer_hinzufuegen(beispiel_benutzer)
yield db
db.schliessen()
Eingebaute Fixtures
pytest bringt nützliche Fixtures mit:
def test_temporaerer_ordner(tmp_path):
"""tmp_path erstellt einen temporären Ordner."""
datei = tmp_path / "test.txt"
datei.write_text("Hallo Welt!")
assert datei.read_text() == "Hallo Welt!"
def test_ausgabe_pruefen(capsys):
"""capsys fängt print()-Ausgaben ab."""
print("Hallo Welt!")
aufgefangen = capsys.readouterr()
assert aufgefangen.out == "Hallo Welt!\n"
def test_umgebungsvariable(monkeypatch):
"""monkeypatch ändert Umgebungsvariablen temporär."""
monkeypatch.setenv("API_KEY", "test-schluessel-123")
import os
assert os.environ["API_KEY"] == "test-schluessel-123"
Fixtures mit Scope
@pytest.fixture(scope="function") # Standard: Pro Test neu erstellt
def einmal_pro_test():
return DatenbankVerbindung()
@pytest.fixture(scope="module") # Einmal pro Testdatei
def einmal_pro_datei():
return DatenbankVerbindung()
@pytest.fixture(scope="session") # Einmal pro Testlauf
def einmal_pro_sitzung():
return DatenbankVerbindung()
Exceptions testen
Mit pytest.raises prüfst du, ob eine bestimmte Exception ausgelöst wird:
import pytest
def test_division_durch_null():
with pytest.raises(ZeroDivisionError):
1 / 0
def test_ungueltige_konvertierung():
with pytest.raises(ValueError):
int("abc")
# Die Fehlermeldung prüfen
def test_fehlermeldung():
with pytest.raises(ValueError, match="ungültig"):
validiere_email("keine-email")
# Auf die Exception zugreifen
def test_exception_details():
with pytest.raises(ValueError) as exc_info:
int("abc")
assert "invalid literal" in str(exc_info.value)
assert exc_info.type == ValueError
Eigene Exceptions testen
from meine_app import (
Bankkonto,
KontoUeberzeichnetError,
UngueltigerBetragError,
)
def test_abheben_zu_viel():
konto = Bankkonto(kontostand=100)
with pytest.raises(KontoUeberzeichnetError) as exc_info:
konto.abheben(200)
assert exc_info.value.betrag == 200
assert exc_info.value.kontostand == 100
def test_abheben_negativer_betrag():
konto = Bankkonto(kontostand=100)
with pytest.raises(UngueltigerBetragError):
konto.abheben(-50)
Test-Organisation
Für größere Projekte organisierst du Tests in einem eigenen Verzeichnis:
mein_projekt/
├── src/
│ ├── __init__.py
│ ├── rechner.py
│ ├── benutzer.py
│ └── datenbank.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Gemeinsame Fixtures
│ ├── test_rechner.py
│ ├── test_benutzer.py
│ └── test_datenbank.py
├── pyproject.toml
└── README.md
conftest.py — Gemeinsame Fixtures
# tests/conftest.py
import pytest
@pytest.fixture
def beispiel_daten():
"""Testdaten, die in allen Testdateien verfügbar sind."""
return {
"benutzer": [
{"name": "Anna", "alter": 25},
{"name": "Bob", "alter": 30},
{"name": "Clara", "alter": 17},
]
}
@pytest.fixture
def leere_datenbank():
"""Frische Testdatenbank für jeden Test."""
db = TestDatenbank()
db.erstellen()
yield db
db.loeschen()
pytest-Konfiguration in pyproject.toml
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
Test Coverage
Test Coverage zeigt dir, wie viel deines Codes von Tests abgedeckt wird:
# pytest-cov installieren
pip install pytest-cov
# Tests mit Coverage ausführen
pytest --cov=src
# Detaillierten Report in der Konsole
pytest --cov=src --cov-report=term-missing
# HTML-Report erstellen
pytest --cov=src --cov-report=html
# Öffne dann htmlcov/index.html im Browser
Coverage-Ausgabe verstehen
---------- coverage: ... ----------
Name Stmts Miss Cover Missing
--------------------------------------------------
src/rechner.py 15 2 87% 23-24
src/benutzer.py 30 5 83% 15, 28-31
src/datenbank.py 45 20 56% 12-25, 38-50
--------------------------------------------------
TOTAL 90 27 70%
- Stmts: Gesamtanzahl der Anweisungen
- Miss: Nicht getestete Anweisungen
- Cover: Prozentsatz der Abdeckung
- Missing: Zeilennummern ohne Testabdeckung
Ziel: Mindestens 80% Coverage ist ein guter Richtwert. 100% ist selten nötig oder sinnvoll.
TDD-Grundlagen (Test Driven Development)
TDD dreht den normalen Ablauf um: Erst den Test schreiben, dann den Code!
Der TDD-Zyklus: Red-Green-Refactor
1. RED: Schreibe einen Test, der fehlschlägt
2. GREEN: Schreibe den minimalen Code, damit der Test besteht
3. REFACTOR: Verbessere den Code, Tests bleiben grün
TDD in Aktion: FizzBuzz
Schritt 1: RED — Erster Test
# test_fizzbuzz.py
from fizzbuzz import fizzbuzz
def test_normale_zahl():
assert fizzbuzz(1) == "1"
assert fizzbuzz(2) == "2"
Test ausführen: FEHLER! fizzbuzz existiert noch nicht.
Schritt 2: GREEN — Minimaler Code
# fizzbuzz.py
def fizzbuzz(n):
return str(n)
Test ausführen: BESTANDEN!
Schritt 3: RED — Nächster Test
def test_durch_drei_teilbar():
assert fizzbuzz(3) == "Fizz"
assert fizzbuzz(6) == "Fizz"
assert fizzbuzz(9) == "Fizz"
Test ausführen: FEHLER!
Schritt 4: GREEN
def fizzbuzz(n):
if n % 3 == 0:
return "Fizz"
return str(n)
Schritt 5: RED
def test_durch_fuenf_teilbar():
assert fizzbuzz(5) == "Buzz"
assert fizzbuzz(10) == "Buzz"
Schritt 6: GREEN
def fizzbuzz(n):
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)
Schritt 7: RED
def test_durch_drei_und_fuenf_teilbar():
assert fizzbuzz(15) == "FizzBuzz"
assert fizzbuzz(30) == "FizzBuzz"
Schritt 8: GREEN
def fizzbuzz(n):
if n % 15 == 0:
return "FizzBuzz"
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)
Schritt 9: REFACTOR
def fizzbuzz(n):
ergebnis = ""
if n % 3 == 0:
ergebnis += "Fizz"
if n % 5 == 0:
ergebnis += "Buzz"
return ergebnis or str(n)
Alle Tests laufen weiter: BESTANDEN!
Praxis: Taschenrechner-Modul testen
Hier ist ein vollständiges Beispiel mit Modul und Tests:
Das Modul
# taschenrechner.py
class Taschenrechner:
"""Ein einfacher Taschenrechner mit Verlauf."""
def __init__(self):
self.verlauf = []
def _speichere(self, ausdruck, ergebnis):
self.verlauf.append({"ausdruck": ausdruck, "ergebnis": ergebnis})
def addiere(self, a, b):
ergebnis = a + b
self._speichere(f"{a} + {b}", ergebnis)
return ergebnis
def subtrahiere(self, a, b):
ergebnis = a - b
self._speichere(f"{a} - {b}", ergebnis)
return ergebnis
def multipliziere(self, a, b):
ergebnis = a * b
self._speichere(f"{a} * {b}", ergebnis)
return ergebnis
def dividiere(self, a, b):
if b == 0:
raise ValueError("Division durch Null ist nicht erlaubt!")
ergebnis = a / b
self._speichere(f"{a} / {b}", ergebnis)
return ergebnis
def potenz(self, basis, exponent):
if not isinstance(exponent, (int, float)):
raise TypeError("Exponent muss eine Zahl sein!")
ergebnis = basis ** exponent
self._speichere(f"{basis} ^ {exponent}", ergebnis)
return ergebnis
def verlauf_loeschen(self):
self.verlauf.clear()
def letztes_ergebnis(self):
if not self.verlauf:
return None
return self.verlauf[-1]["ergebnis"]
Die Tests
# test_taschenrechner.py
import pytest
from taschenrechner import Taschenrechner
# --- Fixture ---
@pytest.fixture
def rechner():
"""Frischer Taschenrechner für jeden Test."""
return Taschenrechner()
# --- Grundoperationen ---
class TestAddition:
def test_positive_zahlen(self, rechner):
assert rechner.addiere(2, 3) == 5
def test_negative_zahlen(self, rechner):
assert rechner.addiere(-1, -1) == -2
def test_mit_null(self, rechner):
assert rechner.addiere(5, 0) == 5
@pytest.mark.parametrize("a,b,erwartet", [
(1, 1, 2),
(100, 200, 300),
(-5, 5, 0),
(0.1, 0.2, pytest.approx(0.3)),
])
def test_verschiedene_werte(self, rechner, a, b, erwartet):
assert rechner.addiere(a, b) == erwartet
class TestSubtraktion:
def test_positives_ergebnis(self, rechner):
assert rechner.subtrahiere(10, 3) == 7
def test_negatives_ergebnis(self, rechner):
assert rechner.subtrahiere(3, 10) == -7
class TestMultiplikation:
def test_grundfall(self, rechner):
assert rechner.multipliziere(4, 5) == 20
def test_mit_null(self, rechner):
assert rechner.multipliziere(100, 0) == 0
def test_mit_negativer_zahl(self, rechner):
assert rechner.multipliziere(-3, 4) == -12
class TestDivision:
def test_ganzzahl_ergebnis(self, rechner):
assert rechner.dividiere(10, 2) == 5.0
def test_dezimal_ergebnis(self, rechner):
assert rechner.dividiere(7, 2) == 3.5
def test_durch_null(self, rechner):
with pytest.raises(ValueError, match="Division durch Null"):
rechner.dividiere(10, 0)
class TestPotenz:
def test_quadrat(self, rechner):
assert rechner.potenz(3, 2) == 9
def test_hoch_null(self, rechner):
assert rechner.potenz(5, 0) == 1
def test_negativer_exponent(self, rechner):
assert rechner.potenz(2, -1) == 0.5
def test_ungueltiger_exponent(self, rechner):
with pytest.raises(TypeError):
rechner.potenz(2, "zwei")
# --- Verlauf ---
class TestVerlauf:
def test_leerer_verlauf(self, rechner):
assert rechner.verlauf == []
def test_verlauf_nach_berechnung(self, rechner):
rechner.addiere(2, 3)
assert len(rechner.verlauf) == 1
assert rechner.verlauf[0]["ergebnis"] == 5
def test_mehrere_berechnungen(self, rechner):
rechner.addiere(1, 1)
rechner.multipliziere(3, 3)
rechner.subtrahiere(10, 5)
assert len(rechner.verlauf) == 3
def test_letztes_ergebnis(self, rechner):
rechner.addiere(2, 3)
rechner.multipliziere(4, 5)
assert rechner.letztes_ergebnis() == 20
def test_letztes_ergebnis_leer(self, rechner):
assert rechner.letztes_ergebnis() is None
def test_verlauf_loeschen(self, rechner):
rechner.addiere(1, 1)
rechner.multipliziere(2, 2)
rechner.verlauf_loeschen()
assert rechner.verlauf == []
assert rechner.letztes_ergebnis() is None
Tests ausführen
# Alle Tests ausführen
pytest test_taschenrechner.py -v
# Nur Division-Tests
pytest test_taschenrechner.py::TestDivision -v
# Nur den test_durch_null Test
pytest test_taschenrechner.py::TestDivision::test_durch_null -v
# Mit Coverage
pytest test_taschenrechner.py --cov=taschenrechner -v
Übungen
Übung 1: Passwort-Validator testen
Schreibe Tests für diesen Passwort-Validator:
# passwort.py
def passwort_pruefen(passwort):
"""
Prüft ein Passwort nach folgenden Regeln:
- Mindestens 8 Zeichen
- Mindestens ein Großbuchstabe
- Mindestens ein Kleinbuchstabe
- Mindestens eine Zahl
Gibt True zurück wenn gültig, sonst ValueError mit Beschreibung.
"""
if len(passwort) < 8:
raise ValueError("Passwort muss mindestens 8 Zeichen haben")
if not any(c.isupper() for c in passwort):
raise ValueError("Passwort braucht mindestens einen Großbuchstaben")
if not any(c.islower() for c in passwort):
raise ValueError("Passwort braucht mindestens einen Kleinbuchstaben")
if not any(c.isdigit() for c in passwort):
raise ValueError("Passwort braucht mindestens eine Zahl")
return True
# test_passwort.py
"""
Aufgabe: Schreibe Tests für passwort_pruefen()
- Teste gültige Passwörter
- Teste zu kurze Passwörter
- Teste fehlende Großbuchstaben
- Teste fehlende Kleinbuchstaben
- Teste fehlende Zahlen
- Verwende parametrisierte Tests
- Prüfe die Fehlermeldungen
"""
# Dein Code hier!
Übung 2: Einkaufsliste testen
Schreibe eine Einkaufsliste-Klasse mit TDD (erst die Tests!):
"""
Aufgabe: Schreibe erst die Tests, dann die Klasse!
Die Klasse Einkaufsliste soll folgendes können:
- artikel_hinzufuegen(name, menge, preis)
- artikel_entfernen(name)
- gesamtpreis() -> float
- anzahl_artikel() -> int
- ist_leer() -> bool
- artikel_vorhanden(name) -> bool
Spezielle Regeln:
- Artikel mit Menge <= 0 sollen einen ValueError auslösen
- Artikel mit negativem Preis sollen einen ValueError auslösen
- Entfernen eines nicht vorhandenen Artikels soll KeyError auslösen
"""
# Dein Code hier!
Übung 3: Datei-Verarbeitung testen
Teste eine Funktion, die CSV-Daten verarbeitet (nutze tmp_path):
"""
Aufgabe:
1. Schreibe eine Funktion csv_lesen(dateipfad), die eine CSV-Datei
liest und eine Liste von Dictionaries zurückgibt
2. Schreibe Tests mit tmp_path Fixture:
- Teste das Lesen einer gültigen CSV
- Teste leere Dateien
- Teste nicht existierende Dateien (FileNotFoundError)
- Teste ungültige CSV-Daten
"""
# Dein Code hier!
Pro-Tipp: Nutze die “Watch”-Funktion von pytest, um Tests automatisch bei Dateiänderungen auszuführen. Installiere dazu pytest-watch:
pip install pytest-watch
# Tests laufen automatisch bei jeder Speicherung
ptw
So bekommst du sofortiges Feedback, ob dein Code noch funktioniert — besonders nützlich beim TDD-Workflow. Kombiniere das mit Coverage-Reports, um immer den Überblick zu behalten:
ptw -- --cov=src --cov-report=term-missing