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

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

Начать обучение
Урок 6 из 20 Средний 35 мин 130 XP

yield-фикстуры: уборка после теста (setup и teardown)

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

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

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

Сначала посмотрим, как уборку пишут без фикстур, чтобы понимать, от чего мы уходим. Классический ручной вариант — оператор try/finally: подготовка, проверка, а в finally — удаление.

уборка руками: try/finally в тесте
def test_report():
    p = Path("report.txt")
    p.write_text("итог: ок", encoding="utf-8")
    try:
        assert p.read_text(encoding="utf-8") == "итог: ок"
    finally:
        os.remove(p)
Фрагмент без запуска. finally выполняется даже при падении assert — но такой блок придётся повторить в каждом тесте с файлами, а порядок подготовки и уборки растянется по всему набору.

Работает — и уже на одном тесте видно накладные расходы: три строки механики уборки на одну строку проверки. Пять тестов с файлами — пять try/finally. Выносим всё в фикстуру с yield.

yield: подготовка и уборка в одной функции

Правило простое: всё, что до yield, — подготовка, всё, что после, — уборка. Сам yield отдаёт подготовленное значение тесту — так же, как return в обычной фикстуре. Смотрим на полный пример: фикстура создаёт файл отчёта, отдаёт его тесту, а после теста удаляет. После прогона печатаем честную проверку — существует ли файл.

фикстура создаёт файл и убирает его
from pathlib import Path
import os

Path("test_report.py").write_text("""import os
from pathlib import Path

import pytest

@pytest.fixture
def report():
    p = Path("report.txt")
    p.write_text("итог: ок", encoding="utf-8")
    yield p
    os.remove(p)

def test_report_content(report):
    assert report.read_text(encoding="utf-8") == "итог: ок"
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_report.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("Файл после прогона:", os.path.exists("report.txt"))
print("exit:", int(rc))
Вывод
.                                                                        [100%]
1 passed in 0.01s
Файл после прогона: False
exit: 0

Три события в отчёте: точка — тест прошёл, Файл после прогона: False — уборка сработала, exit: 0 — набор зелёный. Читаем фикстуру по фазам. p.write_text(...) — подготовка: файл создан до теста. yield p — передача: тест получил путь через аргумент report. os.remove(p) — уборка: файл удалён после теста. Подготовка и уборка живут в одной функции, друг под другом, и читать это можно как историю.

Строка фикстурыФазаКогда выполняется
p.write_text(...)подготовка (setup)перед тестом, который принял фикстуру
yield pпередача значенияв этот момент стартует сам тест
os.remove(p)уборка (teardown)сразу после теста — прошёл он или упал
скелет любой yield-фикстуры
@pytest.fixture
def config():
    # ---- подготовка (setup) ----
    data = load_config()          # что нужно тесту
    yield data                    # передача: тест получил data
    # ---- уборка (teardown) ----
    data.close()                  # что нужно прибрать после теста
Фрагмент без запуска: load_config — условная функция проекта. Каркас один и тот же для файла, соединения и сервера: создал — отдал — прибрал.

Падение теста не отменяет уборки

Главный вопрос к любой уборке: а если тест упал? Файл-то создавался. Ответ pytest: выполняется всегда — падение теста не отменяет teardown, потому что уборка — часть механизма фикстур, а не часть теста. Проверим на жестоком сценарии: тест с заведомо неверным ожиданием, файл после прогона смотрим тем же способом.

тест упал, а файл всё равно удалён
from pathlib import Path
import os

Path("test_rep.py").write_text("""import os
from pathlib import Path

import pytest

@pytest.fixture
def report():
    p = Path("report.txt")
    p.write_text("итог", encoding="utf-8")
    yield p
    os.remove(p)

def test_text(report):
    assert report.read_text(encoding="utf-8") == "план"
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_rep.py", "-q", "--no-header", "--tb=no", "-p", "no:cacheprovider"])
print("Файл после прогона:", os.path.exists("report.txt"))
print("exit:", int(rc))
Вывод
F                                                                        [100%]
=========================== short test summary info ===========================
FAILED test_rep.py::test_text - AssertionError: assert 'итог' == 'план'
1 failed in 0.02s
Файл после прогона: False
exit: 1

Тест упал — в отчёте F, строка FAILED и код выхода 1. И на этом фоне — Файл после прогона: False: уборка выполнена, хотя проверка сорвалась. Вот чем yield-фикстура отличается от кода после теста в самом тесте: при падении assert выполнение теста прерывается, а механизм фикстур pytest доводит уборку до конца. Вручную такой гарантией обладает только try/finally — и её пришлось бы писать в каждом тесте.

return вместо yield: уборка становится мёртвым кодом
@pytest.fixture
def report():
    p = Path("report.txt")
    p.write_text("итог", encoding="utf-8")
    return p
    os.remove(p)      # сюда выполнение никогда не доходит
Фрагмент без запуска: return завершает функцию, и строка после него недостижима — файл останется на диске после прогона. Хочешь код после передачи значения — используй yield, а не return.

Несколько фикстур в одном тесте

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

две фикстуры, одна из них на другой
from pathlib import Path

Path("test_bill.py").write_text("""import pytest

@pytest.fixture
def prices():
    return [100, 250, 40]

@pytest.fixture
def total(prices):
    return sum(prices)

def test_total_value(total):
    assert total == 390

def test_expensive(total, prices):
    assert max(prices) < total
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_bill.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
Вывод
..                                                                       [100%]
2 passed in 0.01s
exit: 0

Две точки, 2 passed. Смотри на второй тест: он принимает сразу total и prices, перечисленные через запятую. А фикстура total сама приняла prices — та самая цепочка из пятого урока, теперь в деле: pytest сначала собрал цены, потом посчитал по ним итог, потом выполнил тесты, а teardown прошёл бы в обратном порядке — сначала за total, потом за prices. Добавь в эти фикстуры код после yield — и он встанет ровно на свои места в уборке.

Обратный порядок — не прихоть, а защита: уборка не должна ломать то, чем ещё пользуется соседняя фикстура. Файл закрывается до того, как удалён; соединение закрывается до того, как разобрана конфигурация.

стопка тарелок: кто за кем убирает
@pytest.fixture
def outer():
    ...               # подготовка outer
    yield "внешняя"
    ...               # уборка outer - выполнится ВТОРОЙ

@pytest.fixture
def inner(outer):
    ...               # подготовка inner - после outer
    yield "внутренняя"
    ...               # уборка inner - выполнится ПЕРВОЙ
Фрагмент без запуска: многоточия — место для настоящего кода подготовки и уборки. Порядок teardown всегда обратен порядку setup.

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

два файла — и оба убраны
from pathlib import Path
import os

Path("test_pages.py").write_text("""import os
from pathlib import Path

import pytest

@pytest.fixture
def a_file():
    p = Path("a.txt")
    p.write_text("A", encoding="utf-8")
    yield p
    os.remove(p)

@pytest.fixture
def b_file():
    p = Path("b.txt")
    p.write_text("B", encoding="utf-8")
    yield p
    os.remove(p)

def test_two_files(a_file, b_file):
    assert a_file.read_text(encoding="utf-8") + b_file.read_text(encoding="utf-8") == "AB"
""", encoding="utf-8")

import pytest

rc = pytest.main(["test_pages.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print(os.path.exists("a.txt"), os.path.exists("b.txt"))
print("exit:", int(rc))
Вывод
.                                                                        [100%]
1 passed in 0.01s
False False
exit: 0

Строка False False — не осталось ни a.txt, ни b.txt: обе фикстуры дочистили за тестом, хотя тест принял их одновременно. Прогон остаётся чистым, сколько бы файлов ни создал набор, — ровно этого и ждут от teardown.

Что дальше

Подготовка и уборка живут в одной yield-фикстуре, уборка гарантирована даже при падении теста, а несколько фикстур собираются в цепочки. Пара последних вопросов осталась за кадром: сколько раз фикстура вправе создаваться и как сделать так, чтобы она применялась к тестам без указания в аргументах. Ответы — в седьмом уроке: scope="module" и autouse. А после фикстур нас ждёт параметризация — один тест на десяток случаев.

yield-фикстура — это история в трёх строках: создал, отдал, убрал. И третья строка выполняется всегда.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

steps = []

def fixture_log():
    steps.append("setup")
    yield "значение"
    steps.append("teardown")

for value in fixture_log():
    print(value)

print(" -> ".join(steps))
created = []

def stage(name):
    created.append(name)
    return name

a = stage("первым")
b = stage("вторым")
for x in [b, a]:
    created.remove(x)
print(created)
log = []

def run_test(fn):
    log.append("setup")
    try:
        fn()
    finally:
        log.append("teardown")

def test_broken():
    raise ValueError

try:
    run_test(test_broken)
except ValueError:
    pass
print(" -> ".join(log))
Проверь себя
0 / 6

1. Что в yield-фикстуре считается уборкой (teardown)?

2. Тест с yield-фикстурой упал на assert. Выполнится ли уборка?

3. Сколько yield допускается в одной фикстуре?

4. Как тест принимает две фикстуры сразу?

5. В каком порядке выполняется teardown у нескольких фикстур одного теста?

6. Почему try/finally в каждом тесте хуже yield-фикстуры?

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

Напиши yield-фикстуру notes, которая создаёт файл notes.txt со строкой «шаг 1 готов» и удаляет его после теста. Тест test_notes_text проверяет содержимое файла. После прогона pytest.main напечатай, существует ли файл, и код выхода — файл обязан исчезнуть.

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

Что такое yield-фикстура в pytest?

Это фикстура, где вместо return стоит yield: код до yield готовит окружение теста (setup), значение на строке yield передаётся в тест как аргумент, а код после yield — уборка (teardown). Подготовка и уборка живут в одной функции друг под другом, и читать её можно как последовательность событий.

Выполняется ли teardown, если тест упал?

Да, всегда. Уборка — часть механизма фикстур pytest, а не код теста: фреймворк выполняет её независимо от исхода теста. Это главная причина предпочесть yield-фикстуру коду уборки внутри теста: при падении assert тест прерывается, а фикстура всё равно дочищает файлы и соединения. Проверить легко: после прогона файла, где фикстура удаляет файл, os.path.exists вернёт False.

Почему pytest запрещает два yield в фикстуре?

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

Как собрать несколько фикстур в одном тесте и что происходит с их уборкой?

Перечисли фикстуры аргументами через запятую: def test_expensive(total, prices). pytest подготовит каждую, при этом фикстуры могут принимать друг друга, образуя цепочку. Teardown выполняется в порядке, обратном созданию: последняя подготовленная фикстура убирается первой. Это защищает от ошибок вроде удаления файла, который ещё нужен другой фикстуре.

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

«yield-фикстура — это история в трёх строках: создал, отдал, убрал. И третья строка выполняется всегда.»

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

TelegramVK

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

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