Skip to main content
Back to course
Pytest для джунов: Моки и интеграция
5 / 863%

Coverage: контроль качества тестов

25 минут

У вас 50 тестов. Класс PaymentProcessor имеет 10 методов. Сколько методов покрыто тестами? 3? 7? Все 10? Как узнать?

Цель: Научиться измерять покрытие кода тестами с помощью coverage.

Вы точно готовы?

Убедитесь, что умеете:

# Писать тесты с моками
from unittest.mock import patch
 
@patch("module.requests")
def test_api(mock_requests):
    mock_requests.get.return_value.json.return_value = {"data": 123}

Если мокирование непонятно — вернитесь к предыдущим урокам (0-3).

Проблема: непокрытый код

Код с тестами

# src/calculator.py
 
class Calculator:
    def add(self, a, b):
        """Сложение"""
 
        return a + b
 
    def subtract(self, a, b):
        """Вычитание"""
 
        return a - b
 
    def divide(self, a, b):
        """Деление"""
 
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b
 
    def power(self, a, b):
        """Возведение в степень"""
 
        if b < 0:
            raise ValueError("Negative exponent not supported")
        return a ** b
 
# tests/test_calculator.py
 
def test_add():
    calc = Calculator()
    assert calc.add(2, 3) == 5
 
def test_subtract():
    calc = Calculator()
    assert calc.subtract(5, 3) == 2

Вопросы:

  • ❓ Метод divide() покрыт тестами?
  • ❓ Проверка if b == 0 протестирована?
  • ❓ Метод power() вообще используется?

Проблема: Неочевидно какой код НЕ покрыт тестами!

Решение: pytest-cov

Установка

pip install pytest-cov

Базовый запуск

# Запускаем тесты с измерением покрытия
pytest --cov=src tests/

Разбор команды:

  • pytest — запуск тестов
  • --cov=src — измерить покрытие для директории src/ (путь относительно корня проекта)
  • tests/ — запустить тесты из директории tests/

Результат:

========================= test session starts ==========================
collected 2 items
 
tests/test_calculator.py ..                                      [100%]
 
---------- coverage: platform darwin, python 3.11 -----------
Name                  Stmts   Miss  Cover
-----------------------------------------
src/calculator.py        12      8    33%
-----------------------------------------
TOTAL                    12      8    33%
 
========================== 2 passed in 0.12s ===========================

Что это значит:

  • Stmts — всего строк кода (statements): 12
  • Miss — пропущено (не выполнено): 8
  • Cover — покрытие: 33%

❌ Только 33% кода покрыто тестами!

Детальный отчёт: какие строки пропущены

pytest --cov=src --cov-report=term-missing tests/

Разбор аргументов:

  • --cov=src — измерить покрытие кода в директории src/
  • --cov-report=term-missing — показать отчёт в терминале (term) с номерами пропущенных строк (missing)
  • tests/ — где искать тесты

Результат:

Name                  Stmts   Miss  Cover   Missing
---------------------------------------------------
src/calculator.py        12      8    33%   10-11, 15-16, 20-24
---------------------------------------------------
TOTAL                    12      8    33%

✅ Теперь видно КАКИЕ строки не покрыты: 10-11, 15-16, 20-24

Смотрим код:

# src/calculator.py
 
class Calculator:
    def add(self, a, b):
        return a + b  # ✅ Покрыто
 
    def subtract(self, a, b):
        return a - b  # ✅ Покрыто
 
    def divide(self, a, b):         # ❌ Строка 10 — не покрыта
        if b == 0:                  # ❌ Строка 11 — не покрыта
            raise ValueError(...)
        return a / b
 
    def power(self, a, b):          # ❌ Строки 15-16 — не покрыты
        if b < 0:
            raise ValueError(...)
        return a ** b               # ❌ Строки 20-24 — не покрыты

✅ Понятно что нужно добавить тесты для divide() и power()!

Добавляем тесты

# tests/test_calculator.py
 
def test_divide():
    calc = Calculator()
    assert calc.divide(10, 2) == 5
 
def test_divide_by_zero():
    calc = Calculator()
    with pytest.raises(ValueError):
        calc.divide(10, 0)
 
def test_power():
    calc = Calculator()
    assert calc.power(2, 3) == 8
 
def test_power_negative():
    calc = Calculator()
    with pytest.raises(ValueError):
        calc.power(2, -1)

Запускаем снова:

pytest --cov=src --cov-report=term-missing tests/

Результат:

Name                  Stmts   Miss  Cover
-----------------------------------------
src/calculator.py        12      0   100%
-----------------------------------------
TOTAL                    12      0   100%

✅ 100% покрытие!

HTML отчёты: визуализация

Генерация HTML отчёта

pytest --cov=src --cov-report=html tests/

Разбор аргументов:

  • --cov=src — измерить покрытие для src/
  • --cov-report=html — сгенерировать HTML отчёт (вместо текстового в терминале)
  • tests/ — директория с тестами

Результат:

Coverage HTML written to dir htmlcov

Открываем в браузере:

open htmlcov/index.html  # macOS
# или
xdg-open htmlcov/index.html  # Linux
# или
start htmlcov/index.html  # Windows

Что показывает HTML отчёт

Главная страница:

FileStatementsMissingCoverage
calculator.py120100%
user.py20575%
TOTAL32584%

Детальный просмотр файла:

# Зелёным — покрытые строки
# Красным — непокрытые строки
 
class Calculator:
    def add(self, a, b):
        return a + b  # ✅ Зелёная строка
 
    def divide(self, a, b):
        if b == 0:  # ❌ Красная строка — не покрыто!
            raise ValueError("Cannot divide by zero")
        return a / b  # ✅ Зелёная

✅ Наглядно видно что именно не покрыто!

Чтение метрик: Lines vs Branches

Line coverage (по умолчанию)

def is_adult(age):
    if age >= 18:  # ✅ Строка выполнена
        return True
    else:           # ❌ Строка НЕ выполнена
        return False
 
# Тест
def test_adult():
    assert is_adult(25) == True

Line coverage: 75% (3 из 4 строк)

Проблема: else ветка не протестирована!

Branch coverage (строже)

pytest --cov=src --cov-branch tests/

Разбор аргументов:

  • --cov=src — измерить покрытие для src/
  • --cov-branch — включить branch coverage (покрытие веток условий)
  • tests/ — директория с тестами

Branch coverage проверяет ВСЕ ветки (if/else):

Name                Stmts   Miss Branch BrPart  Cover
-----------------------------------------------------
src/user.py             4      1      2      1    60%
-----------------------------------------------------
  • Branch — всего веток: 2 (if True, if False)
  • BrPart — частично покрыто: 1 (только if True)
  • Cover — 60% (не 75%!)

✅ Branch coverage строже и находит больше дыр!

Практический пример

# src/discount.py
 
def calculate_discount(price, user_type):
    """Расчёт скидки"""
    if user_type == "vip":
        discount = 0.2
    elif user_type == "regular":
        discount = 0.1
    else:
        discount = 0
 
    return price * (1 - discount)
 
# tests/test_discount.py
 
def test_vip_discount():
    assert calculate_discount(100, "vip") == 80

Line coverage: 6/8 = 75%

Branch coverage: 1/3 веток = 33%

Непокрыто:

  • elif user_type == "regular" — не проверено
  • else — не проверено

✅ Branch coverage показывает реальную проблему!

Настройка coverage

.coveragerc: конфигурация

# .coveragerc (в корне проекта)
 
[run]
source = src
omit =
    */tests/*
    */migrations/*
    */__init__.py
 
[report]
precision = 2
show_missing = True
skip_covered = False
 
[html]
directory = htmlcov

Теперь можно запускать просто:

pytest --cov

Исключение кода из coverage

# src/config.py
 
def get_database_url():
    """Получает URL БД"""
    if os.getenv("ENV") == "production":  # pragma: no cover
        return "postgresql://..."
    else:
        return "sqlite:///:memory:"

pragma: no cover — строка игнорируется в coverage!

Когда использовать:

  • Код только для production
  • Defensive programming (невозможные условия)
  • Debug код

Minimum coverage: fail если < 80%

pytest --cov=src --cov-fail-under=80 tests/

Разбор аргументов:

  • --cov=src — измерить покрытие для src/
  • --cov-fail-under=80 — завершить с ошибкой (exit code 1), если покрытие меньше 80%
  • tests/ — директория с тестами

Если coverage < 80%:

FAIL Required test coverage of 80% not reached. Total coverage: 75.00%

✅ Можно использовать в CI для контроля качества!

Практический пример: реальный проект

# Структура проекта
my-project/
├── src/
   ├── __init__.py
   ├── api/
   ├── client.py
   └── auth.py
   └── services/
       ├── user_service.py
       └── payment_service.py
├── tests/
   ├── test_api.py
   └── test_services.py
├── .coveragerc
└── pytest.ini

Запуск с отчётом:

# Полный отчёт
pytest --cov=src --cov-branch --cov-report=html --cov-report=term-missing

Разбор аргументов:

  • --cov=src — измерить покрытие для src/
  • --cov-branch — включить branch coverage (проверка всех веток if/else)
  • --cov-report=html — создать HTML отчёт в htmlcov/
  • --cov-report=term-missing — показать в терминале с номерами непокрытых строк

💡 Можно комбинировать несколько форматов отчётов!

Результат:

Name                            Stmts   Miss Branch BrPart  Cover   Missing
---------------------------------------------------------------------------
src/__init__.py                     0      0      0      0   100%
src/api/client.py                  45      5     12      2    88%   23-25, 67
src/api/auth.py                    32      0     10      0   100%
src/services/user_service.py       67     12     18      3    80%   45-50, 89-95
src/services/payment_service.py    89     23     22      5    71%   Multiple lines
---------------------------------------------------------------------------
TOTAL                             233     40     62     10    81%

Анализ:

  • api/auth.py — 100%, отлично!
  • ⚠️ api/client.py — 88%, почти идеально
  • ⚠️ user_service.py — 80%, приемлемо
  • payment_service.py — 71%, нужно добавить тесты!

Открываем HTML отчёт:

open htmlcov/index.html

Находим непокрытые строки в payment_service.py:

  • Строки 45-50: обработка ошибок Stripe (не протестирована)
  • Строки 89-95: рефанд платежей (не протестирован)

✅ Добавляем тесты для этих сценариев!

Coverage targets: сколько нужно?

Рекомендации по покрытию

CoverageОценкаРекомендация
< 50%❌ Очень низкоеКритично добавить тесты
50-70%⚠️ НизкоеДобавить тесты для критичного кода
70-85%✅ ХорошоДостаточно для большинства проектов
85-95%✅✅ ОтличноВысокое качество
> 95%⚠️ Слишком высокое?Может быть избыточно

Когда 100% НЕ нужно

# ❌ Бессмысленно тестировать
class Config:
    DATABASE_URL = "postgresql://..."
    API_KEY = "secret"
 
# ❌ Бессмысленно тестировать
def __repr__(self):
    return f"<User {self.name}>"
 
# ❌ Бессмысленно тестировать
if __name__ == "__main__":  # pragma: no cover
    main()

Рекомендация:

  • Критичный код (payment, auth): 90-100%
  • Бизнес-логика: 80-90%
  • UI/Views: 60-70%
  • Config/Constants: можно пропустить

Интеграция с IDE (bonus)

VS Code

Установите расширение: Coverage Gutters

# Генерируем XML отчёт
pytest --cov=src --cov-report=xml
 
# Расширение автоматически подсветит покрытие в редакторе

Разбор аргументов:

  • --cov=src — измерить покрытие для src/
  • --cov-report=xml — сгенерировать XML отчёт (для IDE и CI/CD)

Результат:

  • ✅ Зелёные линии слева — покрыто
  • ❌ Красные линии — не покрыто

PyCharm

Встроенная поддержка:

  1. Run → Run with Coverage
  2. PyCharm автоматически покажет покрытие

Что вы изучили

  • pytest-cov — измерение покрытия кода
  • --cov-report=term-missing — какие строки пропущены
  • --cov-report=html — визуальные отчёты
  • Line vs Branch coverage — разные метрики
  • --cov-fail-under — минимальный порог
  • .coveragerc — конфигурация
  • Coverage targets — сколько покрытия достаточно

Следующий урок

Отлично! Теперь вы умеете измерять покрытие тестами. Но как настроить pytest для всей команды? Как задать маркеры, testpaths, и другие опции?

Переходите к уроку 5: pytest.ini: конфигурация для команды

В следующем уроке вы узнаете:

  • pytest.ini для настройки проекта
  • Регистрация маркеров
  • testpaths для поиска тестов
  • addopts для опций по умолчанию
  • Настройка для CI/CD

Устранение неисправностей

Убедитесь что pytest-cov установлен:

pip install pytest-cov
pytest --version  # Должен показать pytest-cov в списке плагинов

Проверьте путь в --cov=src:

# ✅ Правильно (для src/ директории)
pytest --cov=src tests/

# ❌ Неправильно

pytest --cov=tests tests/ # Измеряет покрытие ТЕСТОВ, не кода!

Используйте .coveragerc:

[run]
omit =
_/tests/_
_/migrations/_
_/**pycache**/_
  1. Используйте pragma: no cover для невозможного кода
  2. Установите реалистичный порог: --cov-fail-under=80
  3. Помните: 80-90% обычно достаточно!

Удалите старый отчёт:

rm -rf htmlcov .coverage
pytest --cov=src --cov-report=html
Coverage: контроль качества тестов — Pytest для джунов: Моки и интеграция — Potapov.me