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

Маркери та вибір тестів

5 завдань

Позначайте тести вбудованими та власними маркерами для контролю запуску.

Мітки та відбір тестів

#
## Не всі тести мають завжди запускатися Коли тест-сьют розростається, тести набувають дуже різних характеристик: - Одні швидкі (чисті функції, без I/O) - Інші повільні (звертаються до БД або зовнішнього API) - Деякі мають сенс лише на конкретній ОС або версії Python - Деякі тестують функцію, яка ще не реалізована - Деякі відомо провалюються через відкритий баг Запускати все на кожному натисканні клавіші -- марна витрата часу. **Мітки** дозволяють семантично позначати тести і фільтрувати їх прапором `-m` під час запуску. ## Вбудовані мітки pytest поставляється з кількома мітками з коробки. Вони не потребують реєстрації. ### @pytest.mark.skip -- безумовний пропуск ```python @pytest.mark.skip(reason='платіжний шлюз не налаштований у тестовому середовищі') def test_charge_card(): ... ``` Тест відображається як `s` у виводі. З `-v` видно причину. Завжди вказуйте `reason=` -- майбутній ви подякує теперішньому. ### @pytest.mark.skipif -- умовний пропуск ```python import sys @pytest.mark.skipif(sys.platform == 'win32', reason='лише POSIX-шляхи') def test_symlinks(): ... @pytest.mark.skipif(sys.version_info < (3, 11), reason='tomllib додано у 3.11') def test_tomllib_parse(): import tomllib ... ``` Умова обчислюється під час **збору** (коли pytest збирає тести), а не під час запуску. Використовуйте будь-який вираз Python, що обчислюється як bool. ### @pytest.mark.xfail -- очікуваний збій ```python @pytest.mark.xfail(reason='баг #47: парсер падає на порожньому введенні') def test_parse_empty(): assert parse('') == [] # наразі підіймає IndexError ``` Результати: - Тест провалюється → `x` (xfail -- очікувано, нормально) - Тест проходить → `X` (xpass -- неочікуваний успіх) Додайте `strict=True`, щоб неочікуваний успіх став жорсткою помилкою. Це змушує видалити мітку після виправлення бага -- критично для CI: ```python @pytest.mark.xfail(strict=True, reason='баг #47') def test_parse_empty(): assert parse('') == [] ``` З `strict=True`: - Тест продовжує провалюватися як очікувано → `x` (так само, як без `strict`) - Тест несподівано проходить → `FAILED` -- блокує CI так само, як звичайний провал тесту Без `strict=True` неочікуваний успіх відображається як `X` (xpass) -- попередження, а не збій. ## @pytest.mark.parametrize -- запуск одного тесту з багатьма вхідними даними Найбільш вживана мітка. Замість написання п'яти майже однакових тестів -- напишіть один і надайте список вхідних даних: ```python import pytest @pytest.mark.parametrize('value,expected', [ (0, 0), (1, 1), (-1, 1), (100, 100), (-50, 50), ]) def test_abs(value, expected): assert abs(value) == expected ``` pytest запускає цей тест п'ять разів, по одному на кожен рядок. Кожен запуск відображається як окремий елемент у виводі: `test_abs[0-0]`, `test_abs[1-1]` тощо. ### Один параметр ```python @pytest.mark.parametrize('n', [1, 2, 3, 10, 100]) def test_positive(n): assert n > 0 ``` ### Позначення окремих кейсів Кожен набір параметрів може мати власні мітки. Корисно для пропуску одного кейсу або позначення його як очікуваного збою: ```python @pytest.mark.parametrize('code,expected_status', [ ('SAVE20', 200), ('EXPIRED', 400), pytest.param('SECRET', 200, marks=pytest.mark.xfail(reason='ще не реалізовано')), ]) def test_coupon(code, expected_status): ... ``` ### Стекування parametrize Кілька декораторів `@pytest.mark.parametrize` на одному тесті дають **декартовий добуток** -- тестується кожна комбінація: ```python @pytest.mark.parametrize('base', [10, 100]) @pytest.mark.parametrize('discount', [0.1, 0.2, 0.5]) def test_discount(base, discount): result = base * (1 - discount) assert result < base # 2 × 3 = 6 тест-кейсів всього ``` --- ## Власні мітки Ви можете визначити будь-яке ім'я мітки: `@pytest.mark.slow`, `@pytest.mark.database`, `@pytest.mark.external`. Але їх необхідно **зареєструвати** у `pytest.ini`, щоб уникнути `PytestUnknownMarkWarning`: ```ini [pytest] markers = slow: позначає повільні тести (відмінити з '-m "not slow"') database: тести, що потребують реального підключення до БД external: тести, що звертаються до зовнішніх сервісів ``` Застосовуйте їх як декоратори -- тест може мати кілька міток: ```python @pytest.mark.slow @pytest.mark.external def test_load_from_s3(): ... ``` ## Запуск тестів за міткою: вирази -m ```bash pytest -m slow # лише тести 'slow' pytest -m "not slow" # все, крім 'slow' pytest -m "slow and external" # мають бути обидві мітки pytest -m "slow or database" # будь-яка з міток pytest -m "not (slow or external)" # жодна з міток ``` Прапор `-m` підтримує `and`, `or`, `not` і дужки -- повна булева алгебра. ## Практичний шаблон розробки Зареєструйте мітку `slow`. Застосовуйте її до всього, що звертається до мережі або реальної БД. Під час розробки запускайте `pytest -m "not slow"` для зворотного зв'язку за частки секунди. У CI запускайте `pytest` без фільтра, щоб охопити все, включаючи повільні тести. Цей шаблон тримає локальні ітерації швидкими, забезпечуючи повне покриття в CI без жодних відмінностей у конфігурації -- ті самі файли тестів, різні прапори `-m`.

Мітки на практиці

#
## Налаштування: реєстрація власних міток у pytest.ini ```ini # pytest.ini [pytest] markers = slow: позначає повільні тести database: потребує живої БД external: звертається до зовнішнього сервісу ``` Без цього pytest показує `PytestUnknownMarkWarning` для власних міток. Реєстрація також дозволяє `--markers` виводити їх для команди. ## Вбудовані мітки у тестовому файлі ```python # test_features.py import sys import pytest @pytest.mark.skip(reason='модуль виставлення рахунків ще не реалізовано') def test_generate_invoice(): from billing import generate_invoice assert generate_invoice(order_id=1) is not None @pytest.mark.skipif(sys.platform != 'linux', reason='inotify лише для Linux') def test_file_watcher(): from watcher import FileWatcher watcher = FileWatcher('/tmp') assert watcher.is_running() @pytest.mark.xfail(reason='баг #88: parse() падає на порожньому введенні') def test_parse_empty(): from mylib import parse assert parse('') == [] # наразі підіймає IndexError -- xfail це дозволяє @pytest.mark.xfail(strict=True, reason='баг #88') def test_parse_empty_strict(): # Коли баг #88 буде виправлено і тест пройде, CI провалиться, поки мітку не видалять from mylib import parse assert parse('') == [] ``` ## Власні мітки: розділення швидких і повільних тестів ```python # test_products.py import pytest import requests BASE_URL = 'https://apilearn.tukas.dev' def test_price_calculation(): # Швидкий -- чиста математика, без I/O; запускається при кожному локальному pytest assert round(100 * 0.9, 2) == 90.0 @pytest.mark.slow def test_products_api(): # Реальний HTTP-запит -- відмінити з '-m "not slow"' під час розробки resp = requests.get(f'{BASE_URL}/api/products/') assert resp.status_code == 200 @pytest.mark.slow @pytest.mark.external def test_product_search(): # Дві мітки -- можна цілитися з '-m "slow and external"' resp = requests.get(f'{BASE_URL}/api/products/?search=laptop') assert resp.status_code == 200 ``` ## Запуск з -m ```bash # Розробка: миттєвий зворотний зв'язок, пропустити все повільне pytest -m "not slow" # CI: все, включаючи повільні та зовнішні тести pytest # Цільова група pytest -m "slow and not external" # Показати причини пропущених тестів pytest -v -m "not slow" ``` **Вивід з `pytest -v -m "not slow"`:** ``` test_products.py::test_price_calculation PASSED test_products.py::test_products_api DESELECTED test_products.py::test_product_search DESELECTED 1 passed, 2 deselected in 0.05s ``` ## Застосування мітки до цілого класу ```python @pytest.mark.slow class TestAPIIntegration: def test_products(self): ... def test_orders(self): ... # обидва автоматично отримують 'slow' ``` ## Поєднання з skipif ```python @pytest.mark.slow @pytest.mark.skipif(sys.platform == 'win32', reason='Unix-шляхи') def test_unix_file_processing(): ... ``` Мітки стекуються -- всі умови застосовуються незалежно.

Довідник: мітки

#
**Вбудовані мітки:** | Мітка | Ефект | Вивід | |-------|-------|-------| | `@pytest.mark.skip(reason='...')` | Завжди пропустити | `s` | | `@pytest.mark.skipif(cond, reason='...')` | Пропустити, якщо умова True | `s` | | `@pytest.mark.xfail(reason='...')` | Очікуваний збій | `x` / `X` | | `@pytest.mark.xfail(strict=True)` | Неочікуваний успіх = збій CI | `F` | **Реєстрація власних міток (pytest.ini):** ```ini [pytest] markers = slow: позначає повільні тести database: потребує БД external: звертається до зовнішніх сервісів ``` **Застосування міток:** ```python @pytest.mark.slow @pytest.mark.database def test_something(): ... ``` **Застосування до цілого класу:** ```python @pytest.mark.slow class TestHeavy: def test_a(self): ... def test_b(self): ... # обидва отримують 'slow' ``` **Синтаксис виразів -m:** ```bash pytest -m slow pytest -m "not slow" pytest -m "slow and database" pytest -m "slow or external" pytest -m "not (slow or external)" ``` **Поширені шаблони skipif:** ```python import sys @pytest.mark.skipif(sys.platform == 'win32', reason='...') @pytest.mark.skipif(sys.version_info < (3, 11), reason='...') ``` **Перелік усіх зареєстрованих міток:** ```bash pytest --markers ```
01

Пропуск тесту з причиною

#

Напишіть тест-функцію `test_future_feature`, яка тестуватиме якусь ще не реалізовану функцію. Позначте її `@pytest.mark.skip` з описовим рядком `reason`. Запустіть `pytest -v` і переконайтеся, що тест відображається як `SKIPPED` з видимою причиною. Потім запустіть `pytest -v -rs`, щоб побачити причину пропуску в розділі підсумку.

import pytest


@pytest.mark.skip(reason='...')   # вкажіть вашу причину тут
def test_future_feature():
    # цей код ніколи не виконується при пропуску
    result = some_unimplemented_function()
    assert result == 42
Рішення
import pytest


@pytest.mark.skip(reason='відшкодування платежів ще не реалізовано -- дивіться issue #42')
def test_future_feature():
    result = some_unimplemented_function()
    assert result == 42
02

Умовний пропуск за версією Python

#

Напишіть тест, що використовує `@pytest.mark.skipif` для пропуску, якщо версія Python нижче 3.10. У тілі тесту використайте синтаксис оператора `match` (доданого у Python 3.10): `match value: case 1: result = "one"`. Запустіть `pytest -v` -- тест має виконуватися на Python 3.10+ і пропускатися на більш ранніх версіях. Виведіть `sys.version` у тесті, щоб підтвердити, яка версія його запустила.

import sys
import pytest


@pytest.mark.skipif(
    # умова: sys.version_info < (?, ?),
    # reason='...',
)
def test_match_statement():
    value = 1
    # використайте тут оператор match/case
    # assert result == 'one'
Рішення
import sys
import pytest


@pytest.mark.skipif(
    sys.version_info < (3, 10),
    reason='оператор match доданий у Python 3.10',
)
def test_match_statement():
    print(f'запускається на Python {sys.version}')
    value = 1
    match value:
        case 1:
            result = 'one'
        case _:
            result = 'other'
    assert result == 'one'
03

Позначення тесту як очікуваного збою (xfail)

#

Напишіть два тести: `test_known_bug` і `test_known_bug_strict`. Обидва викликають `int("abc")` без try/except (це підіймає `ValueError`). Позначте `test_known_bug` через `@pytest.mark.xfail`, а `test_known_bug_strict` -- через `@pytest.mark.xfail(strict=True)`. Запустіть `pytest -v` -- обидва мають відображатися як `XFAIL`. Зверніть увагу на різницю: без `strict` неочікуваний успіх відобразиться як `XPASS` (попередження, але не збій); з `strict=True` неочікуваний успіх стає жорстким `FAILED`, що блокує CI.

import pytest


@pytest.mark.xfail(reason=...)  # вкажіть причину
def test_known_bug():
    result = int('abc')
    assert result == 0


@pytest.mark.xfail(strict=..., reason=...)  # додайте параметр strict=
def test_known_bug_strict():
    result = int('abc')
    assert result == 0
Рішення
import pytest


@pytest.mark.xfail(reason='баг #77: int() не обробляє літери')
def test_known_bug():
    result = int('abc')
    assert result == 0


# З strict=True: якщо цей тест несподівано пройде, pytest повідомить про FAILED
@pytest.mark.xfail(strict=True, reason='баг #77')
def test_known_bug_strict():
    result = int('abc')
    assert result == 0
04

Реєстрація власної мітки та фільтрація через -m

#

Додайте `slow` до секції `markers` у `pytest.ini`. Напишіть три тест-функції в одному файлі: `test_fast_calculation` (без мітки, просто `assert 2 + 2 == 4`), `test_slow_api_call` і `test_slow_db_query` (обидві позначені `@pytest.mark.slow`). Запустіть `pytest -m slow -v` і переконайтеся, що виконуються лише два повільні тести. Запустіть `pytest -m "not slow" -v` і переконайтеся, що виконується лише швидкий тест.

# pytest.ini
# [pytest]
# markers =
#     slow: позначає тести як повільні (відмінити з '-m "not slow"')


# test_mixed.py
import pytest


def test_fast_calculation():
    assert 2 + 2 == 4


@pytest.mark.slow
def test_slow_api_call():
    # симуляція повільної операції
    import time
    time.sleep(0.1)
    assert True


@pytest.mark.slow
def test_slow_db_query():
    import time
    time.sleep(0.1)
    assert True
Рішення
# pytest.ini
# [pytest]
# markers =
#     slow: позначає тести як повільні (відмінити з '-m "not slow"')


# test_mixed.py
import pytest


def test_fast_calculation():
    assert 2 + 2 == 4


@pytest.mark.slow
def test_slow_api_call():
    import time
    time.sleep(0.1)
    assert True


@pytest.mark.slow
def test_slow_db_query():
    import time
    time.sleep(0.1)
    assert True
05

Поєднання кількох міток

#

Зареєструйте обидві мітки `slow` і `unix_only` у `pytest.ini`. Напишіть `test_fast_check` без міток. Напишіть `test_file_permissions` з мітками `@pytest.mark.slow`, `@pytest.mark.unix_only` ТА `@pytest.mark.skipif(sys.platform == 'win32', ...)`. Запустіть `pytest -v`, щоб переконатися, що обидва тести виконуються (або пропускаються на Windows). Потім запустіть `pytest -m slow -v` -- вибирається лише повільний тест. Потім запустіть `pytest -m unix_only -v` -- той самий результат: вибирається лише тест платформи. Зверніть увагу, що `@pytest.mark.skipif` забезпечує захист під час виконання, а мітка вмикає фільтрацію через CLI.

# pytest.ini
# [pytest]
# markers =
#     slow: повільні тести
#     unix_only: запускається лише на Unix/Linux/macOS


import sys
import pytest


def test_fast_check():
    assert 'py' in 'pytest'


@pytest.mark.slow
@pytest.mark.unix_only
@pytest.mark.skipif(sys.platform == 'win32', reason='використовує права доступу POSIX')
def test_file_permissions():
    import os
    import tempfile
    with tempfile.NamedTemporaryFile() as f:
        os.chmod(f.name, 0o644)
        mode = oct(os.stat(f.name).st_mode)[-3:]
        assert mode == '644'
Рішення
# pytest.ini
# [pytest]
# markers =
#     slow: повільні тести
#     unix_only: запускається лише на Unix/Linux/macOS


import sys
import pytest


def test_fast_check():
    assert 'py' in 'pytest'


@pytest.mark.slow
@pytest.mark.unix_only
@pytest.mark.skipif(sys.platform == 'win32', reason='використовує права доступу POSIX')
def test_file_permissions():
    import os
    import tempfile
    with tempfile.NamedTemporaryFile() as f:
        os.chmod(f.name, 0o644)
        mode = oct(os.stat(f.name).st_mode)[-3:]
        assert mode == '644'