Unit Testing in Python: Compleet gids met voorbeelden

Python-testen met pytest, TDD, mocking en coverage

Inhoud

Eenheidstesten zorgen ervoor dat je Python-code correct werkt en blijft werken terwijl je project evolueert. Deze uitgebreide gids behandelt alles wat je moet weten over eenheidstesten in Python, van basisconcepten tot geavanceerde technieken.

Python Eenheidstesten

Waarom Eenheidstesten Belangrijk zijn

Eenheidstesten bieden veel voordelen voor Python-ontwikkelaars:

  • Vroegtijdige foutdetectie: Fouten opvangen voordat ze in productie komen
  • Codekwaliteit: Dwingen je om modulaire, testbare code te schrijven
  • Vertrouwen bij herschrijven: Veranderingen maken met zekerheid, wetend dat tests regressies opvangen
  • Documentatie: Tests dienen als uitvoerbare documentatie van hoe code moet werken
  • Snellere ontwikkeling: Automatische tests zijn sneller dan handmatige testen
  • Beter ontwerp: Het schrijven van testbare code leidt tot een betere architectuur

Begrijpen van de Fundamenten van Eenheidstesten

Wat is een Eenheidstest?

Een eenheidstest verifieert de kleinste testbare onderdeel van een applicatie (meestal een functie of methode) in isolatie. Het moet zijn:

  • Snel: Uitgevoerd in milliseconden
  • Isolatie: Onafhankelijk van andere tests en externe systemen
  • Herhaalbaar: Hetzelfde resultaat opleveren elke keer
  • Zelfverificerend: Duidelijk passen of falen zonder handmatige inspectie
  • Tijdig: Geschreven voor of naast de code

De Testpiramide

Een gezonde testsuite volgt de testpiramide:

           /\
          /  \     E2E Tests (Weinig)
         /____\
        /      \   Integratie Tests (Een aantal)
       /_____ ___\
      /          \ Eenheidstests (Veel)
     /_____ __ ___\

Eenheidstests vormen de basis - ze zijn talrijk, snel en geven snelle feedback.

Vergelijking van Python Testframeworks

unittest: Het Ingebouwde Framework

De standaardbibliotheek van Python bevat unittest, geïnspireerd op JUnit:

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """Voordat elke test wordt uitgevoerd"""
        self.calc = Calculator()
    
    def tearDown(self):
        """Na elke test"""
        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()

Voordelen:

  • Ingebouwd, geen installatie vereist
  • Goed voor ontwikkelaars die vertrouwd zijn met xUnit-frameworks
  • Ondernemingsvriendelijk, goed gevestigd

Nadelen:

  • Verward syntax met boilerplate
  • Vereist klassen voor testorganisatie
  • Minder flexibele fixturebeheer

pytest: De Moderne Keuze

pytest is het populairste derde partij testframework:

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

Voordelen:

  • Eenvoudige, Pythonische syntax
  • Krachtig fixture-systeem
  • Uitstekende plugin-ecosysteem
  • Betere foutmeldingen
  • Parametriseerde testen ingebouwd

Nadelen:

  • Vereist installatie
  • Minder bekend bij ontwikkelaars van andere talen

Installatie:

pip install pytest pytest-cov pytest-mock

Schrijven van Je Eerste Eenheidstests

Laten we een eenvoudig voorbeeld vanaf nul bouwen met Test-Driven Development (TDD). Als je nieuw bent in Python of een snelle verwijzing nodig hebt voor syntaxis en taalkenmerken, bekijk dan onze Python Cheat Sheet voor een uitgebreid overzicht van Python-fundamenten.

Voorbeeld: String Utility Functions

Stap 1: Schrijf de Test Eerst (Rood)

Maak 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

Stap 2: Schrijf Minimale Code om Te Passen (Groen)

Maak string_utils.py:

def reverse_string(s: str) -> str:
    """Omkeer een string"""
    return s[::-1]

def is_palindrome(s: str) -> bool:
    """Controleer of string een palindroom is (genegeerd hoofdletters en spaties)"""
    cleaned = ''.join(s.lower().split())
    return cleaned == cleaned[::-1]

def count_vowels(s: str) -> int:
    """Tel de klinkers in een string"""
    return sum(1 for char in s.lower() if char in 'aeiou')

Stap 3: Test Uitvoeren

pytest test_string_utils.py -v

Uitvoer:

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

Geavanceerde Testtechnieken

Fixtures voor Testopstelling

Fixtures bieden herbruikbare testopstelling en -opruiming:

import pytest
from database import Database

@pytest.fixture
def db():
    """Maak een testdatabase aan"""
    database = Database(":memory:")
    database.create_tables()
    yield database  # Geef door aan de test
    database.close()  # Opruimen na de test

@pytest.fixture
def sample_users(db):
    """Voeg voorbeeldgebruikers toe aan de database"""
    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 Scopes

Controleer de levensduur van fixtures met scopes:

@pytest.fixture(scope="function")  # Standaard: uitvoeren voor elke test
def func_fixture():
    return create_resource()

@pytest.fixture(scope="class")  # Een keer per testklasse
def class_fixture():
    return create_resource()

@pytest.fixture(scope="module")  # Een keer per module
def module_fixture():
    return create_resource()

@pytest.fixture(scope="session")  # Een keer per testsessie
def session_fixture():
    return create_expensive_resource()

Mocken van Externe Afhankelijkheden

Gebruik mocken om code te isoleren van externe afhankelijkheden:

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 met mock
def test_get_temperature():
    service = WeatherService()
    
    # Mock de requests.get functie
    with patch('requests.get') as mock_get:
        # Configureer mock respons
        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
        
        # Controleer de oproep
        mock_get.assert_called_once_with("https://api.weather.com/Boston")

Gebruik van pytest-mock Plugin

pytest-mock biedt een nettere syntaxis:

def test_get_temperature(mocker):
    service = WeatherService()
    
    # Mock met 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

Parametriseerde Testen

Test meerdere scenario’s efficiënt:

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 van Uitzonderingen en Foutafhandeling

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Kan niet delen door nul")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Kan niet delen door nul"):
        divide(10, 0)

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

# Testen dat geen uitzondering wordt opgeworpen
def test_divide_success():
    result = divide(10, 2)
    assert result == 5.0

Testen van Asynchrone Code

Testen van asynchrone code is essentieel voor moderne Python-applicaties, vooral bij het werken met APIs, databases of AI-diensten. Hier is hoe je asynchrone functies kunt testen:

import pytest
import asyncio

async def fetch_data(url):
    """Asynchrone functie om data op te halen"""
    await asyncio.sleep(0.1)  # Simuleer API-aanroep
    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 de asynchrone functie
    mock_fetch = mocker.AsyncMock(return_value={"status": "mocked"})
    mocker.patch('module.fetch_data', mock_fetch)
    
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "mocked"

Voor praktische voorbeelden van het testen van asynchrone code met AI-diensten, zie onze gids over integreren van Ollama met Python, die teststrategieën voor LLM-interacties bevat.

Code Coverage

Meet hoeveel van je code getest is:

Gebruik van pytest-cov

# Testen uitvoeren met coverage
pytest --cov=myproject tests/

# HTML-rapport genereren
pytest --cov=myproject --cov-report=html tests/

# Ontbrekende regels tonen
pytest --cov=myproject --cov-report=term-missing tests/

Coverage Configuratie

Maak .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. Streef naar 80%+ coverage voor kritieke codepaden
  2. Zet niet te veel op 100% - richt je op betekenisvolle tests
  3. Test randgevallen niet alleen gelukkige paden
  4. Uitsluiten van boilerplate uit coverage-rapporten
  5. Gebruik coverage als een gids niet als doel

Testorganisatie en Projectstructuur

Aanbevolen Structuur

myproject/
├── myproject/
│   ├── __init__.py
│   ├── module1.py
│   ├── module2.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # Gedeelde 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 voor Gedeelde Fixtures

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

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

@pytest.fixture
def clean_db(test_db):
    """Schoon database voor elke test"""
    test_db.clear_all_tables()
    return test_db

pytest.ini Configuratie

[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: merkt tests als traag
    integration: merkt tests als integratiestests
    unit: merkt tests als eenheidstests

Beste Praktijken voor Eenheidstesten

1. Volg het AAA Patroon

Arrange-Act-Assert maakt tests duidelijk:

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. Één Assertie per Test (richtlijn, niet regel)

# Goed: Gerichte 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"

# Ook aanvaardbaar: Gerelateerde asserties
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. Gebruik Beschrijvende Testnamen

# Slecht
def test_user():
    pass

# Goed
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. Test Randgevallen en Grenzen

def test_age_validation():
    # Geldige gevallen
    assert validate_age(0) == True
    assert validate_age(18) == True
    assert validate_age(120) == True
    
    # Randgevallen
    assert validate_age(-1) == False
    assert validate_age(121) == False
    
    # Randgevallen
    with pytest.raises(TypeError):
        validate_age("18")
    with pytest.raises(TypeError):
        validate_age(None)

5. Houd Tests Onafhankelijk

# Slecht: Tests afhankelijk van volgorde
counter = 0

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

def test_increment_again():  # Fout als alleen uitgevoerd
    global counter
    counter += 1
    assert counter == 2

# Goed: Tests onafhankelijk
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. Test Geen Implementatie Details

# Slecht: Test implementatie
def test_sort_uses_quicksort():
    sorter = Sorter()
    assert sorter.algorithm == "quicksort"

# Goed: Test gedrag
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

7. Test Reële Gebruiksgevallen

Wanneer je testbibliotheken die gegevens verwerken of transformeren, richt je op reële scenario’s. Bijvoorbeeld, als je werkt met web scraping of inhoudsconversie, bekijk dan onze gids over converteer HTML naar Markdown met Python, die teststrategieën en benchmarkvergelijkingen voor verschillende conversiebibliotheken bevat.

Integratie met Continue Integratie

Voorbeeld van GitHub Actions

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

op: [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: Stel Python ${{ matrix.python-version }} in
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Installeer afhankelijkheden
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt        
    
    - name: Voer tests uit
      run: |
        pytest --cov=myproject --cov-report=xml        
    
    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

Testen van Serverless Functies

Wanneer je AWS Lambda-functies of serverless-applicaties test, overweeg dan integratieteststrategieën naast eenheidstests. Onze gids over bouwen van een dubbelmodus AWS Lambda met Python en Terraform behandelt teststrategieën voor serverless Python-applicaties, inclusief hoe je Lambda-handlers, SQS-consumers en API Gateway-integraties kunt testen.

Checklist van Beste Praktijken voor Testen

  • Schrijf tests voor of naast code (TDD)
  • Houd tests snel (< 1 seconde per test)
  • Maak tests onafhankelijk en geïsoleerd
  • Gebruik beschrijvende testnamen
  • Volg het AAA patroon (Arrange-Act-Assert)
  • Test randgevallen en foutomstandigheden
  • Mock externe afhankelijkheden
  • Streef naar 80%+ code coverage
  • Voer tests uit in de CI/CD-pijplijn
  • Beoordeel en herschrijf tests regelmatig
  • Document complexe testscenario’s
  • Gebruik fixtures voor algemene opstelling
  • Parametrize gelijke tests
  • Houd tests eenvoudig en leesbaar

Algemene Testpatronen

Testen van Classes

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 met Tijdelijke Bestanden

import pytest
from pathlib import Path

@pytest.fixture
def temp_file(tmp_path):
    """Maak een tijdelijk bestand aan"""
    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 van Bestandsopmaak

Wanneer je code test die bestanden genereert (zoals PDF’s, afbeeldingen of documenten), gebruik tijdelijke mappen en controleer bestandskenmerken:

@pytest.fixture
def temp_output_dir(tmp_path):
    """Bied een tijdelijke uitvoermap aan"""
    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

Voor uitgebreide voorbeelden van het testen van PDF-generatie, zie onze gids over genereren van PDF in Python, die teststrategieën voor verschillende PDF-bibliotheken behandelt.

Testen met 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

Gerelateerde Resources

Python Fundamenten en Beste Praktijken

  • Python Cheat Sheet - Uitgebreid overzicht van Python-syntaxis, datastructuren en veelvoorkomende patronen

Testen van Specifieke Python Gebruiksgevallen

Serverless en Cloud Testen


Eenheidstesten zijn een essentieel vaardigheid voor Python-ontwikkelaars. Of je unittest of pytest kiest, het belangrijkste is om tests consistent te schrijven, ze onderhoudbaar te houden en ze te integreren in je ontwikkelingswerkstroom. Begin met eenvoudige tests, adopteer geleidelijk geavanceerde technieken zoals mocken en fixtures, en gebruik coverage-tools om ongeteste code te identificeren.