Python · Тестирование с pytest · Средний

Покрытие тестами

5 задач

Измеряйте, какие строки кода выполняются тестами, с помощью pytest-cov.

Покрытие тестами с pytest-cov

#
## Вопрос, на который отвечает покрытие У вас есть тест-сьют -- но какую часть реального кода он выполняет? Функция с тремя ветками может тестироваться только по основному сценарию, оставляя две ветки полностью нетронутыми. **Покрытие** измеряет, какие строки (и какие ветки) были реально выполнены при прогоне тестов. Покрытие -- диагностический инструмент: он показывает, где ваши тесты не смотрят. Он не говорит, *хорошие* ли ваши тесты. ## pytest-cov: покрытие интегрировано в pytest `pytest-cov` -- плагин, оборачивающий `coverage.py` и запускающий его вместе с тестами: ```bash pip install pytest-cov ``` Запустить тесты с измерением покрытия: ```bash pytest --cov=src tests/ ``` `--cov=src` указывает пакет или директорию для измерения. По завершении прогона автоматически выводится отчёт о покрытии. ## Чтение терминального отчёта ``` ---------- coverage: platform linux, python 3.11 ---------- Name Stmts Miss Cover ------------------------------------------ src/products.py 42 3 93% src/auth.py 28 8 71% src/utils.py 15 0 100% ------------------------------------------ TOTAL 85 11 87% ``` - **Stmts** -- всего исполняемых инструкций в файле - **Miss** -- инструкции, которые так и не были достигнуты за прогон - **Cover** -- процент инструкций Stmts, которые были выполнены Чтобы увидеть *какие именно* строки пропущены, добавьте `--cov-report=term-missing`: ```bash pytest --cov=src --cov-report=term-missing tests/ ``` Вывод включает колонку `Missing`: ``` src/auth.py 28 8 71% 45-52, 78 ``` Строки 45-52 и 78 никогда не выполнялись. Откройте файл -- возможно, там блок обработки ошибок, для которого нужен тест "что если база данных недоступна". ## HTML-отчёт: лучший способ просмотра ```bash pytest --cov=src --cov-report=html tests/ ``` Генерирует `htmlcov/index.html`. Откройте в браузере: выполненные строки зелёные, невыполненные красные. Кликните любой файл, чтобы увидеть точно, какие строки не покрыты в контексте. ## Покрытие строк vs покрытие ветвей **Покрытие строк** фиксирует, выполнилась ли строка вообще. Но одна инструкция `if` содержит два пути: ```python def discount(price, is_member): if is_member: # <- выполнилась return price * 0.9 # <- выполнилась (ветка True) return price # <- НИКОГДА не выполнялась (ветка False) ``` Если тесты вызывают только `discount(100, True)`, покрытие строк показывает 100% -- каждая строка выполнилась. Но путь `False` так и не был протестирован. **Покрытие ветвей** это улавливает. Включите его: ```bash pytest --cov=src --cov-branch tests/ ``` Теперь отчёт отслеживает каждую ветку каждого `if`, `for`, `while`, `try/except` и условного выражения. Ветка, которая никогда не выполнялась, отображается как пробел. ## Почему 100% покрытие строк -- не цель Можно достичь 100% покрытия строк тестами, которые ничего не проверяют. Можно иметь 75% покрытие с тестами, улавливающими каждый реальный баг. Число -- подсказка, а не цель. Правильное использование покрытия: - **Найти пробелы** -- какие нетривиальные пути кода не имеют тестов? Они важны? - **Установить минимальную планку** -- `--cov-fail-under=80` в CI предотвращает случайные регрессии (падение покрытия с 85% до 60% после рефакторинга -- тревожный сигнал) - **Выявить мёртвый код** -- строка, которая *всегда* не покрыта, может быть недостижима и безопасна для удаления Не гонитесь за числом. Гонитесь за осмысленными тестами. ## Исключение кода из покрытия Не всё стоит измерять. Файлы конфигурации, заглушки миграций, утилиты отладки -- исключите их: **Через `.coveragerc`:** ```ini [run] omit = tests/* setup.py */migrations/* [report] exclude_lines = if __name__ == .__main__.: raise NotImplementedError ``` **Инлайн через pragma:** ```python if __name__ == '__main__': # pragma: no cover main() ``` Комментарий pragma исключает эту строку из всех отчётов о покрытии. Используйте экономно -- только для кода, который действительно не имеет смысла тестировать.

Покрытие на практике

#
## Тестируемый код ```python # src/pricing.py def calculate_price(base_price, is_member, coupon_code=None): price = base_price if is_member: price *= 0.9 # скидка 10% для членов if coupon_code == 'SAVE20': price *= 0.8 # дополнительная скидка 20% elif coupon_code is not None: raise ValueError(f'Unknown coupon: {coupon_code}') return round(price, 2) ``` ## Частичный тест-сьют (намеренно неполный) ```python # tests/test_pricing.py from src.pricing import calculate_price def test_no_discount(): assert calculate_price(100, is_member=False) == 100.0 def test_member_discount(): assert calculate_price(100, is_member=True) == 90.0 ``` ## Запуск покрытия и чтение отчёта ```bash pytest --cov=src --cov-report=term-missing tests/ ``` ``` Name Stmts Miss Cover Missing ------------------------------------------------- src/pricing.py 9 2 78% 10, 12 ``` 78% -- ветки купонного кода так и не были протестированы. Строки 10 и 12 -- это блок `price *= 0.8` и блок `raise ValueError`. ## Добавление покрытия ветвей ```bash pytest --cov=src --cov-branch --cov-report=term-missing tests/ ``` ``` Name Stmts Miss Branch BrPart Cover src/pricing.py 9 2 6 2 64% ``` Ещё ниже -- непротестированные ветки внутри условий купона учитываются в покрытии отдельно. ## Закрытие пробелов ```python import pytest def test_coupon_save20(): assert calculate_price(100, is_member=False, coupon_code='SAVE20') == 80.0 def test_member_and_coupon(): assert calculate_price(100, is_member=True, coupon_code='SAVE20') == pytest.approx(72.0) # float-safe def test_invalid_coupon(): with pytest.raises(ValueError, match='Unknown coupon: INVALID'): calculate_price(100, is_member=False, coupon_code='INVALID') ``` После добавления этих тестов покрытие для этого файла достигает 100%. ## HTML-отчёт ```bash pytest --cov=src --cov-report=html tests/ # затем открыть htmlcov/index.html в браузере ``` Красные строки -- не покрыты; зелёные -- выполнены. Кликайте по файлам, чтобы увидеть точно, что было пропущено. ## .coveragerc: исключение файлов и строк ```ini # .coveragerc [run] omit = tests/* setup.py [report] exclude_lines = if __name__ == .__main__.: raise NotImplementedError \.\.\. [html] directory = htmlcov ``` ## Установка минимума в CI ```bash pytest --cov=src --cov-fail-under=80 tests/ ``` Завершается с кодом 2, если общее покрытие опускается ниже 80%. CI интерпретирует это как падение тестов. Это автоматически улавливает ситуацию "кто-то добавил код и забыл написать тесты". ## pragma: no cover ```python def _debug_dump(obj): # pragma: no cover '''Утилита разработчика -- не имеет смысла тестировать.''' import pprint pprint.pprint(vars(obj)) ``` Функция не будет отображаться в статистике покрытия. Используйте это редко -- только для кода, который действительно нельзя осмысленно юнит-тестировать (утилиты отладки, блоки `__main__`, абстрактные заглушки). ## Объединение флагов для полной CI-команды ```bash pytest --cov=src --cov-branch --cov-report=term-missing --cov-fail-under=75 tests/ ```

Краткий справочник по pytest-cov

#
**Установка:** ```bash pip install pytest-cov ``` **Флаги CLI:** ```bash pytest --cov=src # измерить пакет src/ pytest --cov=src --cov-branch # включить покрытие ветвей pytest --cov=src --cov-report=term-missing # показать номера пропущенных строк pytest --cov=src --cov-report=html # HTML-отчёт -> htmlcov/ pytest --cov=src --cov-fail-under=80 # упасть если покрытие < 80% ``` **Объединить:** ```bash pytest --cov=src --cov-branch --cov-report=term-missing --cov-fail-under=75 tests/ ``` **.coveragerc:** ```ini [run] omit = tests/* setup.py */migrations/* [report] exclude_lines = if __name__ == .__main__.: raise NotImplementedError [html] directory = htmlcov ``` **Инлайн-исключение:** ```python def unreachable(): # pragma: no cover ... ``` **Концепции покрытия:** | Термин | Значение | |------|---------| | Stmts | Всего исполняемых инструкций | | Miss | Инструкции, которые не выполнились | | Branch | Всего ветвей решений (if/else, try/except...) | | BrPart | Ветки покрытые частично | | Cover % | (Stmts - Miss) / Stmts | **Установить постоянные опции в pytest.ini:** ```ini [pytest] addopts = --cov=src --cov-report=term-missing ```
01

Запустить покрытие и прочитать отчёт

#

Создайте `src/calculator.py` с тремя функциями: `add(a, b)`, `divide(a, b)` (выбрасывает `ZeroDivisionError` при `b == 0`) и `absolute_value(x)`. Создайте `tests/test_calc.py` с единственным тестом, тестирующим только `add()`. Запустите `pytest --cov=src --cov-report=term-missing tests/` и прочитайте вывод. Определите, какие функции не покрыты и какие номера строк перечислены в колонке Missing.

# src/calculator.py

def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ZeroDivisionError('cannot divide by zero')
    return a / b

def absolute_value(x):
    if x < 0:
        return -x
    return x


# tests/test_calc.py
from src.calculator import add

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

# Запустить: pytest --cov=src --cov-report=term-missing tests/
# Каков процент покрытия?
# Какие строки появляются в колонке Missing?
Решение
# src/calculator.py

def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ZeroDivisionError('cannot divide by zero')
    return a / b

def absolute_value(x):
    if x < 0:
        return -x
    return x


# tests/test_calc.py
from src.calculator import add

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

# Ожидаемый отчёт (приблизительно -- точные номера строк зависят от пустых строк в файле):
# Name               Stmts   Miss  Cover   Missing
# src/calculator.py     <N>   <Miss>  ~40%   <строки в divide() и absolute_value()>
#
# Колонка Missing покажет строки тела divide() и absolute_value().
# Откройте файл и проверьте: перечисленные строки должны быть внутри этих двух функций.
# Покрытие: ~40% -- выполняется только add().
02

Сгенерировать HTML-отчёт и написать недостающий тест

#

Используя `src/calculator.py` из упражнения 1: запустите `pytest --cov=src --cov-report=html tests/` для генерации `htmlcov/index.html`. Откройте в браузере (или используйте `--cov-report=term-missing` для нахождения строк). Напишите тесты для `divide()` и `absolute_value()` -- включая путь ошибки и обе ветки инструкции `if`. Запустите покрытие снова и убедитесь, что достигнуто 100%.

# tests/test_calc.py  (расширить от упражнения 1)
from src.calculator import add, divide, absolute_value
import pytest


def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0


def test_divide():
    # покрыть как нормальный путь, так и путь с ZeroDivisionError
    pass


def test_absolute_value():
    # покрыть обе ветки: отрицательный вход и положительный вход
    pass

# Запустить: pytest --cov=src --cov-report=html tests/
# Открыть htmlcov/index.html -- красные строки не покрыты
Решение
# tests/test_calc.py
from src.calculator import add, divide, absolute_value
import pytest


def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0


def test_divide_normal():
    assert divide(10, 2) == 5.0


def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)


def test_absolute_value_negative():
    assert absolute_value(-5) == 5


def test_absolute_value_positive():
    assert absolute_value(3) == 3

# После добавления этих тестов:
# src/calculator.py    9    0   100%
# Все красные строки должны стать зелёными в HTML-отчёте.
03

Установить минимум покрытия через --cov-fail-under

#

Используя частичный тест-сьют из упражнения 1 (только `test_add`): запустите `pytest --cov=src --cov-fail-under=80 tests/` и наблюдайте код выхода -- он должен быть 2, а не 0. Обратите внимание на сообщение об ошибке в выводе. Затем добавьте полные тесты из упражнения 2 и запустите снова -- должен пройти с кодом выхода 0. Добавьте флаг в `pytest.ini` через `addopts`, чтобы сделать его постоянным.

# Запуск 1 -- только с test_add (ожидать код выхода 2, покрытие ~33%):
# pytest --cov=src --cov-fail-under=80 tests/

# Запуск 2 -- после добавления всех тестов (ожидать код выхода 0, покрытие 100%):
# pytest --cov=src --cov-fail-under=80 tests/

# Для постоянства добавить в pytest.ini:
# [pytest]
# addopts = --cov=src --cov-report=term-missing --cov-fail-under=80
Решение
# pytest.ini
# [pytest]
# addopts = --cov=src --cov-report=term-missing --cov-fail-under=80

# Вывод запуска 1 (частичные тесты):
# FAIL Required test coverage of 80% not reached. Total coverage: 33.33%
# (код выхода 2)

# Вывод запуска 2 (полный тест-сьют):
# Required test coverage of 80% reached. Total coverage: 100.00%
# (код выхода 0)

# addopts применяется к каждому запуску pytest автоматически -- локальная и CI
# среды используют одинаковые настройки без необходимости помнить флаги.
04

Создать .coveragerc для исключения файлов

#

Создайте файл `.coveragerc`, исключающий `tests/` и `setup.py` из измерения, и исключающий строки, совпадающие с `"if __name__ == .__main__.:"`. Создайте `setup.py` с несколькими строками и `src/main.py` с функцией `run()` и блоком `if __name__ == "__main__":`. Запустите покрытие и убедитесь, что эти файлы и строки не появляются в отчёте.

# .coveragerc  (создать в корне проекта)
[run]
omit =
    # добавить: tests/*
    # добавить: setup.py

[report]
exclude_lines =
    # добавить паттерн guard __main__


# setup.py
from setuptools import setup
setup(name='myproject', version='0.1')


# src/main.py
def run():
    print('running')

if __name__ == '__main__':
    run()

# Запустить: pytest --cov=src tests/
# setup.py и строка __main__ не должны появляться в отчёте
Решение
# .coveragerc
[run]
omit =
    tests/*
    setup.py

[report]
exclude_lines =
    if __name__ == .__main__.:
    raise NotImplementedError

[html]
directory = htmlcov


# setup.py
from setuptools import setup
setup(name='myproject', version='0.1')


# src/main.py
def run():
    print('running')

if __name__ == '__main__':
    run()

# Запустить: pytest --cov=src tests/
# setup.py не появится; строка __main__ исключена из статистики
05

Исключить функцию через pragma: no cover

#

Добавьте функцию `debug_print(a, b)` в `src/calculator.py`, которая выводит оба аргумента (два оператора print). Добавьте `# pragma: no cover` к строке определения функции. Запустите `pytest --cov=src --cov-report=term-missing tests/` с полным тест-сьютом из упражнения 2. Убедитесь, что строки `debug_print` не появляются в отчёте. Затем удалите pragma и запустите снова -- убедитесь, что строки появляются как пропущенные.

# src/calculator.py  (добавить к существующему файлу)

def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ZeroDivisionError('cannot divide by zero')
    return a / b

def absolute_value(x):
    if x < 0:
        return -x
    return x

def debug_print(a, b):  # добавьте pragma: no cover здесь
    print(f'debug: a={a}, b={b}')
    print(f'debug: sum={a + b}')

# Запустить: pytest --cov=src --cov-report=term-missing tests/
# С pragma: строки debug_print НЕ должны быть перечислены в Missing
# Без pragma: они появятся как непокрытые
Решение
# src/calculator.py

def add(a, b):
    return a + b

def divide(a, b):
    if b == 0:
        raise ZeroDivisionError('cannot divide by zero')
    return a / b

def absolute_value(x):
    if x < 0:
        return -x
    return x

def debug_print(a, b):  # pragma: no cover
    print(f'debug: a={a}, b={b}')
    print(f'debug: sum={a + b}')

# Запустить: pytest --cov=src --cov-report=term-missing tests/
# С pragma: покрытие 100%, debug_print не в статистике
# Без pragma: покрытие падает и строки 17-18 появляются в Missing