Несколько тестов в файле: независимость и порядок
Один файл — три теста — одно падение: упавший тест не отменяет соседей. Плюс порядок сверху вниз, помощники вместо копипасты и первый рефакторинг под защитой набора.
Редакция Питоники
Один тест на функцию — разминка. В настоящем проекте тестов десятки, и живёт куча из них в одном файле. Отсюда три практических вопроса, на которые отвечает этот урок: что происходит с отчётом, когда падает один из нескольких; в каком порядке pytest выполняет тесты; и куда девать подготовку данных, которая повторяется в каждом тесте. Заодно сделаем первый рефакторинг — и увидим, зачем тесты называют страховочным тросом.
Три теста: упал один — остальные живут
Положим в один файл тесты корзины: два на добавление товаров и один на сумму — с намеренно завышенным ожиданием, чтобы он упал. Порядок выбран не случайно: падающий стоит посередине.
from pathlib import Path
Path("test_cart.py").write_text("""def add_item(cart, item):
cart.append(item)
return cart
def test_add_one():
assert add_item([], "хлеб") == ["хлеб"]
def test_total():
prices = [100, 150]
assert sum(prices) == 240
def test_add_two():
assert add_item(["хлеб"], "молоко") == ["хлеб", "молоко"]
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_cart.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.F. [100%] =========================== short test summary info =========================== FAILED test_cart.py::test_total - assert 250 == 240 1 failed, 2 passed in 0.01s exit: 1
Строка результатов — .F.: первый тест прошёл, второй упал, третий прошёл. Вот главное свойство набора: pytest не останавливается на первом падении. test_total рухнул — и test_add_two всё равно выполнен и отмечен точкой. Сводка считает честно: 1 failed, 2 passed, код выхода 1 — набор в целом красный, но масштаб поломки виден точно: один тест из трёх.
Сравни с print-отладкой: там исключение в середине скрипта убивает всё, что ниже, и ты не узнаешь, работает ли остальной код. Набор тестов переживает аварии по построению — каждый тест в собственном контексте, исключение ловится фреймворком и превращается в букву отчёта.
Порядок: сверху вниз, но не как костыль
Порядок выполнения pytest повторяет порядок объявления: тесты идут сверху вниз, в том виде, как ты их записал. На это можно положиться при чтении отчёта — третья точка соответствует третьей функции. Проверим порядок коллекцией: режим из урока 2 печатает тесты ровно в том порядке, в котором фреймворк будет их выполнять.
from pathlib import Path
Path("test_orders.py").write_text("""def test_cart_not_empty():
assert 1 + 1 == 2
def test_prices():
assert 2 + 2 == 4
def test_names():
assert 3 + 3 == 6
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_orders.py", "--collect-only", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
test_orders.py::test_cart_not_empty test_orders.py::test_prices test_orders.py::test_names 3 tests collected in 0.01s exit: 0
Список собранных тестов совпадает с порядком функций в файле — от test_cart_not_empty до test_names. Тот же маршрут пройдёт полный прогон. Дома то же самое показывает обычный запуск из папки проекта:
pytest -q
... [100%] 3 passed in 0.01s
| Признак | Независимый тест | Зависимый тест |
|---|---|---|
| Данные | готовит сам, при каждом запуске | рассчитывает на чужой прогон |
| Запуск по одному | зелёный даже в одиночку | падает без соседа |
| Перестановка мест | результат не меняется | ломается очерёдность |
CART = [] # общий список на уровне модуля
def test_add_item():
CART.append("хлеб")
assert "хлеб" in CART
def test_cart_not_empty():
assert len(CART) > 0 # живёт только после первого теста
Общий код — в функцию-помощник
Подготовка повторяется: почти каждый тест корзины начинает со сбора корзины с товарами. Копипастить её в каждый тест — путь к болям из пятого урока, пока же вынесем сбор в обычную функцию-помощник без префикса test_ — коллекция её не тронет, а тесты вызовут каждую для себя.
from pathlib import Path
Path("test_orders.py").write_text("""def make_cart():
return [
{"name": "хлеб", "price": 45},
{"name": "молоко", "price": 95},
]
def test_cart_not_empty():
assert len(make_cart()) == 2
def test_prices():
assert [p["price"] for p in make_cart()] == [45, 95]
def test_names():
assert [p["name"] for p in make_cart()] == ["хлеб", "молоко"]
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_orders.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
... [100%] 3 passed in 0.01s exit: 0
Три точки, 3 passed. Смотри, что изменилось по сути: состав корзины описан один раз, а каждый из трёх тестов вызвал make_cart() и получил собственный свежий список. Помощник не считает тестами — в отчёте ровно три позиции, по числу тестовых функций. Захочешь добавить четвёртый товар — правка в одном месте, и тесты либо подтвердят совместимость, либо честно упадут.
Помощники могут принимать аргументы — тогда один и тот же каркас обслуживает разные случаи: тест называет свой состав корзины, а сборку делает общая функция.
def make_cart(*items):
return list(items)
def test_single_item():
assert make_cart("хлеб") == ["хлеб"]
def test_two_items():
assert make_cart("хлеб", "молоко") == ["хлеб", "молоко"]
Рефакторинг под защитой тестов
Вот ради чего набор и заводят. Допустим, в коде посчиталась сумма циклом — рабочий, но многословный вариант. Ты переписываешь её на встроенный sum — короче, яснее, быстрее. Вопрос один: поведение изменилось? Ответ дают тесты — их не трогаем ни на строчку.
# БЫЛО: сумма циклом
def total(prices):
result = 0
for p in prices:
result = result + p
return result
# СТАЛО: то же самое одним вызовом
def total(prices):
return sum(prices)
from pathlib import Path
Path("test_total.py").write_text("""def total(prices):
result = 0
for p in prices:
result = result + p
return result
def test_total_empty():
assert total([]) == 0
def test_total_several():
assert total([45, 95]) == 140
def test_total_single():
assert total([500]) == 500
""", encoding="utf-8")
import pytest
rc = pytest.main(["test_total.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("старая реализация:", int(rc))
Path("test_total.py").write_text("""def total(prices):
return sum(prices)
def test_total_empty():
assert total([]) == 0
def test_total_several():
assert total([45, 95]) == 140
def test_total_single():
assert total([500]) == 500
""", encoding="utf-8")
rc = pytest.main(["test_total.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("новая реализация:", int(rc))
... [100%] 3 passed in 0.01s старая реализация: 0 ... [100%] 3 passed in 0.00s новая реализация: 0
Оба прогона зелёные — 3 passed в каждом, и тесты между прогонами не изменились ни буквой. Вот что значит «тесты как страховка»: смелый рефакторинг перестаёт быть прыжком в темноте, потому что набор за секунды отвечает, сломал ты что-то или нет. Именно поэтому тесты пишут до рефакторинга, а не после: страховка нужна до высоты, а не после падения.
Что дальше
Набор вырос до нескольких тестов и пережил первую аварию: упавший сосед не потащил остальных, порядок читается сверху вниз, общая подготовка переехала в помощник, а рефакторинг прошёл под зелёным отчётом. Следующая ступень — фикстуры: помощник данных со штатным механизмом pytest, который умеет ещё многое — от передачи в любой тест до уборки за собой, о которой пойдёт речь в шестом уроке.
Зелёный отчёт после переписанного кода — это и есть страховка: тесты не тронули, а поведение подтвердилось.
Сначала предскажи ответ в голове — это главный навык программиста.
results = [".", "F", "."]
passed = results.count(".")
failed = results.count("F")
print(str(failed) + " failed, " + str(passed) + " passed")
def make_cart():
return ["хлеб"]
first = make_cart()
first.append("молоко")
second = make_cart()
print(len(second))
def total(prices):
return sum(prices)
assert total([]) == 0
assert total([45, 95]) == 140
print("рефакторинг прошёл")
1. В файле три теста, второй падает. Что выполнится?
2. В каком порядке pytest выполняет тесты одного файла?
3. Почему общую подготовку данных выносят в функцию-помощника?
4. Тесты написаны, реализацию функции переписали, тесты не тронули, отчёт зелёный. О чём это говорит?
5. Чем плох общий мутируемый список на уровне модуля для тестов?
6. Что означает сводка «1 failed, 2 passed»?
Собери файл test_pricing.py с помощником make_order(), возвращающим словарь {"items": ["кофе", "сироп"], "paid": False}, и функцией mark_paid(order), которая ставит paid в True. Напиши три теста: на состав items, на то что новый заказ не оплачен, и на то что mark_paid меняет флаг. Запусти набор и напечатай код выхода.
Останавливается ли pytest на первом упавшем тесте?
Нет: по умолчанию выполняются все собранные тесты, и падение одного не мешает остальным. Исключение ловится фреймворком и превращается в букву F в строке результатов. В сводке видно точную картину — например, 1 failed, 2 passed, а код выхода 1 помечает набор красным. Полный прогон без остановок — то, что делает набор пригодным для регулярных запусков.
В каком порядке pytest запускает тесты в файле?
В порядке объявления — сверху вниз. Этим удобно пользоваться при чтении отчёта: позиции букв совпадают с порядком функций. Строить на порядке логику нельзя: тест, которому нужен результат предыдущего, — зависимый, он падает при точечном запуске и перестановке. Каждый тест готовит данные сам или через помощник.
Куда девать общую подготовку данных для нескольких тестов?
В функцию-помощник без префикса test_: она описывает данные один раз, а каждый тест вызывает её и получает свежий экземпляр. Это дешевле копипасты и безопаснее общих переменных на уровне модуля. Штатный механизм pytest для той же задачи — фикстуры: тест объявляет аргумент, а фреймворк сам вызывает подготовку и передаёт результат.
Можно ли рефакторить код, не переписывая тесты?
Именно для этого тесты и существуют: меняешь реализацию, оставляя контракты прежними, и запускаешь неизменный набор. Зелёный отчёт — доказательство, что внешнее поведение сохранилось; красный — точное указание, что сломалось. Правило гигиены: рефакторинг кода и правки тестов не делают одним коммитом, иначе страховка перестаёт что-либо страховать.
Понравился урок? Сошлитесь на него
«Зелёный отчёт после переписанного кода — это и есть страховка: тесты не тронули, а поведение подтвердилось.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 3
Отчёт pytest: подробный вывод -v и разбор падения
Зелёная точка — скучный отчёт, и это хорошо. Настоящая сила pytest раскрывается при падении: он показывает строку, ожидание, реальность и разницу между ними.
pytest · Урок 2
Как pytest находит тесты: конвенции имён файлов и функций
pytest не читает мысли: тестом становится только то, что названо по конвенции — test_*.py, test_-функции, классы Test*. И адрес одного теста: файл::тест.
pytest · Урок 5
Фикстуры pytest: @pytest.fixture для подготовки данных
Одна и та же корзина собирается в каждом тесте заново — знакомая копипаста. Фикстура описывает подготовку один раз, а pytest сам передаёт результат в тест по имени.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
flask тестирование pytest test_clientтестирование flask api pytest
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
pytest flask фикстуры conftestpytest fixture test_client
pytest · Урок 8
Параметризация: @pytest.mark.parametrize
Десять одинаковых тестов для десяти случаев — это десять копипаст. parametrize сжимает их в один тест и список кортежей, а pytest разворачивает обратно в десять отчётных строк.
pytest параметризацияpytest parametrize