Testowanie jednostkowe w Pythonie: kompletny przewodnik z przykładami

Testowanie w Pythonie z użyciem pytest, TDD, mockowanie i pokrycie kodu

Page content

Testowanie jednostkowe zapewnia, że Twoja kod w Pythonie działa poprawnie i nadal działa, gdy projekt ewoluuje. Ten kompleksowy przewodnik pokrывает wszystko, co musisz wiedzieć na temat testowania jednostkowego w Pythonie, od podstawowych pojęć po zaawansowane techniki.

Python Unit Testing

Dlaczego testowanie jednostkowe ma znaczenie

Testowanie jednostkowe oferuje wiele korzyści dla programistów Pythona:

  • Wczesne wykrywanie błędów: Znajdź błędy przed ich dotarciem do produkcji
  • Jakość kodu: Wymusza napisanie modularycznego, testowalnego kodu
  • Pewność refaktoryzacji: Zmieniaj kod bezpiecznie, wiedząc, że testy wykryją regresje
  • Dokumentacja: Testy pełnią funkcję wykonywalnej dokumentacji, jak powinien działać kod
  • Szybszy rozwój: Automatyczne testy są szybsze niż testowanie ręczne
  • Lepszy projekt: Pisanie testowalnego kodu prowadzi do lepszej architektury

Zrozumienie podstaw testowania jednostkowego

Co to jest test jednostkowy?

Test jednostkowy weryfikuje najmniejszy testowalny fragment aplikacji (zwykle funkcję lub metodę) izolowanie. Powinien być:

  • Szybki: Uruchamia się w milisekundach
  • Izolowany: Niezależny od innych testów i zewnętrznych systemów
  • Powtarzalny: Zawsze daje takie same wyniki
  • Samoweryfikujący się: Jasno przekazuje wynik (przechodzi lub nie) bez konieczności inspekcji ręcznej
  • Wtórny: Napisany przed lub wraz z kodem

Piramida testów

Zdrowy zestaw testów opiera się na piramidzie testów:

           /\
          /  \     Testy E2E (Mało)
         /____\
        /      \   Testy integracyjne (Nieco)
       /_____ ___\
      /          \ Testy jednostkowe (Wiele)
     /_____ __ ___\

Testy jednostkowe tworzą fundament – są liczne, szybkie i dają szybki zwrot.

Porównanie frameworków testowych w Pythonie

unittest: Wbudowany framework

Standardowa biblioteka Pythona zawiera unittest, inspirowany przez JUnit:

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """Uruchamiany przed każdym testem"""
        self.calc = Calculator()
    
    def tearDown(self):
        """Uruchamiany po każdym teście"""
        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()

Zalety:

  • Wbudowany, nie wymaga instalacji
  • Dobry dla programistów znających frameworki xUnit
  • Przyjazny dla przedsiębiorstw, dobrze ustalony

Wady:

  • Złożona składnia z boilerplate
  • Wymaga klas do organizacji testów
  • Mniej elastyczne zarządzanie fixture

pytest: Nowoczesna opcja

pytest to najpopularniejszy framework testowy zewnętrzny:

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

Zalety:

  • Prosta, pythonowska składnia
  • Potężny system fixture
  • Świetna ekosystema wtyczek
  • Lepsze raportowanie błędów
  • Parametryzowane testy wbudowane

Wady:

  • Wymaga instalacji
  • Mniej znany dla programistów z innych języków

Instalacja:

pip install pytest pytest-cov pytest-mock

Pisanie pierwszych testów jednostkowych

Zbudujmy prosty przykład od podstaw za pomocą Test-Driven Development (TDD). Jeśli jesteś nowy w Pythonie lub potrzebujesz szybkiego odniesienia do składni i funkcji językowych, sprawdź nasz Python Cheatsheet dla kompleksowego przeglądu podstaw Pythona.

Przykład: Funkcje narzędziowe dla ciągów

Krok 1: Napisz test pierwszy (Czerwony)

Utwórz 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

Krok 2: Napisz minimalny kod, aby przekazać (Zielony)

Utwórz string_utils.py:

def reverse_string(s: str) -> str:
    """Odwróć ciąg"""
    return s[::-1]

def is_palindrome(s: str) -> bool:
    """Sprawdź, czy ciąg jest palindromem (ignorując wielkość liter i spacje)"""
    cleaned = ''.join(s.lower().split())
    return cleaned == cleaned[::-1]

def count_vowels(s: str) -> int:
    """Zlicz samogłoski w ciągu"""
    return sum(1 for char in s.lower() if char in 'aeiou')

Krok 3: Uruchom testy

pytest test_string_utils.py -v

Wynik:

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

Zaawansowane techniki testowania

Używanie fixture do konfiguracji testów

Fixtury oferują ponownie wykorzystywalną konfigurację i oczyszczenie testów:

import pytest
from database import Database

@pytest.fixture
def db():
    """Utwórz testową bazę danych"""
    database = Database(":memory:")
    database.create_tables()
    yield database  # Udostępnij testowi
    database.close()  # Oczyszczenie po teście

@pytest.fixture
def sample_users(db):
    """Dodaj próbkę użytkowników do bazy danych"""
    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

Zakresy fixture

Kontroluj czas życia fixture za pomocą zakresów:

@pytest.fixture(scope="function")  # Domyślnie: uruchamiany dla każdego testu
def func_fixture():
    return create_resource()

@pytest.fixture(scope="class")  # Raz na klasę testową
def class_fixture():
    return create_resource()

@pytest.fixture(scope="module")  # Raz na moduł
def module_fixture():
    return create_resource()

@pytest.fixture(scope="session")  # Raz na sesję testową
def session_fixture():
    return create_expensive_resource()

Symulowanie zależności zewnętrznych

Używaj symulacji, aby odizolować kod od zależności zewnętrznych:

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 z symulacją
def test_get_temperature():
    service = WeatherService()
    
    # Symuluj funkcję requests.get
    with patch('requests.get') as mock_get:
        # Skonfiguruj symulowany odpowiedź
        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
        
        # Sprawdź wywołanie
        mock_get.assert_called_once_with("https://api.weather.com/Boston")

Używanie wtyczki pytest-mock

pytest-mock oferuje czystszy składnia:

def test_get_temperature(mocker):
    service = WeatherService()
    
    # Symuluj za pomocą 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

Testowanie parametryzowane

Efektywnie testuj wiele scenariuszy:

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

Testowanie wyjątków i obsługi błędów

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Nie można dzielić przez zero")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Nie można dzielić przez zero"):
        divide(10, 0)

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

# Testowanie, że nie jest wyrzucany żaden wyjątek
def test_divide_success():
    result = divide(10, 2)
    assert result == 5.0

Testowanie kodu asynchronicznego

Testowanie kodu asynchronicznego jest istotne dla nowoczesnych aplikacji Pythona, szczególnie przy pracy z API, bazami danych lub usługami AI. Oto jak testować funkcje asynchroniczne:

import pytest
import asyncio

async def fetch_data(url):
    """Asynchroniczna funkcja do pobierania danych"""
    await asyncio.sleep(0.1)  # Symulacja wywołania API
    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):
    # Symuluj asynchroniczną funkcję
    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"

Dla praktycznych przykładów testowania kodu asynchronicznego z usługami AI, zobacz nasz przewodnik po integracji Ollama z Pythonem, który zawiera strategie testowania interakcji z LLM.

Pokrycie kodu

Zmierz, ile Twojego kodu jest przetestowane:

Używanie pytest-cov

# Uruchom testy z pokryciem
pytest --cov=myproject tests/

# Wygeneruj raport HTML
pytest --cov=myproject --cov-report=html tests/

# Pokaż brakujące linie
pytest --cov=myproject --cov-report=term-missing tests/

Konfiguracja pokrycia

Utwórz .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

Najlepsze praktyki pokrycia

  1. Dąż do 80%+ pokrycia dla krytycznych ścieżek kodu
  2. Nie przesadzaj z 100% – skup się na znaczących testach
  3. Testuj przypadki graniczne nie tylko przypadki sukcesu
  4. Wyklucz boilerplate z raportów pokrycia
  5. Używaj pokrycia jako przewodnika, a nie celu

Organizacja testów i struktura projektu

Zalecana struktura

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

conftest.py dla udostępnionych fixture

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

@pytest.fixture(scope="session")
def test_db():
    """Testowa baza danych na poziomie sesji"""
    db = Database(":memory:")
    db.create_schema()
    yield db
    db.close()

@pytest.fixture
def clean_db(test_db):
    """Czysta baza danych dla każdego testu"""
    test_db.clear_all_tables()
    return test_db

Konfiguracja pytest.ini

[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: oznacza testy jako wolne
    integration: oznacza testy jako testy integracyjne
    unit: oznacza testy jako testy jednostkowe

Najlepsze praktyki testowania jednostkowego

1. Obserwuj wzorzec AAA

Ustaw, Wykonaj, Sprawdź czyni testy jasnymi:

def test_user_creation():
    # Ustaw
    username = "john_doe"
    email = "john@example.com"
    
    # Wykonaj
    user = User(username, email)
    
    # Sprawdź
    assert user.username == username
    assert user.email == email
    assert user.is_active == True

2. Jeden asercja na test (Zasada, nie reguła)

# Dobre: Skupiony 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"

# Także akceptowalne: Powiązane asercje
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. Używaj opisowych nazw testów

# Zły
def test_user():
    pass

# Dobry
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. Testuj przypadki graniczne i granice

def test_age_validation():
    # Poprawne przypadki
    assert validate_age(0) == True
    assert validate_age(18) == True
    assert validate_age(120) == True
    
    # Przypadki graniczne
    assert validate_age(-1) == False
    assert validate_age(121) == False
    
    # Przypadki graniczne
    with pytest.raises(TypeError):
        validate_age("18")
    with pytest.raises(TypeError):
        validate_age(None)

5. Zachowuj niezależność testów

# Zły: Testy zależą od kolejności
counter = 0

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

def test_increment_again():  # Nie powiedzie się, jeśli uruchomione oddzielnie
    global counter
    counter += 1
    assert counter == 2

# Dobry: Testy są niezależne
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. Nie testuj szczegółów implementacji

# Zły: Testowanie implementacji
def test_sort_uses_quicksort():
    sorter = Sorter()
    assert sorter.algorithm == "quicksort"

# Dobry: Testowanie zachowania
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

7. Testuj rzeczywiste przypadki użycia

Gdy testujesz biblioteki przetwarzające lub przekształcające dane, skup się na rzeczywistych scenariuszach. Na przykład, jeśli pracujesz z web scraping lub konwersją treści, sprawdź nasz przewodnik po konwersji HTML na Markdown w Pythonie, który zawiera strategie testowania i porównania benchmarkowe dla różnych bibliotek konwersji.

Integracja z Continuous Integration

Przykład GitHub Actions

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

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: Ustaw Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Zainstaluj zależności
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt        
    
    - name: Uruchom testy
      run: |
        pytest --cov=myproject --cov-report=xml        
    
    - name: Prześlij pokrycie
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

Testowanie funkcji bezserwerowych

Gdy testujesz funkcje AWS Lambda lub aplikacje bezserwerowe, rozważ strategie testowania integracyjnego wraz z testami jednostkowymi. Nasz przewodnik po budowaniu dwu trybowego AWS Lambda z Pythonem i Terraformem pokrывает strategie testowania aplikacji Pythona bezserwerowych, w tym jak testować obsługę Lambda, konsumentów SQS i integracje z API Gateway.

Lista sprawdzania najlepszych praktyk testowania

  • Pisz testy przed lub wraz z kodem (TDD)
  • Zachowuj testy szybkie (< 1 sekunda na test)
  • Utwórz testy niezależne i izolowane
  • Używaj opisowych nazw testów
  • Obserwuj wzorzec AAA (Ustaw, Wykonaj, Sprawdź)
  • Testuj przypadki graniczne i warunki błędów
  • Symuluj zależności zewnętrzne
  • Dąż do 80%+ pokrycia kodu
  • Uruchamiaj testy w pipeline’ach CI/CD
  • Przeglądaj i refaktoryzuj testy regularnie
  • Dokumentuj złożone scenariusze testowe
  • Używaj fixture do wspólnych konfiguracji
  • Parametryzuj podobne testy
  • Zachowuj testy proste i czytelne

Powszechne wzorce testowania

Testowanie klas

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"

Testowanie z tymczasowymi plikami

import pytest
from pathlib import Path

@pytest.fixture
def temp_file(tmp_path):
    """Utwórz tymczasowy plik"""
    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"

Testowanie generowania plików

Gdy testujesz kod generujący pliki (np. PDFy, obrazy lub dokumenty), używaj tymczasowych katalogów i weryfikuj właściwości plików:

@pytest.fixture
def temp_output_dir(tmp_path):
    """Zaopatruj w tymczasowy katalog wyjściowy"""
    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

Dla kompleksowych przykładów testowania generowania PDF, zobacz nasz przewodnik po generowaniu PDF w Pythonie, który pokrывает strategie testowania dla różnych bibliotek PDF.

Testowanie z użyciem 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

Przydatne linki i zasoby

Powiązane zasoby

Podstawy Pythona i najlepsze praktyki

  • Python Cheatsheet - Kompleksowy odniesienie do składni Pythona, struktur danych i wspólnych wzorców

Testowanie konkretnych przypadków użycia Pythona

Testowanie bezserwerowe i chmurowe


Testowanie jednostkowe to istotna umiejętność dla programistów Pythona. Niezależnie od tego, czy wybierzesz unittest czy pytest, kluczem jest pisanie testów zgodnie, zachowuj je utrzymywalne i integruj je z Twoim przepływem pracy. Zacznij od prostych testów, stopniowo przyjmuj zaawansowane techniki takie jak symulacje i fixture, a używaj narzędzi do pokrycia, aby zidentyfikować nieprzetestowany kod.