Python · Тестування з pytest · Середній

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

5 завдань

Вимірюйте, які рядки коду виконуються тестами, за допомогою pytest-cov.

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

#
## На яке питання відповідає coverage У вас є тест-сьют -- але яку частину реального коду він виконує? Функція з трьома гілками може тестуватися лише на щасливому шляху, залишаючи дві гілки абсолютно не перевіреними. **Coverage** вимірює, які рядки (і які гілки) фактично виконалися під час запуску тестів. Coverage -- це діагностичний інструмент: він показує, куди ваші тести не дивляться. Він не каже, чи є ваші тести *хорошими*. ## pytest-cov: coverage інтегрований у 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% покриття з тестами, що виявляють кожен реальний баг. Число -- це підказка, а не ціль. Правильний спосіб використання coverage: - **Знаходити прогалини** -- які нетривіальні шляхи коду не мають тестів? Чи вони важливі? - **Встановлювати мінімальну планку** -- `--cov-fail-under=80` у CI запобігає випадковим регресіям (падіння покриття з 85% до 60% після рефакторингу -- тривожний сигнал) - **Виявляти мертвий код** -- рядок, який *завжди* не покритий, може бути недосяжним і безпечним для видалення Не женіться за числом. Женіться за змістовними тестами. ## Виключення коду з coverage Не все варто вимірювати. Конфігураційні файли, заготовки міграцій, утиліти для налагодження -- виключіть їх: **Через `.coveragerc`:** ```ini [run] omit = tests/* setup.py */migrations/* [report] exclude_lines = if __name__ == .__main__.: raise NotImplementedError ``` **Через inline-pragma:** ```python if __name__ == '__main__': # pragma: no cover main() ``` Коментар pragma виключає цей рядок з усіх звітів coverage. Використовуйте рідко -- лише для коду, що справді не може бути змістовно протестований.

Coverage на практиці

#
## Код, що тестується ```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 ``` ## Запуск coverage і читання звіту ```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 def test_invalid_coupon(): with pytest.raises(ValueError, match='Unknown coupon: INVALID'): calculate_price(100, is_member=False, coupon_code='INVALID') ``` Після додавання цих тестів coverage досягає 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)) ``` Функція не з'явиться у статистиці coverage. Використовуйте рідко -- лише для коду, що справді не може бути юніт-протестований (утиліти налагодження, блоки `__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 ``` **Inline-виключення:** ```python def unreachable(): # pragma: no cover ... ``` **Концепції coverage:** | Термін | Значення | |--------|---------| | Stmts | Загальна кількість виконуваних інструкцій | | Miss | Інструкції, що ніколи не виконувалися | | Branch | Загальна кількість гілок рішень (if/else, try/except...) | | BrPart | Частково покриті гілки | | Cover % | (Stmts - Miss) / Stmts | **Постійні параметри у pytest.ini:** ```ini [pytest] addopts = --cov=src --cov-report=term-missing ```
01

Запуск coverage та читання звіту

#

Створіть `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. Запустіть coverage знову і переконайтеся, що досягнуто 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__":`. Запустіть coverage і переконайтеся, що ці файли і рядки не з'являються у звіті.

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

[report]
exclude_lines =
    # додайте шаблон для захисту __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