
Python Testing: Покрытие кода и метрики качества
Покрытие кода (code coverage) — это метрика, которая показывает, какая часть исходного кода выполняется во время тестов. Это важный инструмент для оценки качества тестирования, но важно понимать, что высокое покрытие не гарантирует отсутствие ошибок.
1. Введение в покрытие кода
Типы покрытия:
- Покрытие строк (line coverage) — показывает, какие строки кода были выполнены
- Покрытие веток (branch coverage) — показывает, какие ветки условных операторов были пройдены
- Покрытие функций (function coverage) — показывает, какие функции были вызваны
- Покрытие операторов (statement coverage) — показывает, какие операторы были выполнены
Важность покрытия кода:
- Обнаружение непокрытого кода — помогает найти код, который не тестируется
- Мотивация к тестированию — поощряет написание тестов для всех частей кода
- Качество кода — непокрытый код часто является "мёртвым" или устаревшим
- Рефакторинг — помогает безопасно изменять код, зная что он протестирован
Ограничения покрытия:
- Не гарантирует качество — код может быть покрыт тестами, но тесты могут быть плохими
- Не показывает сложность — простой код может иметь высокое покрытие, но быть сложным для понимания
- Не учитывает бизнес-логику — покрытие не показывает, правильно ли работает код с точки зрения требований
2. Coverage.py
coverage.py
— это стандартная библиотека для измерения покрытия кода в Python.
2.1. Установка
pip install coverage
2.2. Базовое использование
# run_tests_with_coverage.py
import coverage
import unittest
# Начинаем измерение покрытия
cov = coverage.Coverage()
cov.start()
# Запускаем тесты
loader = unittest.TestLoader()
suite = loader.discover('tests')
runner = unittest.TextTestRunner()
runner.run(suite)
# Останавливаем измерение и генерируем отчёт
cov.stop()
cov.save()
cov.report()
cov.html_report(directory='htmlcov')
Объяснение команд coverage:
cov.start()
— начинает отслеживание выполнения кодаcov.stop()
— останавливает отслеживаниеcov.save()
— сохраняет данные о покрытии в файлcov.report()
— выводит текстовый отчёт в консольcov.html_report()
— генерирует HTML отчёт для просмотра в браузере
Пример вывода отчёта:
Name Stmts Miss Cover
-------------------------------------------
myapp/calculator.py 15 2 87%
myapp/database.py 25 5 80%
myapp/utils.py 10 0 100%
-------------------------------------------
TOTAL 50 7 86%
HTML отчёт содержит:
- Общую статистику покрытия
- Детальную информацию по каждому файлу
- Подсветку покрытых и непокрытых строк
- Навигацию между файлами
- Фильтры для анализа покрытия
2.3. Конфигурация coverage.py
# .coveragerc
[run]
source = myapp
omit =
*/tests/*
*/venv/*
*/migrations/*
setup.py
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
class .*\bProtocol\):
@(abc\.)?abstractmethod
[html]
directory = htmlcov
title = Coverage Report
Объяснение секций конфигурации:
- [run] — настройки для запуска измерения покрытия
- [report] — настройки для генерации отчётов
- [html] — настройки для HTML отчётов
2.4. Программное управление покрытием
import coverage
# Создание объекта покрытия с настройками
cov = coverage.Coverage(
source=['myapp'],
omit=['*/tests/*', '*/venv/*'],
branch=True, # включить покрытие веток
data_file='.coverage'
)
# Начало измерения
cov.start()
# Ваш код здесь
import myapp
result = myapp.some_function()
# Остановка измерения
cov.stop()
# Сохранение данных
cov.save()
# Генерация отчётов
cov.report()
cov.html_report(directory='htmlcov')
cov.xml_report(outfile='coverage.xml')
3. pytest-cov
pytest-cov
— это плагин для pytest, который интегрирует coverage.py с pytest.
3.1. Установка
pip install pytest-cov
3.2. Базовое использование
# Запуск с измерением покрытия
pytest --cov=myapp tests/
# Запуск с HTML отчётом
pytest --cov=myapp --cov-report=html tests/
# Запуск с показом непокрытых строк
pytest --cov=myapp --cov-report=term-missing tests/
# Запуск с XML отчётом для CI/CD
pytest --cov=myapp --cov-report=xml tests/
# Запуск с проверкой минимального покрытия
pytest --cov=myapp --cov-fail-under=80 tests/
Объяснение флагов pytest-cov:
--cov=myapp
— указывает модуль для измерения покрытия--cov-report=html
— генерирует HTML отчёт--cov-report=term-missing
— показывает непокрытые строки в консоли--cov-report=xml
— генерирует XML отчёт для интеграции с CI/CD--cov-fail-under=80
— тесты падают, если покрытие меньше указанного процента
3.3. Конфигурация в pytest.ini
[tool:pytest]
addopts = --cov=myapp --cov-report=html --cov-report=term-missing
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
3.4. Интеграция с CI/CD
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest pytest-cov
- name: Run tests with coverage
run: |
pytest --cov=myapp --cov-report=xml --cov-fail-under=80
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
4. Интерпретация результатов покрытия
4.1. Понимание метрик
Процент покрытия:
- 0-50% — критически низкое покрытие, требует немедленного внимания
- 50-70% — низкое покрытие, нужно добавить тесты
- 70-85% — приемлемое покрытие для большинства проектов
- 85-95% — хорошее покрытие
- 95-100% — отличное покрытие, но может быть избыточным
Анализ непокрытого кода:
# Пример кода с низким покрытием
def process_user_data(user_data):
if user_data is None:
return None # Эта строка может быть непокрыта
if user_data.get('age') < 18:
raise ValueError("User too young") # Эта ветка может быть непокрыта
return user_data['name'].upper() # Эта строка может быть непокрыта
4.2. Типы непокрытого кода
1. Мёртвый код:
def old_function():
# Этот код больше не используется
return "old result"
def new_function():
return "new result"
2. Обработка ошибок:
def divide(a, b):
try:
return a / b
except ZeroDivisionError:
return None # Эта строка может быть непокрыта
3. Граничные случаи:
def validate_age(age):
if age < 0:
raise ValueError("Age cannot be negative") # Может быть непокрыто
elif age > 150:
raise ValueError("Age too high") # Может быть непокрыто
return True
4.3. Стратегии улучшения покрытия
1. Добавление тестов для граничных случаев:
def test_validate_age_boundaries():
"""Тест граничных случаев валидации возраста"""
# Тест отрицательного возраста
with pytest.raises(ValueError, match="Age cannot be negative"):
validate_age(-1)
# Тест слишком высокого возраста
with pytest.raises(ValueError, match="Age too high"):
validate_age(151)
# Тест валидных значений
assert validate_age(0) is True
assert validate_age(150) is True
2. Тестирование обработки ошибок:
def test_divide_by_zero():
"""Тест деления на ноль"""
result = divide(10, 0)
assert result is None
def test_api_error_handling():
"""Тест обработки ошибок API"""
with patch('requests.get') as mock_get:
mock_get.side_effect = requests.RequestException("Network error")
result = fetch_data_from_api("https://api.example.com/data")
assert result is None
5. Продвинутые техники покрытия
5.1. Покрытие веток
# Включение покрытия веток
pytest --cov=myapp --cov-branch tests/
Пример кода с ветками:
def process_data(data, threshold=10):
if data is None:
return None
elif len(data) == 0:
return []
elif len(data) > threshold:
return data[:threshold]
else:
return data
Тесты для покрытия всех веток:
def test_process_data_all_branches():
"""Тест всех веток функции process_data"""
# Ветка: data is None
assert process_data(None) is None
# Ветка: len(data) == 0
assert process_data([]) == []
# Ветка: len(data) > threshold
assert process_data([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) == [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Ветка: else
assert process_data([1, 2, 3]) == [1, 2, 3]
5.2. Исключение кода из покрытия
# Исключение отдельных строк
def some_function():
if debug_mode: # pragma: no cover
print("Debug info")
# Исключение блока кода
if __name__ == "__main__": # pragma: no cover
main()
# Исключение функций
def __repr__(self): # pragma: no cover
return f"<{self.__class__.__name__}>"
# Исключение классов
class ProtocolClass: # pragma: no cover
"""Протокольный класс, не требует тестирования"""
pass
5.3. Покрытие асинхронного кода
import pytest
import asyncio
@pytest.mark.asyncio
async def test_async_function():
"""Тест асинхронной функции"""
result = await async_function()
assert result == "expected"
# conftest.py
@pytest.fixture(scope="session")
def event_loop():
"""Создание event loop для асинхронных тестов"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
6. Метрики качества тестов
6.1. Помимо покрытия кода
1. Покрытие требований:
- Каждое требование должно быть покрыто тестами
- Тесты должны проверять как позитивные, так и негативные сценарии
2. Покрытие путей выполнения:
- Все возможные пути выполнения кода должны быть протестированы
- Особое внимание граничным случаям
3. Покрытие данных:
- Тесты должны использовать различные наборы данных
- Параметризованные тесты для покрытия разных сценариев
6.2. Качество тестов
1. Читаемость тестов:
# Хорошо - читаемый тест
def test_user_creation_with_valid_data():
"""Тест создания пользователя с валидными данными"""
user_data = {
"name": "John Doe",
"email": "john@example.com",
"age": 25
}
user = create_user(user_data)
assert user.name == "John Doe"
assert user.email == "john@example.com"
assert user.age == 25
# Плохо - нечитаемый тест
def test_user():
u = create_user({"n": "John", "e": "john@example.com", "a": 25})
assert u.name == "John"
2. Изоляция тестов:
# Хорошо - изолированный тест
def test_user_creation_isolated(mocker):
"""Изолированный тест создания пользователя"""
mock_db = mocker.patch('database.connection')
mock_db.return_value.insert.return_value = 1
user_id = create_user({"name": "John"})
assert user_id == 1
mock_db.assert_called_once()
# Плохо - зависимый тест
def test_user_creation_dependent():
"""Тест, зависящий от других тестов"""
# Зависит от предыдущих тестов
user = get_user_by_id(1) # может не существовать
assert user.name == "John"
3. Поддержка тестов:
# Хорошо - поддерживаемый тест
@pytest.fixture
def sample_user_data():
"""Фикстура с тестовыми данными пользователя"""
return {
"name": "Test User",
"email": "test@example.com",
"age": 30
}
def test_user_creation_with_fixture(sample_user_data):
"""Тест с использованием фикстуры"""
user = create_user(sample_user_data)
assert user.name == sample_user_data["name"]
# Плохо - хардкод в тесте
def test_user_creation_hardcoded():
"""Тест с хардкодом данных"""
user = create_user({"name": "John", "email": "john@example.com"})
assert user.name == "John"
6.3. Метрики производительности тестов
1. Время выполнения:
# Измерение времени выполнения тестов
pytest --durations=10 tests/ # показать 10 самых медленных тестов
# Параллельное выполнение
pytest -n auto tests/ # автоматическое определение количества процессов
2. Использование памяти:
import psutil
import os
def test_memory_usage():
"""Тест использования памяти"""
process = psutil.Process(os.getpid())
initial_memory = process.memory_info().rss
# Выполнение операции
result = memory_intensive_operation()
final_memory = process.memory_info().rss
memory_increase = final_memory - initial_memory
# Проверяем, что увеличение памяти не превышает лимит
assert memory_increase < 10 * 1024 * 1024 # 10 MB
7. Интеграция с инструментами
7.1. Codecov
Codecov — популярный сервис для отслеживания покрытия кода.
Настройка в .github/workflows/tests.yml:
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
fail_ci_if_error: true
Конфигурация в .codecov.yml:
coverage:
status:
project:
default:
target: 80%
threshold: 5%
patch:
default:
target: 80%
threshold: 5%
comment:
layout: "reach, diff, flags, files"
behavior: default
require_changes: false
7.2. SonarQube
SonarQube — платформа для анализа качества кода.
Настройка в .github/workflows/sonar.yml:
- name: SonarQube Scan
uses: sonarqube-quality-gate-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
scannerHomePath: /opt/sonar-scanner
args: >
-Dsonar.projectKey=my-project
-Dsonar.sources=src
-Dsonar.tests=tests
-Dsonar.python.coverage.reportPaths=coverage.xml
7.3. Локальные инструменты
pre-commit hooks:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: 23.3.0
hooks:
- id: black
- repo: local
hooks:
- id: pytest
name: pytest
entry: pytest
language: system
pass_filenames: false
always_run: true
args: [--cov=myapp, --cov-fail-under=80]
8. Лучшие практики
8.1. Целевые показатели покрытия
Рекомендуемые уровни покрытия:
- Критически важный код — 95%+
- Бизнес-логика — 85-95%
- Утилиты и хелперы — 80-90%
- Интеграционные тесты — 70-85%
Не гонитесь за 100% покрытием:
# Код, который не нужно тестировать
def __repr__(self): # pragma: no cover
return f"<{self.__class__.__name__}>"
if __name__ == "__main__": # pragma: no cover
main()
8.2. Стратегия улучшения покрытия
1. Приоритизация:
- Начните с критически важного кода
- Фокусируйтесь на бизнес-логике
- Не тратьте время на тестирование boilerplate кода
2. Постепенное улучшение:
- Устанавливайте реалистичные цели
- Улучшайте покрытие с каждым релизом
- Используйте покрытие как инструмент, а не как цель
3. Автоматизация:
- Интегрируйте проверку покрытия в CI/CD
- Устанавливайте минимальные пороги покрытия
- Автоматически генерируйте отчёты
8.3. Мониторинг и отчётность
Регулярные отчёты:
# Еженедельный отчёт о покрытии
pytest --cov=myapp --cov-report=html --cov-report=term-missing tests/
Тренды покрытия:
- Отслеживайте изменения покрытия во времени
- Анализируйте причины снижения покрытия
- Празднуйте улучшения
9. Заключение
Покрытие кода — это важный инструмент для оценки качества тестирования, но не единственный. Важно помнить:
Ключевые принципы:
- Покрытие кода — это метрика, а не цель
- Качество тестов важнее количества
- Фокусируйтесь на критически важном коде
- Используйте покрытие как инструмент для улучшения
Рекомендации:
- Устанавливайте реалистичные цели покрытия
- Интегрируйте проверку покрытия в процесс разработки
- Регулярно анализируйте отчёты о покрытии
- Не жертвуйте качеством ради количества
Следующие шаги:
- Настройте автоматическую проверку покрытия в CI/CD
- Интегрируйтесь с внешними сервисами (Codecov, SonarQube)
- Регулярно анализируйте и улучшайте качество тестов
- Используйте покрытие как часть общей стратегии качества
Дополнительные ресурсы: