Unit Testing in Python: Vollständiger Leitfaden mit Beispielen

Python-Tests mit pytest, TDD, Mocking und Abdeckung

Inhaltsverzeichnis

Unit testing stellt sicher, dass Ihr Python-Code korrekt funktioniert und weiterhin funktioniert, wenn sich Ihr Projekt weiterentwickelt. Diese umfassende Anleitung behandelt alles, was Sie über Unit Testing in Python wissen müssen, von grundlegenden Konzepten bis hin zu fortgeschrittenen Techniken.

Python Unit Testing

Warum Unit Testing wichtig ist

Unit Testing bietet zahlreiche Vorteile für Python-Entwickler:

  • Frühe Fehlererkennung: Fangen Sie Fehler, bevor sie in die Produktion gelangen
  • Code-Qualität: Erzwingt modularen, testbaren Code
  • Refactoring-Konfidenz: Machen Sie Änderungen sicher, da Tests Regressionen erkennen
  • Dokumentation: Tests dienen als ausführbare Dokumentation, wie der Code funktionieren sollte
  • Schnellere Entwicklung: Automatisierte Tests sind schneller als manuelles Testen
  • Bessere Architektur: Testbarer Code führt zu besserer Architektur

Grundlagen des Unit Testings verstehen

Was ist ein Unit Test?

Ein Unit Test überprüft den kleinsten testbaren Teil einer Anwendung (meist eine Funktion oder Methode) in Isolation. Er sollte:

  • Schnell: In Millisekunden ausgeführt werden
  • Isoliert: Unabhängig von anderen Tests und externen Systemen
  • Wiederholbar: Gleiche Ergebnisse bei jeder Ausführung liefern
  • Selbstvalidierend: Klare Erfolgs- oder Fehlermeldung ohne manuelle Überprüfung
  • Zeitnah: Vor oder neben dem Code geschrieben werden

Die Test-Pyramide

Eine gesunde Test-Suite folgt der Test-Pyramide:

           /\
          /  \     E2E-Tests (Wenig)
         /____\
        /      \   Integrationstests (Einige)
       /________\
      /          \ Unit-Tests (Viele)
     /____________\

Unit-Tests bilden die Grundlage - sie sind zahlreich, schnell und liefern schnelles Feedback.

Vergleich von Python-Testing-Frameworks

unittest: Das eingebaute Framework

Die Python-Standardbibliothek enthält unittest, inspiriert von JUnit:

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """Wird vor jedem Test ausgeführt"""
        self.calc = Calculator()

    def tearDown(self):
        """Wird nach jedem Test ausgeführt"""
        self.calc = None

    def test_addition(self):
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)

    def test_division_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            self.calc.divide(10, 0)

if __name__ == '__main__':
    unittest.main()

Vorteile:

  • Eingebaut, keine Installation erforderlich
  • Gut für Entwickler, die mit xUnit-Frameworks vertraut sind
  • Unternehmensfreundlich, gut etabliert

Nachteile:

  • Umständliche Syntax mit Boilerplate-Code
  • Erfordert Klassen für die Testorganisation
  • Weniger flexible Fixture-Verwaltung

pytest: Die moderne Wahl

pytest ist das beliebteste Drittanbieter-Testing-Framework:

import pytest

def test_addition():
    calc = Calculator()
    assert calc.add(2, 3) == 5

def test_division_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_addition_parametrized(a, b, expected):
    calc = Calculator()
    assert calc.add(a, b) == expected

Vorteile:

  • Einfache, Python-idiomatische Syntax
  • Leistungsfähiges Fixture-System
  • Ausgezeichnetes Plugin-Ökosystem
  • Bessere Fehlerberichterstattung
  • Parametrisiertes Testen eingebaut

Nachteile:

  • Installation erforderlich
  • Weniger vertraut für Entwickler anderer Sprachen

Installation:

pip install pytest pytest-cov pytest-mock

Erstellen Ihrer ersten Unit Tests

Lassen Sie uns ein einfaches Beispiel von Grund auf mit Test-Driven Development (TDD) erstellen. Wenn Sie neu in Python sind oder eine schnelle Referenz für Syntax und Sprachmerkmale benötigen, werfen Sie einen Blick auf unseren Python Cheatsheet für einen umfassenden Überblick über Python-Grundlagen.

Beispiel: String-Utilitätsfunktionen

Schritt 1: Schreiben Sie den Test zuerst (Red)

Erstellen Sie test_string_utils.py:

import pytest
from string_utils import reverse_string, is_palindrome, count_vowels

def test_reverse_string():
    assert reverse_string("hello") == "olleh"
    assert reverse_string("") == ""
    assert reverse_string("a") == "a"

def test_is_palindrome():
    assert is_palindrome("racecar") == True
    assert is_palindrome("hello") == False
    assert is_palindrome("") == True
    assert is_palindrome("A man a plan a canal Panama") == True

def test_count_vowels():
    assert count_vowels("hello") == 2
    assert count_vowels("HELLO") == 2
    assert count_vowels("xyz") == 0
    assert count_vowels("") == 0

Schritt 2: Schreiben Sie minimalen Code zum Bestehen (Green)

Erstellen Sie string_utils.py:

def reverse_string(s: str) -> str:
    """Ein String wird rückwärts geschrieben"""
    return s[::-1]

def is_palindrome(s: str) -> bool:
    """Prüft, ob ein String ein Palindrom ist (Groß-/Kleinschreibung und Leerzeichen werden ignoriert)"""
    cleaned = ''.join(s.lower().split())
    return cleaned == cleaned[::-1]

def count_vowels(s: str) -> int:
    """Zählt die Vokale in einem String"""
    return sum(1 for char in s.lower() if char in 'aeiou')

Schritt 3: Führen Sie die Tests aus

pytest test_string_utils.py -v

Ausgabe:

test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED

Fortgeschrittene Testtechniken

Verwendung von Fixtures für Testaufbau

Fixtures bieten wiederverwendbare Testaufbau- und -abbauprozeduren:

import pytest
from database import Database

@pytest.fixture
def db():
    """Erstellt eine Testdatenbank"""
    database = Database(":memory:")
    database.create_tables()
    yield database  # Wird dem Test bereitgestellt
    database.close()  # Aufräumen nach dem Test

@pytest.fixture
def sample_users(db):
    """Fügt Beispielbenutzer zur Datenbank hinzu"""
    db.add_user("Alice", "alice@example.com")
    db.add_user("Bob", "bob@example.com")
    return db

def test_get_user(sample_users):
    user = sample_users.get_user_by_email("alice@example.com")
    assert user.name == "Alice"

def test_user_count(sample_users):
    assert sample_users.count_users() == 2

Fixture-Bereiche

Steuern Sie den Lebenszyklus von Fixtures mit Bereichen:

@pytest.fixture(scope="function")  # Standard: Wird für jeden Test ausgeführt
def func_fixture():
    return create_resource()

@pytest.fixture(scope="class")  # Einmal pro Testklasse
def class_fixture():
    return create_resource()

@pytest.fixture(scope="module")  # Einmal pro Modul
def module_fixture():
    return create_resource()

@pytest.fixture(scope="session")  # Einmal pro Test-Sitzung
def session_fixture():
    return create_expensive_resource()

Mocking externer Abhängigkeiten

Verwenden Sie Mocking, um Code von externen Abhängigkeiten zu isolieren:

from unittest.mock import Mock, patch, MagicMock
import requests

class WeatherService:
    def get_temperature(self, city):
        response = requests.get(f"https://api.weather.com/{city}")
        return response.json()["temp"]

# Test mit Mock
def test_get_temperature():
    service = WeatherService()

    # Mock die requests.get-Funktion
    with patch('requests.get') as mock_get:
        # Konfigurieren Sie die Mock-Antwort
        mock_response = Mock()
        mock_response.json.return_value = {"temp": 72}
        mock_get.return_value = mock_response

        # Test
        temp = service.get_temperature("Boston")
        assert temp == 72

        # Überprüfen Sie den Aufruf
        mock_get.assert_called_once_with("https://api.weather.com/Boston")

Verwendung des pytest-mock-Plugins

pytest-mock bietet eine sauberere Syntax:

def test_get_temperature(mocker):
    service = WeatherService()

    # Mock mit pytest-mock
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"temp": 72}
    mocker.patch('requests.get', return_value=mock_response)

    temp = service.get_temperature("Boston")
    assert temp == 72

Parametrisiertes Testen

Testen Sie mehrere Szenarien effizient:

import pytest

@pytest.mark.parametrize("input,expected", [
    ("", True),
    ("a", True),
    ("ab", False),
    ("aba", True),
    ("racecar", True),
    ("hello", False),
])
def test_is_palindrome_parametrized(input, expected):
    assert is_palindrome(input) == expected

@pytest.mark.parametrize("number,is_even", [
    (0, True),
    (1, False),
    (2, True),
    (-1, False),
    (-2, True),
])
def test_is_even(number, is_even):
    assert (number % 2 == 0) == is_even

Testen von Ausnahmen und Fehlerbehandlung

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Kann nicht durch null dividieren")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Kann nicht durch null dividieren"):
        divide(10, 0)

def test_divide_by_zero_with_message():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "null" in str(exc_info.value).lower()

# Testen, dass keine Ausnahme auftritt
def test_divide_success():
    result = divide(10, 2)
    assert result == 5.0

Testen von asynchronem Code

Das Testen asynchroner Code ist für moderne Python-Anwendungen essenziell, insbesondere bei der Arbeit mit APIs, Datenbanken oder KI-Diensten. Hier ist, wie Sie asynchrone Funktionen testen:

import pytest
import asyncio

async def fetch_data(url):
    """Asynchrone Funktion zum Abrufen von Daten"""
    await asyncio.sleep(0.1)  # Simuliert API-Aufruf
    return {"status": "success"}

@pytest.mark.asyncio
async def test_fetch_data():
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "success"

@pytest.mark.asyncio
async def test_fetch_data_with_mock(mocker):
    # Mock die asynchrone Funktion
    mock_fetch = mocker.AsyncMock(return_value={"status": "gemockt"})
    mocker.patch('module.fetch_data', mock_fetch)

    result = await fetch_data("https://api.example.com")
    assert result["status"] == "gemockt"

Für praktische Beispiele zum Testen asynchroner Code mit KI-Diensten sehen Sie unsere Anleitung zur Integration von Ollama mit Python, die Teststrategien für LLM-Interaktionen umfasst.

Code Coverage

Messen Sie, wie viel Ihres Codes getestet wird:

Mit pytest-cov

# Führen Sie Tests mit Abdeckung aus
pytest --cov=myproject tests/

# Generieren Sie einen HTML-Bericht
pytest --cov=myproject --cov-report=html tests/

# Zeigen Sie fehlende Zeilen an
pytest --cov=myproject --cov-report=term-missing tests/

Coverage-Konfiguration

Erstellen Sie .coveragerc:

[run]
source = myproject
omit =
    */tests/*
    */venv/*
    */__pycache__/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod

Coverage-Best Practices

  1. Streben Sie 80%+ Abdeckung für kritische Codepfade an
  2. Verfolgen Sie nicht 100% - konzentrieren Sie sich auf sinnvolle Tests
  3. Testen Sie Randfälle nicht nur glückliche Pfade
  4. Schließen Sie Boilerplate aus den Abdeckungsberichten aus
  5. Nutzen Sie die Abdeckung als Leitfaden nicht als Ziel

Testorganisation und Projektstruktur

Empfohlene Struktur

myproject/
├── myproject/
│   ├── __init__.py
│   ├── module1.py
│   ├── module2.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # Geteilte Fixtures
│   ├── test_module1.py
│   ├── test_module2.py
│   ├── test_utils.py
│   └── integration/
│       ├── __init__.py
│       └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt

conftest.py für Geteilte Fixtures

# tests/conftest.py
import pytest
from myproject.database import Database

@pytest.fixture(scope="session")
def test_db():
    """Sitzungsweite Testdatenbank"""
    db = Database(":memory:")
    db.create_schema()
    yield db
    db.close()

@pytest.fixture
def clean_db(test_db):
    """Saubere Datenbank für jeden Test"""
    test_db.clear_all_tables()
    return test_db

pytest.ini-Konfiguration

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
    -v
    --strict-markers
    --cov=myproject
    --cov-report=term-missing
    --cov-report=html
markers =
    slow: markiert Tests als langsam
    integration: markiert Tests als Integrationstests
    unit: markiert Tests als Unit-Tests

Best Practices für Unit-Tests

1. Folgen Sie dem AAA-Muster

Arrange-Act-Assert macht Tests klar:

def test_user_creation():
    # Arrange
    username = "john_doe"
    email = "john@example.com"

    # Act
    user = User(username, email)

    # Assert
    assert user.username == username
    assert user.email == email
    assert user.is_active == True

2. Eine Assertion pro Test (Richtlinie, nicht Regel)

# Gut: Fokussierter Test
def test_user_username():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"

def test_user_email():
    user = User("john_doe", "john@example.com")
    assert user.email == "john@example.com"

# Auch akzeptabel: Verwandte Assertions
def test_user_creation():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"
    assert user.email == "john@example.com"
    assert isinstance(user.created_at, datetime)

3. Verwenden Sie beschreibende Testnamen

# Schlecht
def test_user():
    pass

# Gut
def test_user_creation_with_valid_data():
    pass

def test_user_creation_fails_with_invalid_email():
    pass

def test_user_password_is_hashed_after_setting():
    pass

4. Testen Sie Randfälle und Grenzen

def test_age_validation():
    # Gültige Fälle
    assert validate_age(0) == True
    assert validate_age(18) == True
    assert validate_age(120) == True

    # Grenzfälle
    assert validate_age(-1) == False
    assert validate_age(121) == False

    # Randfälle
    with pytest.raises(TypeError):
        validate_age("18")
    with pytest.raises(TypeError):
        validate_age(None)

5. Halten Sie Tests unabhängig

# Schlecht: Tests hängen von der Reihenfolge ab
counter = 0

def test_increment():
    global counter
    counter += 1
    assert counter == 1

def test_increment_again():  # Fällt an, wenn allein ausgeführt
    global counter
    counter += 1
    assert counter == 2

# Gut: Tests sind unabhängig
def test_increment():
    counter = Counter()
    counter.increment()
    assert counter.value == 1

def test_increment_multiple_times():
    counter = Counter()
    counter.increment()
    counter.increment()
    assert counter.value == 2

6. Testen Sie keine Implementierungsdetails

# Schlecht: Implementierung testen
def test_sort_uses_quicksort():
    sorter = Sorter()
    assert sorter.algorithm == "quicksort"

# Gut: Verhalten testen
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

7. Testen Sie Echtwelt-Szenarien

Wenn Sie Bibliotheken testen, die Daten verarbeiten oder transformieren, konzentrieren Sie sich auf Echtwelt-Szenarien. Wenn Sie beispielsweise mit Web-Scraping oder Inhaltskonvertierung arbeiten, sehen Sie sich unsere Anleitung zur Konvertierung von HTML in Markdown mit Python an, die Teststrategien und Benchmark-Vergleiche für verschiedene Konvertierungsbibliotheken enthält.

Continuous Integration Integration

GitHub Actions-Beispiel

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.9, 3.10, 3.11, 3.12]

    steps:
    - uses: actions/checkout@v3

    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt        

    - name: Run tests
      run: |
        pytest --cov=myproject --cov-report=xml        

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

Testen von Serverless-Funktionen

Beim Testen von AWS Lambda-Funktionen oder serverlosen Anwendungen sollten Sie Integrationsteststrategien neben Unit-Tests berücksichtigen. Unsere Anleitung zum Aufbau einer dualmodalen AWS Lambda mit Python und Terraform behandelt Testansätze für serverlose Python-Anwendungen, einschließlich der Testung von Lambda-Handlern, SQS-Verbrauchern und API-Gateway-Integrationen.

Testing Best Practices Checklist

  • Schreiben Sie Tests vor oder neben dem Code (TDD)
  • Halten Sie Tests schnell (< 1 Sekunde pro Test)
  • Machen Sie Tests unabhängig und isoliert
  • Verwenden Sie beschreibende Testnamen
  • Folgen Sie dem AAA-Muster (Arrange-Act-Assert)
  • Testen Sie Randfälle und Fehlerbedingungen
  • Mocken Sie externe Abhängigkeiten
  • Streben Sie 80%+ Code-Abdeckung an
  • Führen Sie Tests im CI/CD-Pipeline aus
  • Überprüfen und refaktorieren Sie Tests regelmäßig
  • Dokumentieren Sie komplexe Test-Szenarien
  • Verwenden Sie Fixtures für gemeinsame Einstellungen
  • Parametrisieren Sie ähnliche Tests
  • Halten Sie Tests einfach und lesbar

Häufige Testmuster

Testen von Klassen

class TestUser:
    @pytest.fixture
    def user(self):
        return User("john_doe", "john@example.com")

    def test_username(self, user):
        assert user.username == "john_doe"

    def test_email(self, user):
        assert user.email == "john@example.com"

    def test_full_name(self, user):
        user.first_name = "John"
        user.last_name = "Doe"
        assert user.full_name() == "John Doe"

Testen mit temporären Dateien

import pytest
from pathlib import Path

@pytest.fixture
def temp_file(tmp_path):
    """Erstellen Sie eine temporäre Datei"""
    file_path = tmp_path / "test_file.txt"
    file_path.write_text("test content")
    return file_path

def test_read_file(temp_file):
    content = temp_file.read_text()
    assert content == "test content"

Testen der Dateierstellung

Beim Testen von Code, der Dateien generiert (wie PDFs, Bilder oder Dokumente), verwenden Sie temporäre Verzeichnisse und überprüfen Sie die Dateieigenschaften:

@pytest.fixture
def temp_output_dir(tmp_path):
    """Bietet ein temporäres Ausgabeverzeichnis"""
    output_dir = tmp_path / "output"
    output_dir.mkdir()
    return output_dir

def test_pdf_generation(temp_output_dir):
    pdf_path = temp_output_dir / "output.pdf"
    generate_pdf(pdf_path, content="Test")

    assert pdf_path.exists()
    assert pdf_path.stat().st_size > 0

Für umfassende Beispiele zum Testen der PDF-Erstellung sehen Sie sich unsere Anleitung zur Erstellung von PDFs in Python an, die Teststrategien für verschiedene PDF-Bibliotheken abdeckt.

Testen mit Monkeypatch

def test_environment_variable(monkeypatch):
    monkeypatch.setenv("API_KEY", "test_key_123")
    assert os.getenv("API_KEY") == "test_key_123"

def test_module_attribute(monkeypatch):
    monkeypatch.setattr("module.CONSTANT", 42)
    assert module.CONSTANT == 42

Verwandte Ressourcen

Python-Grundlagen und Best Practices

  • Python Cheatsheet - Umfassende Referenz für Python-Syntax, Datenstrukturen und häufige Muster

Testen spezifischer Python-Anwendungsfälle

Serverless- und Cloud-Testing


Unit-Testing ist eine essentielle Fähigkeit für Python-Entwickler. Ob Sie sich für unittest oder pytest entscheiden, der Schlüssel liegt darin, Tests konsistent zu schreiben, sie wartbar zu halten und sie in Ihren Entwicklungsworkflow zu integrieren. Beginnen Sie mit einfachen Tests, übernehmen Sie schrittweise fortgeschrittene Techniken wie Mocking und Fixtures und nutzen Sie Abdeckungstools, um ungetesteten Code zu identifizieren.