Раздел 04 — Фикстуры, параметризация, маркеры

Подготовку — в фикстуры, варианты данных — в параметры, а тест — в три строки

Прогресс курса Раздел 4 из 12

Что вы освоите в этом разделе

6 академических часов: 2 теории, 4 практики. Примеры — снова на shop.py, ошибку с границей скидки из раздела 03 считаем исправленной.
01

Параметризация

Граничных значений у скидки четыре, у доставки — четыре сочетания суммы и города. Писать восемь почти одинаковых функций незачем: @pytest.mark.parametrize запускает один тест на каждом наборе данных.

import pytest

import shop


@pytest.mark.parametrize("total, expected", [
    (4999.99, 0),
    (5000, 500),
    (5000.01, 500.0),
    (12345, 1234.5),
])
def test_discount(total, expected):
    assert shop.discount(total) == expected


@pytest.mark.parametrize("total, city, expected", [
    pytest.param(2999, "Санкт-Петербург", 300, id="spb-below"),
    pytest.param(3000, "Санкт-Петербург", 0, id="spb-free"),
    pytest.param(2999, "Казань", 500, id="other-below"),
    pytest.param(3000, "Казань", 0, id="other-free"),
])
def test_delivery(total, city, expected):
    assert shop.delivery(total, city) == expected

Первая строка декоратора — имена параметров через запятую, вторая — список наборов. pytest сам строит имя каждого варианта из значений: test_discount[5000-500]. Если значения длинные или непонятные, вариантам дают имена через pytest.param(…, id="…") — так сделано у доставки:

$ pytest -v tests/test_param.py
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0 -- ~/qa/.venv/bin/python3
cachedir: .pytest_cache
metadata: {'Python': '3.13.5', 'Platform': 'Linux-6.12.95+deb13-amd64-x86_64-with-glibc2.41', 'Packages': {'pytest': '9.2.0', 'pluggy': '1.7.0'}, 'Plugins': {'metadata': '3.1.1', 'playwright': '0.10.0', 'locust': '2.47.0', 'html': '4.2.0'}}
rootdir: ~/qa
configfile: pytest.ini
plugins: metadata-3.1.1, playwright-0.10.0, locust-2.47.0, html-4.2.0
collecting ... collected 8 items

tests/test_param.py::test_discount[4999.99-0] PASSED                     [ 12%]
tests/test_param.py::test_discount[5000-500] PASSED                      [ 25%]
tests/test_param.py::test_discount[5000.01-500.0] PASSED                 [ 37%]
tests/test_param.py::test_discount[12345-1234.5] PASSED                  [ 50%]
tests/test_param.py::test_delivery[spb-below] PASSED                     [ 62%]
tests/test_param.py::test_delivery[spb-free] PASSED                      [ 75%]
tests/test_param.py::test_delivery[other-below] PASSED                   [ 87%]
tests/test_param.py::test_delivery[other-free] PASSED                    [100%]

============================== 8 passed in 0.13s ===============================

Каждый вариант — отдельный тест: упадёт один — остальные всё равно выполнятся, а в отчёте будет точно видно, на каких данных. Параметризация — прямое продолжение тест-дизайна: таблица классов эквивалентности и границ из курса ручного тестирования переносится в код почти дословно.

Имена вариантов пишите латиницей: русский текст pytest показывает в отчёте кодами вида спб. Описание по-русски — в имени теста или в docstring.
02

Фикстуры

Фикстура — функция, которая готовит то, что нужно тесту: данные, файл, запущенное приложение, вошедшего пользователя. Её объявляют декоратором @pytest.fixture, а тест получает результат, просто указав имя фикстуры параметром. Кто и в каком порядке вызывает фикстуры — решает pytest.

import pytest


@pytest.fixture
def small_cart():
    """Корзина на 810 ₽: без скидки, с платной доставкой."""
    return [(120, 3), (45, 10)]


@pytest.fixture
def big_cart():
    """Корзина ровно на 5000 ₽: граница скидки."""
    return [(2500, 2)]


@pytest.fixture(scope="session")
def price_list():
    print("\n[прайс-лист загружен]")
    return {"ручка": 45, "тетрадь": 120, "папка": 2500}


@pytest.fixture
def receipts(tmp_path):
    folder = tmp_path / "receipts"
    folder.mkdir()
    print("\n[папка чеков создана]")
    yield folder
    print("\n[папка чеков: файлов —", len(list(folder.iterdir())), "]")
import shop


def test_small_cart_total(small_cart):
    assert shop.order_total(small_cart) == 810 + 300


def test_big_cart_total(big_cart):
    assert shop.order_total(big_cart) == 4500


def test_prices_known(price_list, small_cart):
    assert price_list["тетрадь"] == small_cart[0][0]


def test_receipt_saved(receipts, small_cart):
    path = receipts / "1.txt"
    shop.save_receipt(path, small_cart)
    assert path.read_text(encoding="utf-8").endswith("Итого: 1110.00\n")

Фикстуры лежат в conftest.py — особом файле, который pytest читает сам; импортировать его не нужно. Фикстуры из conftest.py в корне видны всем тестам проекта, из tests/ui/conftest.py — только тестам папки ui.

Тест может просить несколько фикстур (test_prices_known), а фикстура — другие фикстуры: receipts использует встроенную tmp_path — временную папку, своя у каждого теста.

03

Уборка и область видимости

Всё, что в фикстуре до yield, выполняется перед тестом; что после — после теста, даже если он упал. Это место для уборки: удалить созданную задачу, остановить сервер, закрыть файл.

Параметр scope говорит, как часто создавать фикстуру:

scopeСоздаётсяКогда брать
function (по умолчанию)Заново для каждого тестаДанные, которые тест меняет: корзина, новый пользователь
moduleОдин раз на файлРедко; общая подготовка тестов одного файла
sessionОдин раз на весь прогонДорогое и неизменное: запуск приложения, справочник, браузер

Порядок видно с ключом -s — он показывает вывод print, который pytest обычно скрывает:

$ pytest -v -s tests/test_fixtures.py
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0 -- ~/qa/.venv/bin/python3
cachedir: .pytest_cache
metadata: {'Python': '3.13.5', 'Platform': 'Linux-6.12.95+deb13-amd64-x86_64-with-glibc2.41', 'Packages': {'pytest': '9.2.0', 'pluggy': '1.7.0'}, 'Plugins': {'metadata': '3.1.1', 'playwright': '0.10.0', 'locust': '2.47.0', 'html': '4.2.0'}}
rootdir: ~/qa
configfile: pytest.ini
plugins: metadata-3.1.1, playwright-0.10.0, locust-2.47.0, html-4.2.0
collecting ... collected 4 items

tests/test_fixtures.py::test_small_cart_total PASSED
tests/test_fixtures.py::test_big_cart_total PASSED
tests/test_fixtures.py::test_prices_known 
[прайс-лист загружен]
PASSED
tests/test_fixtures.py::test_receipt_saved 
[папка чеков создана]
PASSED
[папка чеков: файлов — 1 ]


============================== 4 passed in 0.12s ===============================

Прайс-лист загрузился один раз и не раньше, чем понадобился: первому тесту с ним. Сообщение об уборке папки чеков напечаталось после PASSED — сначала тест, потом код после yield.

Фикстура с областью session общая для всех тестов. Если тест её изменит — следующие получат изменённое, и результат будет зависеть от порядка тестов. Поэтому изменяемое делают на тест, а общее — только читают.
04

Фикстуры стартового проекта

В conftest.py стартового проекта «Задачника» (раздел 01) те же приёмы, только подготовка серьёзнее:

"""Общие фикстуры: запуск «Задачника», адрес, пользователи и клиенты API."""
import secrets
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path

import pytest
import requests

from client import Api, login_token, register

ROOT = Path(__file__).parent


def pytest_addoption(parser):
    parser.addoption("--tasker-version", default="1.0", choices=["1.0", "1.1"],
                     help="версия Задачника для прогона")


def free_port():
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


@pytest.fixture(scope="session")
def tasker_dir(tmp_path_factory):
    """Временная папка с копией tasker.py: база и журнал прогона — там, а не в проекте."""
    work = tmp_path_factory.mktemp("tasker")
    shutil.copy(ROOT / "tasker.py", work)
    return work


@pytest.fixture(scope="session")
def base_url(request, tasker_dir):
    """Свой Задачник на свободном порту со свежими учебными данными — на весь прогон."""
    work = tasker_dir
    port = free_port()
    version = request.config.getoption("--tasker-version")
    proc = subprocess.Popen(
        [sys.executable, "tasker.py", "--reset", "--port", str(port), "--version", version],
        cwd=work, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
    url = f"http://127.0.0.1:{port}"
    for _ in range(100):
        try:
            requests.get(url + "/login", timeout=1)
            break
        except requests.ConnectionError:
            time.sleep(0.1)
    else:
        proc.kill()
        pytest.fail("Задачник не запустился за 10 секунд")
    yield url
    proc.terminate()
    proc.wait(timeout=5)


@pytest.fixture(scope="session")
def tasker_log(base_url, tasker_dir):
    """Журнал запросов запущенного Задачника."""
    return tasker_dir / "tasker.log"


@pytest.fixture
def user(base_url):
    """Новый пользователь без задач — тест не зависит от других тестов."""
    login, password = "u_" + secrets.token_hex(4), "Test2026pass"
    r = register(base_url, login, password)
    assert r.status_code == 303, r.text
    return {"login": login, "password": password}


@pytest.fixture
def api(base_url, user):
    return Api(base_url, login_token(base_url, user["login"], user["password"]))


@pytest.fixture
def ivan(base_url):
    """Учебный пользователь с 8 задачами. Только читать: данные общие на весь прогон."""
    return Api(base_url, login_token(base_url, "ivan", "Ivan2026pass"))


@pytest.fixture
def maria(base_url):
    """Второй учебный пользователь (3 задачи). Тоже только читать."""
    return Api(base_url, login_token(base_url, "maria", "Maria2026pass"))


@pytest.fixture
def anon(base_url):
    return Api(base_url)
ФикстураОбластьЧто даёт тесту
tasker_dirsessionВременную папку с копией tasker.py — база прогона не смешивается с вашей
base_urlsessionАдрес запущенного «Задачника»; после yield сервер останавливается
userfunctionНового пользователя без задач — тест работает со своими данными
apifunctionКлиент API, вошедший под этим пользователем
ivan, mariafunctionКлиенты учебных пользователей с готовыми задачами — только читать
anonfunctionКлиент без токена — для проверок «без входа нельзя»

Функция pytest_addoption добавляет свой ключ командной строки: pytest --tasker-version 1.1 запускает тесты на новой версии. Он понадобится для регресса в разделе 11. Свободный порт выбирается автоматически, поэтому набор не мешает «Задачнику», который вы держите открытым на 8000 для ручной работы.

05

Маркеры

Маркер — метка теста. По меткам набор делят на части: smoke — несколько главных проверок за секунды, перед всем остальным; slow — долгие, их можно отложить.

import pytest

import shop


@pytest.mark.smoke
def test_discount_works():
    assert shop.discount(10000) == 1000


@pytest.mark.smoke
def test_delivery_works():
    assert shop.delivery(100) == 300


@pytest.mark.slow
def test_many_orders():
    for n in range(1, 20001):
        assert shop.order_total([(n, 1)]) >= 0
[pytest]
testpaths = tests
pythonpath = .
addopts = --strict-markers
markers =
    smoke: быстрые проверки главного — запускать первыми
    slow: долгие проверки
$ pytest -m smoke -v
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0 -- ~/qa/.venv/bin/python3
cachedir: .pytest_cache
metadata: {'Python': '3.13.5', 'Platform': 'Linux-6.12.95+deb13-amd64-x86_64-with-glibc2.41', 'Packages': {'pytest': '9.2.0', 'pluggy': '1.7.0'}, 'Plugins': {'metadata': '3.1.1', 'playwright': '0.10.0', 'locust': '2.47.0', 'html': '4.2.0'}}
rootdir: ~/qa
configfile: pytest.ini
testpaths: tests
plugins: metadata-3.1.1, playwright-0.10.0, locust-2.47.0, html-4.2.0
collecting ... collected 15 items / 13 deselected / 2 selected

tests/test_markers.py::test_discount_works PASSED                        [ 50%]
tests/test_markers.py::test_delivery_works PASSED                        [100%]

======================= 2 passed, 13 deselected in 0.12s =======================
$ pytest -m "not slow" -q
..............                                                           [100%]
14 passed, 1 deselected in 0.14s

-m smoke — только помеченные, -m "not slow" — все, кроме долгих; допустимы and, or, скобки. Маркеры описывают в pytest.ini, а ключ --strict-markers в addopts превращает опечатку в ошибку. Без него тест с @pytest.mark.smok молча выпал бы из smoke-набора:

$ pytest -q tests/test_markers.py

==================================== ERRORS ====================================
____________________ ERROR collecting tests/test_markers.py ____________________
'smok' not found in `markers` configuration option
=========================== short test summary info ============================
ERROR tests/test_markers.py - Failed: 'smok' not found in `markers` configura...
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.26s

Какие тесты действительно медленные, а не «кажутся», показывает --durations:

$ pytest -q --durations=3
...............                                                          [100%]
============================= slowest 3 durations ==============================
0.04s call     tests/test_markers.py::test_many_orders

(2 durations < 0.005s hidden.  Use -vv to show these durations.)
15 passed in 0.18s
06

Пропуск и ожидаемое падение

Два встроенных маркера меняют результат теста:

МаркерРезультатКогда
@pytest.mark.skip(reason="…")
@pytest.mark.skipif(условие, reason="…")
s — пропущенТест не имеет смысла в этом окружении: «только Windows», «нет доступа к почте»
@pytest.mark.xfail(reason="BUG-12: …", strict=True)x — упал, как ожидалось; XPASS — неожиданно прошёлИзвестный дефект зарегистрирован, но ещё не исправлен

xfail — честный способ держать набор зелёным, пока дефект ждёт исправления: тест остаётся в наборе и проверяет, что дефект всё ещё есть. С strict=True тест, который вдруг прошёл, считается упавшим — это сигнал «дефект исправлен, снимите метку». В reason пишут номер баг-репорта. Подробнее — в разделе 10, где набор запускается в CI.

skip без причины — способ спрятать неудобный тест. При ревью (раздел 11) каждый пропуск должен объясняться в reason.

Ключевые выводы раздела

Запомните главное

  • Параметризация переносит таблицу классов и границ в код: один тест — много вариантов, каждый в отчёте отдельно
  • Фикстура готовит данные; тест получает её по имени параметра; общие фикстуры — в conftest.py
  • Код после yield — уборка, выполняется и после упавшего теста
  • session — для дорогого и неизменного, function — для того, что тест меняет
  • Маркеры делят набор на части, --strict-markers ловит опечатки; xfail — для зарегистрированных дефектов
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 03: pytest — первые тесты Все разделы Раздел 05: Модульные тесты и заглушки