Подготовку — в фикстуры, варианты данных — в параметры, а тест — в три строки
conftest.pyyieldГраничных значений у скидки четыре, у доставки — четыре сочетания суммы и города. Писать восемь почти одинаковых функций незачем: @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 ===============================
Каждый вариант — отдельный тест: упадёт один — остальные всё равно выполнятся, а в отчёте будет точно видно, на каких данных. Параметризация — прямое продолжение тест-дизайна: таблица классов эквивалентности и границ из курса ручного тестирования переносится в код почти дословно.
спб. Описание по-русски — в имени теста или в docstring.Фикстура — функция, которая готовит то, что нужно тесту: данные, файл, запущенное приложение, вошедшего пользователя. Её объявляют декоратором @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 — временную папку, своя у каждого теста.
Всё, что в фикстуре до 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 общая для всех тестов. Если тест её изменит — следующие получат изменённое, и результат будет зависеть от порядка тестов. Поэтому изменяемое делают на тест, а общее — только читают.В 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_dir | session | Временную папку с копией tasker.py — база прогона не смешивается с вашей |
base_url | session | Адрес запущенного «Задачника»; после yield сервер останавливается |
user | function | Нового пользователя без задач — тест работает со своими данными |
api | function | Клиент API, вошедший под этим пользователем |
ivan, maria | function | Клиенты учебных пользователей с готовыми задачами — только читать |
anon | function | Клиент без токена — для проверок «без входа нельзя» |
Функция pytest_addoption добавляет свой ключ командной строки: pytest --tasker-version 1.1 запускает тесты на новой версии. Он понадобится для регресса в разделе 11. Свободный порт выбирается автоматически, поэтому набор не мешает «Задачнику», который вы держите открытым на 8000 для ручной работы.
Маркер — метка теста. По меткам набор делят на части: 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
Два встроенных маркера меняют результат теста:
| Маркер | Результат | Когда |
|---|---|---|
@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.pyyield — уборка, выполняется и после упавшего тестаsession — для дорогого и неизменного, function — для того, что тест меняет--strict-markers ловит опечатки; xfail — для зарегистрированных дефектов