Модульное тестирование в Python: Полное руководство с примерами
Тестирование на Python с использованием pytest, TDD, мокирования и покрытия кода
Модульное тестирование гарантирует, что ваш код на Python работает правильно и продолжает работать по мере развития проекта. Это всеобъемлющее руководство охватывает все, что вам нужно знать о модульном тестировании в Python, от базовых концепций до продвинутых техник.

Почему модульное тестирование важно
Модульное тестирование предоставляет множество преимуществ для разработчиков Python:
- Раннее обнаружение ошибок: Найдите ошибки до того, как они попадут в продакшен
- Качество кода: Заставляет вас писать модульный, тестируемый код
- Уверенность при рефакторинге: Вносите изменения, зная, что тесты обнаружат регрессии
- Документация: Тесты служат исполняемой документацией о том, как должен работать код
- Быстрое развитие: Автоматизированные тесты быстрее, чем ручное тестирование
- Лучший дизайн: Написание тестируемого кода ведет к лучшей архитектуре
Понимание основ модульного тестирования
Что такое модульный тест?
Модульный тест проверяет самую маленькую тестируемую часть приложения (обычно функцию или метод) в изоляции. Он должен быть:
- Быстрым: Запускаться за миллисекунды
- Изолированным: Независимым от других тестов и внешних систем
- Повторяемым: Давать одинаковые результаты каждый раз
- Самопроверяемым: Проходить или не проходить явно без ручного осмотра
- Своевременным: Написанным до или вместе с кодом
Пирамида тестирования
Здоровый набор тестов следует пирамиде тестирования:
/\
/ \ E2E Тесты (Немного)
/____\
/ \ Интеграционные тесты (Некоторые)
/________\
/ \ Модульные тесты (Много)
/____________\
Модульные тесты составляют основу - их много, они быстрые и дают быструю обратную связь.
Сравнение фреймворков тестирования Python
unittest: Встроенный фреймворк
Стандартная библиотека Python включает unittest, вдохновленную JUnit:
import unittest
class TestCalculator(unittest.TestCase):
def setUp(self):
"""Запускается перед каждым тестом"""
self.calc = Calculator()
def tearDown(self):
"""Запускается после каждого теста"""
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()
Преимущества:
- Встроен, не требует установки
- Хорош для разработчиков, знакомых с xUnit фреймворками
- Подходит для корпоративного использования, хорошо установлен
Недостатки:
- Сложная синтаксис с шаблонным кодом
- Требует классов для организации тестов
- Менее гибкое управление фикстурами
pytest: Современный выбор
pytest - самый популярный сторонний фреймворк тестирования:
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
Преимущества:
- Простая, питоническая синтаксис
- Мощная система фикстур
- Отличное сообщество плагинов
- Лучшая отчетность об ошибках
- Встроенное параметрическое тестирование
Недостатки:
- Требует установки
- Менее знаком разработчикам из других языков
Установка:
pip install pytest pytest-cov pytest-mock
Написание первых модульных тестов
Давайте создадим простой пример с нуля, используя Test-Driven Development (TDD). Если вы новичок в Python или вам нужна быстрая справка по синтаксису и языковым конструкциям, ознакомьтесь с нашей Шпаргалкой по Python для всеобъемлющего обзора основ Python.
Пример: Утилиты для строк
Шаг 1: Сначала напишите тест (Red)
Создайте 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
Шаг 2: Напишите минимальный код для прохождения (Green)
Создайте string_utils.py:
def reverse_string(s: str) -> str:
"""Развернуть строку"""
return s[::-1]
def is_palindrome(s: str) -> bool:
"""Проверить, является ли строка палиндромом (игнорируя регистр и пробелы)"""
cleaned = ''.join(s.lower().split())
return cleaned == cleaned[::-1]
def count_vowels(s: str) -> int:
"""Подсчитать гласные в строке"""
return sum(1 for char in s.lower() if char in 'aeiou')
Шаг 3: Запустите тесты
pytest test_string_utils.py -v
Вывод:
test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED
Продвинутые техники тестирования
Использование фикстур для настройки тестов
Фикстуры предоставляют повторно используемую настройку и очистку тестов:
import pytest
from database import Database
@pytest.fixture
def db():
"""Создать тестовую базу данных"""
database = Database(":memory:")
database.create_tables()
yield database # Предоставить тесту
database.close() # Очистка после теста
@pytest.fixture
def sample_users(db):
"""Добавить образцовых пользователей в базу данных"""
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
Области фикстур
Управляйте временем жизни фикстур с помощью областей:
@pytest.fixture(scope="function") # По умолчанию: запускается для каждого теста
def func_fixture():
return create_resource()
@pytest.fixture(scope="class") # Один раз на класс тестов
def class_fixture():
return create_resource()
@pytest.fixture(scope="module") # Один раз на модуль
def module_fixture():
return create_resource()
@pytest.fixture(scope="session") # Один раз на сессию тестов
def session_fixture():
return create_expensive_resource()
Мокирование внешних зависимостей
Используйте мокирование для изоляции кода от внешних зависимостей:
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"]
# Тест с моком
def test_get_temperature():
service = WeatherService()
# Мокировать функцию requests.get
with patch('requests.get') as mock_get:
# Настроить мок-ответ
mock_response = Mock()
mock_response.json.return_value = {"temp": 72}
mock_get.return_value = mock_response
# Тест
temp = service.get_temperature("Boston")
assert temp == 72
# Проверить вызов
mock_get.assert_called_once_with("https://api.weather.com/Boston")
Использование плагина pytest-mock
pytest-mock предоставляет более чистую синтаксис:
def test_get_temperature(mocker):
service = WeatherService()
# Мокирование с использованием 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
Параметрическое тестирование
Тестируйте несколько сценариев эффективно:
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
Тестирование исключений и обработки ошибок
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Нельзя делить на ноль")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError, match="Нельзя делить на ноль"):
divide(10, 0)
def test_divide_by_zero_with_message():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert "ноль" in str(exc_info.value).lower()
# Тестирование, что исключение не возникает
def test_divide_success():
result = divide(10, 2)
assert result == 5.0
Тестирование асинхронного кода
Тестирование асинхронного кода является важным для современных приложений Python, особенно при работе с API, базами данных или сервисами ИИ. Вот как тестировать асинхронные функции:
import pytest
import asyncio
async def fetch_data(url):
"""Асинхронная функция для получения данных"""
await asyncio.sleep(0.1) # Симуляция вызова 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):
# Мокировать асинхронную функцию
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"
Для практических примеров тестирования асинхронного кода с сервисами ИИ см. наше руководство по интеграции Ollama с Python, которое включает стратегии тестирования для взаимодействий с LLM.
Покрытие кода
Измерьте, сколько вашего кода протестировано:
Использование pytest-cov
# Запуск тестов с покрытием
pytest --cov=myproject tests/
# Генерация HTML отчета
pytest --cov=myproject --cov-report=html tests/
# Показ пропущенных строк
pytest --cov=myproject --cov-report=term-missing tests/
Конфигурация покрытия
Создайте .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
Лучшие практики покрытия
- Стремитесь к 80%+ покрытию для критических путей кода
- Не фанатейте над 100% - сосредоточьтесь на осмысленных тестах
- Тестируйте крайние случаи, а не только счастливые пути
- Исключайте шаблонный код из отчетов о покрытии
- Используйте покрытие как руководство, а не как цель
Организация тестов и структура проекта
Рекомендуемая структура
myproject/
├── myproject/
│ ├── __init__.py
│ ├── module1.py
│ ├── module2.py
│ └── utils.py
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Общие фикстуры
│ ├── test_module1.py
│ ├── test_module2.py
│ ├── test_utils.py
│ └── integration/
│ ├── __init__.py
│ └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt
conftest.py для общих фикстур
# tests/conftest.py
import pytest
from myproject.database import Database
@pytest.fixture(scope="session")
def test_db():
"""Сеансовая тестовая база данных"""
db = Database(":memory:")
db.create_schema()
yield db
db.close()
@pytest.fixture
def clean_db(test_db):
"""Очищенная база данных для каждого теста"""
test_db.clear_all_tables()
return test_db
Конфигурация 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: помечает тесты как медленные
integration: помечает тесты как интеграционные
unit: помечает тесты как модульные
Лучшие практики модульного тестирования
1. Следуйте шаблону AAA
Arrange-Act-Assert делает тесты понятными:
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. Одна проверка на тест (Рекомендация, а не правило)
# Хорошо: Сфокусированный тест
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"
# Также приемлемо: Связанные проверки
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. Используйте описательные имена тестов
# Плохо
def test_user():
pass
# Хорошо
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. Тестируйте крайние случаи и границы
def test_age_validation():
# Валидные случаи
assert validate_age(0) == True
assert validate_age(18) == True
assert validate_age(120) == True
# Граничные случаи
assert validate_age(-1) == False
assert validate_age(121) == False
# Крайние случаи
with pytest.raises(TypeError):
validate_age("18")
with pytest.raises(TypeError):
validate_age(None)
5. Держите тесты независимыми
# Плохо: Тесты зависят от порядка
counter = 0
def test_increment():
global counter
counter += 1
assert counter == 1
def test_increment_again(): # Не проходит, если запущен отдельно
global counter
counter += 1
assert counter == 2
# Хорошо: Тесты независимы
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. Не тестируйте детали реализации
# Плохо: Тестирование реализации
def test_sort_uses_quicksort():
sorter = Sorter()
assert sorter.algorithm == "quicksort"
# Хорошо: Тестирование поведения
def test_sort_returns_sorted_list():
sorter = Sorter()
result = sorter.sort([3, 1, 2])
assert result == [1, 2, 3]
7. Тестируйте реальные сценарии использования
При тестировании библиотек, которые обрабатывают или преобразуют данные, сосредоточьтесь на реальных сценариях. Например, если вы работаете с веб-скрапингом или преобразованием контента, ознакомьтесь с нашим руководством по преобразованию HTML в Markdown с помощью Python, которое включает стратегии тестирования и сравнительные бенчмарки для различных библиотек преобразования.
Интеграция с непрерывной интеграцией
Пример GitHub Actions
# .github/workflows/tests.yml
name: Тесты
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: Настройка Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Установка зависимостей
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Запуск тестов
run: |
pytest --cov=myproject --cov-report=xml
- name: Загрузка покрытия
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
Тестирование серверных функций
При тестировании AWS Lambda функций или серверных приложений рассмотрите стратегии интеграционного тестирования наряду с модульными тестами. Наше руководство по созданию двурежимной AWS Lambda с Python и Terraform охватывает подходы к тестированию для серверных Python приложений, включая как тестировать обработчики Lambda, потребителей SQS и интеграции с API Gateway.
Чек-лист лучших практик тестирования
- Пишите тесты до или вместе с кодом (TDD)
- Держите тесты быстрыми (< 1 секунда на тест)
- Делайте тесты независимыми и изолированными
- Используйте описательные имена тестов
- Следуйте шаблону AAA (Arrange-Act-Assert)
- Тестируйте крайние случаи и условия ошибок
- Мокируйте внешние зависимости
- Стремитесь к 80%+ покрытию кода
- Запускайте тесты в CI/CD пайплайне
- Регулярно проверяйте и рефакторите тесты
- Документируйте сложные сценарии тестирования
- Используйте фикстуры для общего набора
- Параметризуйте похожие тесты
- Держите тесты простыми и читаемыми
Общие шаблоны тестирования
Тестирование классов
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"
Тестирование с временными файлами
import pytest
from pathlib import Path
@pytest.fixture
def temp_file(tmp_path):
"""Создание временного файла"""
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"
Тестирование генерации файлов
При тестировании кода, который генерирует файлы (например, PDF, изображения или документы), используйте временные директории и проверяйте свойства файлов:
@pytest.fixture
def temp_output_dir(tmp_path):
"""Предоставление временной выходной директории"""
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
Для всесторонних примеров тестирования генерации PDF см. наше руководство по генерации PDF в Python, которое охватывает стратегии тестирования для различных библиотек PDF.
Тестирование с 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
Полезные ссылки и ресурсы
- Документация pytest
- Документация unittest
- Плагин pytest-cov
- Плагин pytest-mock
- Документация unittest.mock
- Test Driven Development by Example - Kent Beck
- Python Testing with pytest - Brian Okken
Связанные ресурсы
Основы Python и лучшие практики
- Шпаргалка по Python - Полное руководство по синтаксису Python, структурам данных и распространенным паттернам
Тестирование конкретных случаев использования Python
- Интеграция Ollama с Python - Стратегии тестирования интеграций LLM и асинхронных вызовов AI-сервисов
- Конвертация HTML в Markdown с помощью Python - Подходы к тестированию библиотек для веб-скрапинга и конвертации контента
- Генерация PDF в Python - Стратегии тестирования генерации PDF и вывода файлов
Тестирование серверных и облачных решений
- Создание двурежимной AWS Lambda с использованием Python и Terraform - Тестирование функций Lambda, потребителей SQS и интеграций API Gateway
Модульное тестирование - это важный навык для разработчиков на Python. Независимо от того, выбираете ли вы unittest или pytest, главное - писать тесты последовательно, поддерживать их в актуальном состоянии и интегрировать в рабочий процесс разработки. Начните с простых тестов, постепенно внедряйте продвинутые техники, такие как мокирование и фикстуры, и используйте инструменты покрытия кода для выявления не протестированных участков.