PythonpytestTestingBest Practices

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.