Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Тестирование: pytest и фикстуры
python_testing

Тестирование: pytest и фикстуры

pytest, проверка ошибок и границ, параметризация, фикстуры, маркеры, организация набора

Открыть лабораториюv0.6.2Запускается локально из публичного репозитория

Тестирование в Python: 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 или целые копейки — здесь же неточность нужна, чтобы разобрать её в тестах.

#1. Что делает тест полезным

Тест фиксирует наблюдаемое поведение и помогает заметить регрессию после изменения кода. Он не доказывает отсутствие ошибок. Хороший набор проверяет важные сценарии, границы и сбои, оставаясь достаточно быстрым и понятным для регулярного запуска.

Удобная структура одного теста — Arrange, Act, Assert:

  1. подготовить данные и зависимости;
  2. выполнить одно проверяемое действие;
  3. проверить результат или наблюдаемый побочный эффект.
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

Тесты должны быть независимыми: порядок запуска, состояние соседнего теста и реальные часы или сеть не должны менять результат. Независимость — не формальность: именно она позволяет запускать набор параллельно, повторять отдельный упавший тест и доверять красному результату.

#2. Первый тест: 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.0
uv 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. Это не ошибка расчёта, а свойство типа — и повод для следующего раздела.

#3. Ожидаемые ошибки и приблизительные значения

Двоичное представление 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})

#4. Параметризация: таблица случаев вместо копий

Четыре почти одинаковых теста на разные скидки — это четыре места, где придётся править контракт. @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): внутри теста они перекроют встроенное имя, и следующая правка теста упрётся в неочевидную ошибку.

#5. Встроенные фикстуры: 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().out

#6. Свои фикстуры: yield, область видимости, 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]

#7. Маркеры и выбор подмножества

Маркер — метка на тесте, по которой его можно выбрать или пропустить.

@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"

#8. Организация набора

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)

#9. Антипаттерны

Один тест проверяет всё сразу. При падении на первом 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.

#10. unittest: что делать со старым кодом

unittest входит в стандартную библиотеку, и в существующих проектах тесты часто написаны на нём. Переписывать их ради стиля не нужно: pytest запускает unittest.TestCase без изменений, а новые тесты можно писать функциями рядом.

unittestpytest
наследование от 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 discoverpytest
uv run pytest tests/ # запустит и TestCase-классы, и функции

Главное отличие на практике: в TestCase подготовка идёт через наследование, и общий код собирается в базовые классы. Фикстуры вместо этого передаются по имени, поэтому тест сам объявляет, что ему нужно.

#Практика: от пробы к рабочему решению

#Шаг 1. Соберите минимальный пример

Каждый тест должен отличать правильное поведение от конкретной старой ошибки. Результат основы — короткий набор проверок счастливого пути, границ и ожидаемой ошибки, который падает на намеренно испорченной реализации.

#Шаг 2. Проверьте поведение

Ниже — проба без 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} ожидалась ошибка")

#Шаг 3. Доведите решение до рабочего сценария

Перенесите обе таблицы в @pytest.mark.parametrize с осмысленными id, замените ручной try/except на pytest.raises(..., match=...) и добавьте фикстуру временного каталога для файла настроек. Проверяйте наблюдаемое поведение, а не внутренний порядок вызовов.

#Шаг 4. Объясните внутренний механизм

Испортите реализацию намеренно — замените 1024 <= port на 1024 < port — и убедитесь, что набор краснеет. Если ни один тест не заметил правку, граница не покрыта. Затем объясните выбор области видимости каждой фикстуры: что именно сломается, если поднять её до session.

#Упражнения

  1. Напишите тесты для BankAccount с методами deposit, withdraw, balance: нормальный сценарий, овердрафт, отрицательный депозит.
  2. Проверьте is_palindrome через @pytest.mark.parametrize на десяти случаях: пустая строка, один символ, регистр, пробелы, кириллица, цифры.
  3. Напишите тест, который читает настройки из файла в tmp_path и подменяет переменную окружения через monkeypatch, и убедитесь, что после теста окружение не изменилось.
  4. Замените набор из пяти почти одинаковых тестов одной параметризацией с pytest.param(..., id=...) так, чтобы имена случаев читались в отчёте.
  5. Напишите фикстуру promo_file со scope="session" через tmp_path_factory и объясните, какая ошибка появится, если сделать её изменяемой.
  6. Возьмите тест с @pytest.mark.xfail без strict=True, добавьте strict=True и объясните, что изменится в отчёте после исправления бага.

#Самопроверка

  • Каждый тест должен стать красным после одной намеренной поломки проверяемого поведения; тест, который остаётся зелёным, не доказывает контракт.
  • Для счёта проверьте нулевую сумму, граничный баланс и то, что неуспешная операция не меняет состояние.
  • Параметры palindrome получают читаемые id; заранее определите политику регистра, пробелов и Unicode.
  • После monkeypatch проверьте исходное окружение уже в отдельном тесте.
  • Session-фикстура не должна отдавать общий изменяемый объект без сброса.
  • Исправленный строгий 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

Далее: Итераторы и генераторы