yield-фикстуры: уборка после теста (setup и teardown)
Тест создал файл — и кто-то должен убрать за ним. yield-фикстура держит подготовку и уборку в одной функции, а уборку выполняет всегда, даже когда тест упал.
Редакция Питоники
Пятый урок научил готовить данные фикстурами, но остался открытым хвост: тесты не только потребляют, но и наследуют. Тест проверил запись отчёта — файл отчёта существует. Тест открыл соединение — соединение держится. Оставить это после себя — значит завалить песочницу мусором, а в настоящем проекте — исчерпать лимиты и затирать чужие файлы. Уборка — такая же часть теста, как и подготовка, и pytest даёт ей штатное место: код фикстуры после yield.
Сначала посмотрим, как уборку пишут без фикстур, чтобы понимать, от чего мы уходим. Классический ручной вариант — оператор try/finally: подготовка, проверка, а в 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)
Работает — и уже на одном тесте видно накладные расходы: три строки механики уборки на одну строку проверки. Пять тестов с файлами — пять 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) | сразу после теста — прошёл он или упал |
@pytest.fixture
def config():
# ---- подготовка (setup) ----
data = load_config() # что нужно тесту
yield data # передача: тест получил data
# ---- уборка (teardown) ----
data.close() # что нужно прибрать после теста
Падение теста не отменяет уборки
Главный вопрос к любой уборке: а если тест упал? Файл-то создавался. Ответ 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 — и её пришлось бы писать в каждом тесте.
@pytest.fixture
def report():
p = Path("report.txt")
p.write_text("итог", encoding="utf-8")
return p
os.remove(p) # сюда выполнение никогда не доходит
Несколько фикстур в одном тесте
Тесту бывает нужно несколько вещей сразу: и данные, и файл, и подготовленный итог. Перечисли фикстуры аргументами через запятую — 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 - выполнится ПЕРВОЙ
Обратный порядок удобнее всего увидеть на файлах: две фикстуры, каждая со своим файлом и уборкой после 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))
1. Что в yield-фикстуре считается уборкой (teardown)?
2. Тест с yield-фикстурой упал на assert. Выполнится ли уборка?
3. Сколько yield допускается в одной фикстуре?
4. Как тест принимает две фикстуры сразу?
5. В каком порядке выполняется teardown у нескольких фикстур одного теста?
6. Почему try/finally в каждом тесте хуже yield-фикстуры?
Напиши yield-фикстуру notes, которая создаёт файл notes.txt со строкой «шаг 1 готов» и удаляет его после теста. Тест test_notes_text проверяет содержимое файла. После прогона pytest.main напечатай, существует ли файл, и код выхода — файл обязан исчезнуть.
Что такое 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-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 5
Фикстуры pytest: @pytest.fixture для подготовки данных
Одна и та же корзина собирается в каждом тесте заново — знакомая копипаста. Фикстура описывает подготовку один раз, а pytest сам передаёт результат в тест по имени.
pytest · Урок 7
Область видимости фикстур: scope и autouse
Сколько раз создаётся фикстура? По умолчанию — для каждого теста, но scope="module" экономит до одного раза на файл, а autouse применяет её вовсе без указания в аргументах.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
pytest flask фикстуры conftestфикстура test_client flask
sqlite3 · Урок 8
UPDATE и DELETE в SQLite: меняем данные безопасно
Два самых ответственных оператора SQL: UPDATE и DELETE. Учимся менять и удалять строки так, чтобы не задеть чужие, — с rowcount как страховкой и мягким удалением.
удалить строку sqlupdate set несколько полей
pytest · Урок 4
Несколько тестов в файле: независимость и порядок
Один файл — три теста — одно падение: упавший тест не отменяет соседей. Плюс порядок сверху вниз, помощники вместо копипасты и первый рефакторинг под защитой набора.
pytest несколько тестов файлpytest независимость тестов