Testowanie jednostkowe w Pythonie: kompletny przewodnik z przykładami
Testowanie w Pythonie z użyciem pytest, TDD, mockowanie i pokrycie kodu
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.

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
- Dąż do 80%+ pokrycia dla krytycznych ścieżek kodu
- Nie przesadzaj z 100% – skup się na znaczących testach
- Testuj przypadki graniczne nie tylko przypadki sukcesu
- Wyklucz boilerplate z raportów pokrycia
- 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
- Dokumentacja pytest
- Dokumentacja unittest
- Wtyczka pytest-cov
- Wtyczka pytest-mock
- Dokumentacja unittest.mock
- Test Driven Development by Example - Kent Beck
- Python Testing with pytest - Brian Okken
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
- Integracja Ollama z Pythonem - Strategie testowania integracji z LLM i asynchronicznych wywołań usług AI
- Konwersja HTML na Markdown w Pythonie - Strategie testowania dla bibliotek web scraping i konwersji treści
- Generowanie PDF w Pythonie - Strategie testowania dla generowania PDF i wyjścia plików
Testowanie bezserwerowe i chmurowe
- Budowanie dwu trybowego AWS Lambda z Pythonem i Terraformem - Testowanie funkcji Lambda, konsumentów SQS i integracji z API Gateway
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.