pytest Patterns aus der Praxis
Was sich in produktiven Projekten bewährt hat
pytest ist seit Jahren mein bevorzugtes Test-Framework. In dieser Zeit haben sich einige Patterns als besonders wertvoll herausgestellt – nicht als Dogma, sondern als Werkzeuge, die man kennen sollte.
Fixtures richtig scopieren
# Schlecht: Für jeden Test neue DB-Connection
@pytest.fixture
def db_connection():
conn = create_connection()
yield conn
conn.close()
# Besser: Eine Connection pro Session
@pytest.fixture(scope="session")
def db_connection():
conn = create_connection()
yield conn
conn.close()
# Noch besser: Session-Connection, aber Transaction pro Test
@pytest.fixture(scope="session")
def db_connection():
conn = create_connection()
yield conn
conn.close()
@pytest.fixture
def db_transaction(db_connection):
trans = db_connection.begin()
yield db_connection
trans.rollback() # Automatisches Cleanup
Die richtige Scope-Wahl kann Test-Suites um Faktor 10 beschleunigen.
Parametrisierung statt Copy-Paste
# Schlecht: Drei fast identische Tests
def test_validate_email_valid():
assert validate_email("user@example.com") is True
def test_validate_email_invalid_no_at():
assert validate_email("userexample.com") is False
def test_validate_email_invalid_no_domain():
assert validate_email("user@") is False
# Besser: Ein parametrisierter Test
@pytest.mark.parametrize("email,expected", [
("user@example.com", True),
("userexample.com", False),
("user@", False),
("@example.com", False),
("user@example", False),
("user+tag@example.com", True),
])
def test_validate_email(email, expected):
assert validate_email(email) is expected
Bonus: pytest zeigt bei Fehlern genau, welche Kombination fehlgeschlagen ist.
Factories statt komplexe Fixtures
# Problematisch: Starre Test-Daten
@pytest.fixture
def user():
return User(name="Test User", email="test@example.com", role="admin")
# Besser: Factory mit Defaults
@pytest.fixture
def user_factory():
def _create_user(**kwargs):
defaults = {
"name": "Test User",
"email": "test@example.com",
"role": "user",
}
defaults.update(kwargs)
return User(**defaults)
return _create_user
# Verwendung
def test_admin_can_delete(user_factory):
admin = user_factory(role="admin")
regular = user_factory(role="user")
assert admin.can_delete(regular)
Factories geben Flexibilität, ohne jedes Mal alle Felder angeben zu müssen.
Marks für Test-Kategorien
# In conftest.py
def pytest_configure(config):
config.addinivalue_line("markers", "slow: marks tests as slow")
config.addinivalue_line("markers", "integration: marks integration tests")
config.addinivalue_line("markers", "database: marks tests needing db")
# In Tests
@pytest.mark.slow
@pytest.mark.integration
def test_full_import_process():
...
# Ausführung
# pytest -m "not slow" # Nur schnelle Tests
# pytest -m "integration" # Nur Integrationstests
# pytest -m "not database" # Tests ohne DB-Abhängigkeit
Strukturierte Assertions mit pytest-check
# Standard: Erster Fehler bricht ab
def test_user_creation():
user = create_user("test@example.com")
assert user.email == "test@example.com" # Bricht hier ab wenn falsch
assert user.is_active is True
assert user.role == "user"
# Mit pytest-check: Alle Fehler sammeln
from pytest_check import check
def test_user_creation():
user = create_user("test@example.com")
with check:
assert user.email == "test@example.com"
with check:
assert user.is_active is True
with check:
assert user.role == "user"
# Zeigt ALLE fehlgeschlagenen Assertions
Besonders wertvoll bei Tests mit vielen Prüfungen.
Testdaten extern halten
# tests/data/users.json
[
{"email": "valid@example.com", "valid": true},
{"email": "invalid", "valid": false}
]
# Im Test
import json
from pathlib import Path
TEST_DATA = Path(__file__).parent / "data"
def load_test_cases(filename):
return json.loads((TEST_DATA / filename).read_text())
@pytest.mark.parametrize("case", load_test_cases("users.json"))
def test_email_validation(case):
assert validate_email(case["email"]) == case["valid"]
Testdaten können so auch von Nicht-Entwicklern gepflegt werden.
Konfigurierbares Logging in Tests
# conftest.py
@pytest.fixture(autouse=True)
def configure_logging(caplog):
caplog.set_level(logging.DEBUG)
def test_process_logs_correctly(caplog):
process_data(invalid_data)
assert "Validation failed" in caplog.text
assert any(
record.levelname == "WARNING"
for record in caplog.records
)
Mocking mit Kontext
# Statt globalem Patch
@patch("mymodule.external_api")
def test_with_mock(mock_api):
mock_api.return_value = {"status": "ok"}
...
# Besser: Kontextmanager für Klarheit
def test_with_explicit_mock():
with patch("mymodule.external_api") as mock_api:
mock_api.return_value = {"status": "ok"}
result = my_function()
assert result == expected
mock_api.assert_called_once_with("expected_arg")
Custom Assertions
# conftest.py
def assert_valid_user(user):
"""Custom assertion mit klaren Fehlermeldungen."""
assert user is not None, "User is None"
assert user.email, f"User {user.id} has no email"
assert "@" in user.email, f"Invalid email: {user.email}"
assert user.created_at, f"User {user.id} has no created_at"
# Verwendung
def test_user_creation():
user = create_user("test@example.com")
assert_valid_user(user)
Parallele Ausführung richtig
# pytest.ini
[pytest]
addopts = -n auto # pytest-xdist
# Aber: Isolation sicherstellen!
@pytest.fixture
def isolated_db(db_connection):
"""Jeder Worker bekommt eigenes Schema."""
worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master")
schema = f"test_{worker_id}"
db_connection.execute(f"CREATE SCHEMA IF NOT EXISTS {schema}")
db_connection.execute(f"SET search_path TO {schema}")
yield db_connection
db_connection.execute(f"DROP SCHEMA {schema} CASCADE")
Wichtiger als die “perfekte” Test-Architektur sind Tests, die tatsächlich geschrieben und gepflegt werden.