Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 4 из 20 Начальный 30 мин 100 XP

Несколько тестов в файле: независимость и порядок

Один файл — три теста — одно падение: упавший тест не отменяет соседей. Плюс порядок сверху вниз, помощники вместо копипасты и первый рефакторинг под защитой набора.

Редакция Питоники

Один тест на функцию — разминка. В настоящем проекте тестов десятки, и живёт куча из них в одном файле. Отсюда три практических вопроса, на которые отвечает этот урок: что происходит с отчётом, когда падает один из нескольких; в каком порядке 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. Тот же маршрут пройдёт полный прогон. Дома то же самое показывает обычный запуск из папки проекта:

терминал, не Python
pytest -q
Вывод
...                                                                      [100%]
3 passed in 0.01s
Команда терминала, поэтому блок не запускается в браузере: pytest без аргументов собирает тесты текущей папки. Три точки — три теста в порядке объявления.
ПризнакНезависимый тестЗависимый тест
Данныеготовит сам, при каждом запускерассчитывает на чужой прогон
Запуск по одномузелёный даже в одиночкупадает без соседа
Перестановка местрезультат не меняетсяломается очерёдность
так не надо: тест на чужих данных
CART = []          # общий список на уровне модуля

def test_add_item():
    CART.append("хлеб")
    assert "хлеб" in CART

def test_cart_not_empty():
    assert len(CART) > 0      # живёт только после первого теста
Фрагмент без запуска. В порядке объявления набор зелёный, но test_cart_not_empty в одиночку упадёт: список пуст, ведь его наполняет сосед. Это и есть зависимый тест.

Общий код — в функцию-помощник

Подготовка повторяется: почти каждый тест корзины начинает со сбора корзины с товарами. Копипастить её в каждый тест — путь к болям из пятого урока, пока же вынесем сбор в обычную функцию-помощник без префикса 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("хлеб", "молоко") == ["хлеб", "молоко"]
Фрагмент без запуска: звёздочка собирает аргументы в кортеж, list(items) превращает его в список. Каждый тест получает собственную корзину своего состава.

Рефакторинг под защитой тестов

Вот ради чего набор и заводят. Допустим, в коде посчиталась сумма циклом — рабочий, но многословный вариант. Ты переписываешь её на встроенный 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("рефакторинг прошёл")
Проверь себя
0 / 6

1. В файле три теста, второй падает. Что выполнится?

2. В каком порядке pytest выполняет тесты одного файла?

3. Почему общую подготовку данных выносят в функцию-помощника?

4. Тесты написаны, реализацию функции переписали, тесты не тронули, отчёт зелёный. О чём это говорит?

5. Чем плох общий мутируемый список на уровне модуля для тестов?

6. Что означает сводка «1 failed, 2 passed»?

Карточки терминов
Запомнено: 0 / 6
Практика

Собери файл test_pricing.py с помощником make_order(), возвращающим словарь {"items": ["кофе", "сироп"], "paid": False}, и функцией mark_paid(order), которая ставит paid в True. Напиши три теста: на состав items, на то что новый заказ не оплачен, и на то что mark_paid меняет флаг. Запусти набор и напечатай код выхода.

practice.py
Вопросы и ответы по уроку

Останавливается ли pytest на первом упавшем тесте?

Нет: по умолчанию выполняются все собранные тесты, и падение одного не мешает остальным. Исключение ловится фреймворком и превращается в букву F в строке результатов. В сводке видно точную картину — например, 1 failed, 2 passed, а код выхода 1 помечает набор красным. Полный прогон без остановок — то, что делает набор пригодным для регулярных запусков.

В каком порядке pytest запускает тесты в файле?

В порядке объявления — сверху вниз. Этим удобно пользоваться при чтении отчёта: позиции букв совпадают с порядком функций. Строить на порядке логику нельзя: тест, которому нужен результат предыдущего, — зависимый, он падает при точечном запуске и перестановке. Каждый тест готовит данные сам или через помощник.

Куда девать общую подготовку данных для нескольких тестов?

В функцию-помощник без префикса test_: она описывает данные один раз, а каждый тест вызывает её и получает свежий экземпляр. Это дешевле копипасты и безопаснее общих переменных на уровне модуля. Штатный механизм pytest для той же задачи — фикстуры: тест объявляет аргумент, а фреймворк сам вызывает подготовку и передаёт результат.

Можно ли рефакторить код, не переписывая тесты?

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

Понравился урок? Сошлитесь на него

«Зелёный отчёт после переписанного кода — это и есть страховка: тесты не тронули, а поведение подтвердилось.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.