pytest, проверка ошибок и границ, параметризация, фикстуры, маркеры, организация набора
- Маршрут: Основы · тема 7 из 7
- Навыки:
J6- До урока:
functions,exceptions.- Результат: тесты как исполняемый контракт до появления сложных абстракций.
- Основа: первый тест на pytest, проверка границ и ожидаемых ошибок, параметризация, встроенные и собственные фикстуры. Работа в эксплуатации: организация набора и маркеры.
- Продолжение: изоляция зависимостей — тема
test_doubles; покрытие, пирамида и качество набора — темаtest_strategy.- Подтверждение: набор pytest-тестов счастливого пути, границ и ожидаемых ошибок.
Тест полезен, когда фиксирует наблюдаемое поведение и даёт понятный сигнал при регрессии. Количество тестов само по себе ничего не говорит об их качестве.
Весь урок построен вокруг одного модуля — расчёта стоимости заказа. Он маленький, но у него есть всё, что нужно проверять: арифметика с деньгами, границы скидки, ожидаемая ошибка, файл с промокодами, переменная окружения и запись в журнал.
# shop/orders.py
import json
import logging
import os
from pathlib import Path
log = logging.getLogger(__name__)
def load_promos(path: Path) -> dict[str, int]:
"""Читает промокоды из JSON-файла: {"WELCOME": 10, "SALE50": 50}."""
return json.loads(path.read_text(encoding="utf-8"))
def order_total(items: list[tuple[float, int]], promo: str | None,
promos: dict[str, int]) -> float:
"""Считает стоимость заказа со скидкой и налогом.
items — пары (цена за единицу, количество).
promo — код скидки или None.
"""
subtotal = sum(price * qty for price, qty in items)
if promo is not None:
if promo not in promos:
raise ValueError(f"неизвестный промокод: {promo}")
percent = promos[promo]
if percent > 50:
log.warning("скидка %d%% превышает обычный предел", percent)
subtotal *= 1 - percent / 100
tax = float(os.environ.get("TAX_RATE", "0"))
return subtotal * (1 + tax)Функция намеренно возвращает float без округления: так видно, что арифметика с
деньгами неточна. В настоящем расчёте для этого берут Decimal или целые копейки
— здесь же неточность нужна, чтобы разобрать её в тестах.
Тест фиксирует наблюдаемое поведение и помогает заметить регрессию после изменения кода. Он не доказывает отсутствие ошибок. Хороший набор проверяет важные сценарии, границы и сбои, оставаясь достаточно быстрым и понятным для регулярного запуска.
Удобная структура одного теста — Arrange, Act, Assert:
def test_order_total_sums_positions():
items = [(100.0, 2), (50.0, 1)] # Arrange
total = order_total(items, None, {}) # Act
assert total == 250.0 # AssertТесты должны быть независимыми: порядок запуска, состояние соседнего теста и реальные часы или сеть не должны менять результат. Независимость — не формальность: именно она позволяет запускать набор параллельно, повторять отдельный упавший тест и доверять красному результату.
assert и чтение паденияpytest не требует наследования от базового класса. Тест — это функция с именем
test_* в файле test_*.py.
uv add --dev pytest# tests/test_orders.py
from shop.orders import order_total
def test_order_total_applies_promo():
total = order_total([(100.0, 1)], "WELCOME", {"WELCOME": 10})
assert total == 90.0uv run pytest # весь набор
uv run pytest tests/test_orders.py # один файл
uv run pytest tests/test_orders.py::test_order_total_applies_promo
uv run pytest -k "promo" # по подстроке в имени
uv run pytest -x # остановиться на первом падении
uv run pytest --lf # только упавшие в прошлый раз
uv run pytest -q # короткий выводГлавное преимущество перед голым assert — разбор выражения при падении.
pytest показывает не «AssertionError», а фактические значения. Добавим второй
тест, на три позиции по 1.10:
def test_order_total_sums_three_positions():
assert order_total([(1.10, 3)], None, {}) == 3.30 def test_order_total_sums_three_positions():
> assert order_total([(1.10, 3)], None, {}) == 3.30
E assert 3.3000000000000003 == 3.3
Читать вывод нужно снизу вверх: строка с E — суть расхождения, строка с > —
где оно обнаружено. Здесь видно и то, что тест не врёт: 1.10 * 3 в двоичном
float действительно не равно 3.30. Это не ошибка расчёта, а свойство типа — и
повод для следующего раздела.
Двоичное представление float не хранит десятичные дроби точно, поэтому
результат арифметики совпадает с ожидаемым побитово далеко не всегда.
pytest.approx сравнивает с допуском — по умолчанию около шести значащих цифр:
import pytest
def test_order_total_sums_three_positions():
assert order_total([(1.10, 3)], None, {}) == pytest.approx(3.30)
def test_approx_works_for_collections():
totals = [order_total([(1.10, 3)], None, {}), order_total([(0.1, 3)], None, {})]
assert totals == pytest.approx([3.30, 0.30])Обратите внимание: тест с 100.0 и скидкой 10% прошёл бы и с обычным == —
именно поэтому неточность легко не заметить. Она проявляется на конкретных
значениях, а не на всех подряд, и тест без approx начинает падать при первой
же смене цены в данных.
Ожидаемую ошибку проверяют через pytest.raises. Тест на ошибку обязан
проверять и её тип, и опознаваемую часть сообщения — иначе он пройдёт на любом
сбое, включая опечатку в имени переменной:
def test_unknown_promo_is_rejected():
with pytest.raises(ValueError, match="неизвестный промокод"):
order_total([(100.0, 1)], "NOPE", {"WELCOME": 10})
def test_error_carries_the_code():
with pytest.raises(ValueError) as exc_info:
order_total([(100.0, 1)], "NOPE", {"WELCOME": 10})
assert "NOPE" in str(exc_info.value)match принимает регулярное выражение и применяется через re.search.
Спецсимволы в ожидаемом тексте нужно экранировать: match=re.escape("(1024)").
Проверка отсутствия предупреждения делается превращением предупреждений в ошибки, а не сравнением накопленных списков:
import warnings
def test_normal_discount_does_not_warn():
with warnings.catch_warnings():
warnings.simplefilter("error")
order_total([(100.0, 1)], "WELCOME", {"WELCOME": 10})Четыре почти одинаковых теста на разные скидки — это четыре места, где придётся
править контракт. @pytest.mark.parametrize превращает их в таблицу, где каждая
строка остаётся отдельным тестом со своим результатом в отчёте.
@pytest.mark.parametrize("percent, expected", [
(0, 100.0),
(10, 90.0),
(50, 50.0),
(100, 0.0),
])
def test_discount_boundaries(percent, expected):
total = order_total([(100.0, 1)], "CODE", {"CODE": percent})
assert total == pytest.approx(expected)Имена случаев в отчёте задаются через pytest.param(..., id=...) — это
единственный способ увидеть в выводе смысл упавшей строки, а не индекс:
@pytest.mark.parametrize("items, expected", [
pytest.param([], 0.0, id="пустой-заказ"),
pytest.param([(100.0, 1)], 100.0, id="одна-позиция"),
pytest.param([(10.0, 3), (5.0, 2)], 40.0, id="несколько-позиций"),
])
def test_subtotal(items, expected):
assert order_total(items, None, {}) == pytest.approx(expected)Два декоратора подряд дают декартово произведение — удобно, но растёт быстро: три значения на два дают шесть тестов, а не пять.
@pytest.mark.parametrize("qty", [1, 2, 3])
@pytest.mark.parametrize("promo", [None, "WELCOME"])
def test_total_is_never_negative(qty, promo):
assert order_total([(100.0, qty)], promo, {"WELCOME": 10}) >= 0Не называйте параметры именами встроенных функций (input, id, type):
внутри теста они перекроют встроенное имя, и следующая правка теста упрётся в
неочевидную ошибку.
tmp_path, monkeypatch, caplogФикстура — именованный ресурс, который pytest подставляет в тест по имени аргумента. Часть фикстур уже встроена, и переписывать их вручную не нужно.
tmp_path — уникальный временный каталог на каждый тест, удаляется
автоматически:
import json
def test_load_promos_reads_file(tmp_path):
path = tmp_path / "promos.json"
path.write_text(json.dumps({"WELCOME": 10}), encoding="utf-8")
assert load_promos(path) == {"WELCOME": 10}monkeypatch меняет переменные окружения, атрибуты и элементы словарей на время
одного теста и возвращает всё обратно после него:
def test_tax_is_added(monkeypatch):
monkeypatch.setenv("TAX_RATE", "0.2")
assert order_total([(100.0, 1)], None, {}) == pytest.approx(120.0)
def test_no_tax_by_default(monkeypatch):
monkeypatch.delenv("TAX_RATE", raising=False)
assert order_total([(100.0, 1)], None, {}) == pytest.approx(100.0)Без monkeypatch тест, записавший os.environ["TAX_RATE"] напрямую, оставит
значение следующим тестам — и набор начнёт зависеть от порядка запуска.
caplog перехватывает записи журнала, capsys — то, что напечатано в stdout
и stderr:
import logging
def test_large_discount_is_logged(caplog):
with caplog.at_level(logging.WARNING):
order_total([(100.0, 1)], "SALE90", {"SALE90": 90})
assert "превышает обычный предел" in caplog.text
def test_report_prints_total(capsys):
print_receipt([(100.0, 1)])
assert "100.0" in capsys.readouterr().outyield, область видимости, conftest.pyСобственная фикстура — функция с декоратором @pytest.fixture. Код до yield
готовит ресурс, код после — освобождает его, даже если тест упал.
@pytest.fixture
def promos(tmp_path):
"""Файл промокодов и разобранный словарь."""
path = tmp_path / "promos.json"
path.write_text(json.dumps({"WELCOME": 10, "SALE50": 50}), encoding="utf-8")
return load_promos(path)
@pytest.fixture
def db_connection():
conn = connect()
yield conn # тест работает здесь
conn.close() # выполнится и при падении теста
def test_promo_from_file(promos):
assert order_total([(100.0, 1)], "SALE50", promos) == pytest.approx(50.0)Фикстуры могут зависеть друг от друга — promos выше принимает tmp_path.
Область видимости (scope) определяет, как часто фикстура пересоздаётся:
| scope | Создаётся | Когда уместно |
|---|---|---|
function | на каждый тест (по умолчанию) | всё изменяемое состояние |
class | один раз на класс | общая дорогая подготовка группы |
module | один раз на файл | тестовый сервер, загруженная модель |
session | один раз на запуск | схема БД, сборка контейнера |
@pytest.fixture(scope="session")
def database():
db = create_test_database()
yield db
db.drop()Широкая область — обмен скоростью на изоляцию. Если тест изменит объект из
session-фикстуры, следующие тесты получат изменённое состояние, и падение
проявится где-то ещё. Дорогую подготовку держите в session, а изменяемые
данные — в function-фикстуре поверх неё.
Фикстуры из conftest.py доступны всем тестам в каталоге и подкаталогах без
импорта:
# tests/conftest.py
import pytest
from shop.orders import load_promos
@pytest.fixture(scope="session")
def promo_file(tmp_path_factory):
# tmp_path_factory — session-версия tmp_path
path = tmp_path_factory.mktemp("data") / "promos.json"
path.write_text('{"WELCOME": 10, "SALE50": 50}', encoding="utf-8")
return path
@pytest.fixture
def promos(promo_file):
return load_promos(promo_file)Параметризованная фикстура прогоняет каждый зависящий от неё тест по разу на значение — так один набор проверяется против нескольких реализаций:
@pytest.fixture(params=["sqlite", "postgres"])
def db(request):
return create_db(request.param)
# test_query[sqlite] и test_query[postgres]Маркер — метка на тесте, по которой его можно выбрать или пропустить.
@pytest.mark.skip(reason="ждём решение по округлению, тикет #123")
def test_rounding_policy():
...
@pytest.mark.skipif(sys.platform == "win32", reason="пути POSIX")
def test_promo_path():
...
@pytest.mark.xfail(strict=True, reason="баг #456: налог применяется до скидки")
def test_tax_after_discount():
...
@pytest.mark.slow
def test_full_pipeline():
...xfail(strict=True) предпочтителен: если тест внезапно прошёл, набор сообщит об
этом. Нестрогий xfail тихо примет и провал, и успех — и вы не заметите, что
баг исправлен.
Собственные маркеры регистрируются, иначе pytest выдаёт предупреждение:
# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"slow: дольше секунды",
"integration: требует внешнюю систему",
]uv run pytest -m "not slow" # быстрый прогон
uv run pytest -m "integration" # только интеграционные
uv run pytest -m "slow and not integration"project/
├── shop/
│ ├── __init__.py
│ └── orders.py
├── tests/
│ ├── conftest.py # общие фикстуры
│ ├── unit/
│ │ └── test_orders.py
│ └── integration/
│ └── test_checkout.py
└── pyproject.toml
Имя теста — это отчёт о падении. Формат
test_{что}_{при каком условии}_{ожидаемый результат} избавляет от чтения тела
теста при разборе красного CI:
def test_order_total_with_unknown_promo_raises_value_error(): ...
def test_order_total_with_empty_items_returns_zero(): ...
def test_load_promos_with_broken_json_raises(): ...Когда объекты нужны в разных вариантах, фикстура возвращает не объект, а функцию-фабрику. Так тест сам задаёт отличия, а уборка остаётся общей:
@pytest.fixture
def order_factory():
def make(price=100.0, qty=1, promo=None):
return {"items": [(price, qty)], "promo": promo}
return make
def test_promo_reduces_total(order_factory, promos):
order = order_factory(promo="SALE50")
assert order_total(order["items"], order["promo"], promos) == pytest.approx(50.0)Один тест проверяет всё сразу. При падении на первом assert остальные
проверки не выполняются, и вы видите одну ошибку вместо трёх.
# Проблема
def test_everything(promos):
assert order_total([(100.0, 1)], None, promos) == 100.0
assert order_total([(100.0, 1)], "WELCOME", promos) == 90.0
assert order_total([], None, promos) == 0.0
# Как надо: одно поведение — один тест (или параметризация)
def test_total_without_promo(promos): ...
def test_total_with_promo(promos): ...
def test_total_of_empty_order(promos): ...Тесты зависят друг от друга. Общее состояние через модульную переменную
ломает выборочный запуск: pytest --lf прогонит только упавший тест, и тот
упадёт снова уже по другой причине.
# Проблема
_total = None
def test_calculate():
global _total
_total = order_total([(100.0, 1)], None, {})
def test_receipt():
assert format_receipt(_total) == "100.00" # зависит от предыдущего теста
# Как надо: подготовка в фикстуре, каждый тест самодостаточен
def test_receipt(promos):
total = order_total([(100.0, 1)], None, promos)
assert format_receipt(total) == "100.00"Тест повторяет реализацию. Если проверка дословно повторяет формулу из кода, она пройдёт даже при неверной формуле — и сломается при любом безобидном рефакторинге.
# Проблема: та же арифметика, что и в order_total
def test_total(promos):
expected = 100.0 * (1 - promos["WELCOME"] / 100)
assert order_total([(100.0, 1)], "WELCOME", promos) == expected
# Как надо: ожидаемое значение записано числом
def test_total(promos):
assert order_total([(100.0, 1)], "WELCOME", promos) == pytest.approx(90.0)time.sleep вместо управления временем. Такой тест медленный и всё равно
ненадёжный: на загруженной машине двух секунд не хватит.
# Проблема
def test_receipt_is_sent():
start_task()
time.sleep(2)
assert task_completed()Как выполнять код без настоящего ожидания и без обращения к внешним системам —
тема test_doubles.
unittest: что делать со старым кодомunittest входит в стандартную библиотеку, и в существующих проектах тесты
часто написаны на нём. Переписывать их ради стиля не нужно: pytest запускает
unittest.TestCase без изменений, а новые тесты можно писать функциями рядом.
unittest | pytest |
|---|---|
наследование от TestCase | функции test_* |
self.assertEqual(a, b) | assert a == b |
self.assertRaises(Exc) | pytest.raises(Exc) |
setUp / tearDown | фикстура с yield |
setUpClass / tearDownClass | фикстура со scope="class" |
@unittest.skip | @pytest.mark.skip |
python -m unittest discover | pytest |
uv run pytest tests/ # запустит и TestCase-классы, и функцииГлавное отличие на практике: в TestCase подготовка идёт через наследование, и
общий код собирается в базовые классы. Фикстуры вместо этого передаются по
имени, поэтому тест сам объявляет, что ему нужно.
Каждый тест должен отличать правильное поведение от конкретной старой ошибки. Результат основы — короткий набор проверок счастливого пути, границ и ожидаемой ошибки, который падает на намеренно испорченной реализации.
Ниже — проба без pytest: те же проверки, написанные вручную. Она показывает,
что именно даёт фреймворк — таблицу случаев, разбор assert и изоляцию.
def parse_port(raw: str) -> int:
port = int(raw)
if not 1024 <= port <= 65535:
raise ValueError("порт вне допустимого диапазона")
return port
for raw, expected in [("1024", 1024), ("8080", 8080), ("65535", 65535)]:
assert parse_port(raw) == expected
for raw in ("1023", "65536", "not-a-number"):
try:
parse_port(raw)
except ValueError:
pass
else:
raise AssertionError(f"для {raw!r} ожидалась ошибка")Перенесите обе таблицы в @pytest.mark.parametrize с осмысленными id,
замените ручной try/except на pytest.raises(..., match=...) и добавьте
фикстуру временного каталога для файла настроек. Проверяйте наблюдаемое
поведение, а не внутренний порядок вызовов.
Испортите реализацию намеренно — замените 1024 <= port на 1024 < port — и
убедитесь, что набор краснеет. Если ни один тест не заметил правку, граница не
покрыта. Затем объясните выбор области видимости каждой фикстуры: что именно
сломается, если поднять её до session.
BankAccount с методами deposit, withdraw, balance: нормальный сценарий, овердрафт, отрицательный депозит.is_palindrome через @pytest.mark.parametrize на десяти случаях: пустая строка, один символ, регистр, пробелы, кириллица, цифры.tmp_path и подменяет переменную окружения через monkeypatch, и убедитесь, что после теста окружение не изменилось.pytest.param(..., id=...) так, чтобы имена случаев читались в отчёте.promo_file со scope="session" через tmp_path_factory и объясните, какая ошибка появится, если сделать её изменяемой.@pytest.mark.xfail без strict=True, добавьте strict=True и объясните, что изменится в отчёте после исправления бага.id; заранее определите политику
регистра, пробелов и Unicode.monkeypatch проверьте исходное окружение уже в отдельном тесте.xfail должен дать XPASS(strict) и завершить тест
ошибкой, сигнализируя, что метку пора удалить.Закрепите тему в работе «Проверка повторов через внесение сбоев». Сценарный тестовый двойник должен доказать точное число попыток, задержки, распространение неожиданных ошибок и отсутствие настоящего ожидания.
git clone --branch v0.6.2 --depth 1 https://gitlab.potapov.me/courses/python-labs.git
cd python-labs
uv sync --group test
uv run --group test pytest python_testing/testsДалее: Итераторы и генераторы