Встроенные фикстуры: capsys и tmp_path
Половину бытовых задач тестирования закрывают две фикстуры из коробки: capsys читает то, что функция напечатала, tmp_path выдаёт чистую временную папку — без декораторов и настроек.
Редакция Питоники
В уроке 5 мы писали фикстуры сами: декоратор, функция-подготовка, аргумент у теста. Но самые нужные фикстуры уже встроены в pytest — их не объявляют, не импортируют и не настраивают: написал имя в аргументах теста, и pytest всё сделал. Сегодня разберём две самые ходовые: capsys для перехвата print-вывода и tmp_path для временных файлов.
Почему именно эти две? Потому что вокруг них живёт половина бытовых проверок: функция печатает чек — как убедиться, что печатает правильное? Функция пишет отчёт в файл — как проверить файл, не завалив проект мусором и не устроив гонку между тестами? Обе задачи решаются одним аргументом в сигнатуре теста.
capsys: что функция напечатала
Функция print_receipt печатает кассовый чек: шапку, позиции, итог. Проверить её возврат нельзя — функция ничего не возвращает, весь её результат уходит в print. На помощь приходит capsys: pytest перехватывает стандартный вывод, а фикстура отдаёт его тесту на блюдечке:
from pathlib import Path
Path("receipt.py").write_text('''
def print_receipt(items, total):
print("=== Чек ===")
for name, price in items:
print(name, "-", price, "руб.")
print("Итого:", total, "руб.")
''', encoding="utf-8")
Path("test_receipt.py").write_text('''
from receipt import print_receipt
def test_receipt_has_total(capsys):
print_receipt([("Кофе", 350)], 350)
captured = capsys.readouterr()
assert "Итого: 350 руб." in captured.out
def test_receipt_line_count(capsys):
print_receipt([("Кофе", 350), ("Чай", 120)], 470)
captured = capsys.readouterr()
assert len(captured.out.splitlines()) == 4
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_receipt.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.01s exit: 0
Как это устроено. Аргумент capsys — имя встроенной фикстуры: pytest видит его и подставляет перехватчик. После вызова print_receipt тест просит capsys.readouterr() — метод возвращает объект с двумя полями: out для стандартного вывода и err для потока ошибок. В captured.out лежит всё, что функция напечатала, одной строкой с переводами строк — дальше обычные проверки: в первом тесте подстрока «Итого: 350 руб.» обязана найтись, во втором — строк ровно четыре: шапка, две позиции, итог. Ни одного print в самом тесте: вывод живёт в буфере, а не в консоли.
Что именно оказалось в буфере первого теста? Всё, что ушла в печать функция, — по строке на каждый print:
=== Чек ===
Кофе - 350 руб.
Итого: 350 руб.
Побочный бонус понятен сразу: print внутри тестов не мусорит в отчёте. pytest перехватывает вывод всех тестов по умолчанию и показывает его только для упавших — поэтому при сотне тестов консоль остаётся чистой. capsys лишь даёт доступ к этому потоку осознанно: не «что там вывалилось», а «что функция обязана была напечатать».
err: поток ошибок тоже перехватывается
Половина служебных сообщений живёт не в стандартном выводе, а в потоке ошибок: предупреждения, логи о сбоях, кричащие баннеры. Они печатаются аргументом file=sys.stderr — и capsys складывает их в то же самое поле err. Тест на предупреждающую функцию:
from pathlib import Path
Path("logger.py").write_text('''
import sys
def warn(message):
print("ВНИМАНИЕ:", message, file=sys.stderr)
''', encoding="utf-8")
Path("test_logger.py").write_text('''
from logger import warn
def test_warn_goes_to_stderr(capsys):
warn("низкий баланс")
captured = capsys.readouterr()
assert "низкий баланс" in captured.err
assert captured.out == ""
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_logger.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
. [100%] 1 passed in 0.01s exit: 0
Обрати внимание на вторую проверку: captured.out пуст — сообщение ушло именно в stderr, а не в обычный вывод. Разделение потоков — не педантизм: скрипты в конвейерах фильтруют stdout и stderr по-разному, и функция, пишущая предупреждение не туда, ломает чужие пайплайны. Тест с двумя полями ловит такую ошибку за один прогон.
Есть у capsys и стратегическое применение: утилиты командной строки печатают свой результат, а не возвращают его. Проверять такой скрипт запуском процесса — тяжело и медленно; зато если логика выделена в функцию main(), capsys протестирует её как обычную функцию: вызвал main() — сверил captured.out. Это стандартный приём тестирования CLI-утилит без запуска отдельных процессов.
tmp_path: временная папка на каждый тест
Функция save_report пишет строки в файл по пути, который ей передали. Где взять путь, чтобы тест не писал в настоящую папку проекта? tmp_path — встроенная фикстура, которая выдаёт каждому тесту собственный пустой временный каталог в виде pathlib.Path:
from pathlib import Path
Path("report.py").write_text('''
def save_report(path, lines):
with open(path, "w", encoding="utf-8") as f:
for line in lines:
print(line, file=f)
''', encoding="utf-8")
Path("test_report.py").write_text('''
from report import save_report
def test_save_and_read_back(tmp_path):
target = tmp_path / "report.txt"
save_report(target, ["Строка 1", "Строка 2"])
assert target.exists()
lines = target.read_text(encoding="utf-8").splitlines()
assert lines == ["Строка 1", "Строка 2"]
def test_tmp_path_is_dir(tmp_path):
assert tmp_path.is_dir()
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_report.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
.. [100%] 2 passed in 0.02s exit: 0
Разбираем. target = tmp_path / "report.txt" — оператор слэша из pathlib: склеивает путь к временной папке с именем файла. Функция честно пишет файл по этому пути, а тест читает его обратно и сверяет содержимое построчно. Никакого os.remove, никаких папок temp в проекте: pytest сам удаляет временные каталоги после прогона — и сам же гарантирует, что у каждого теста каталог свой.
Именно уникальность делает tmp_path правильным ответом на вопрос «почему нельзя писать в фиксированный файл». Два теста, пишущие в report.txt в текущей папке, начинают гонку: кто первый записал, то содержимое и увидит второй — итог зависит от порядка запуска. С tmp_path каждый тест получает свою папку, и порядок перестаёт иметь значение — то самое свойство независимости из урока 4, только для файловых операций. В одной временной папке спокойно живут и несколько файлов:
from pathlib import Path
Path("test_files.py").write_text('''
def test_many_files(tmp_path):
for i in range(3):
(tmp_path / ("f" + str(i) + ".txt")).write_text(str(i), encoding="utf-8")
files = sorted(p.name for p in tmp_path.iterdir())
assert files == ["f0.txt", "f1.txt", "f2.txt"]
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_files.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
. [100%] 1 passed in 0.02s exit: 0
tmp_path — полноценный объект pathlib: .iterdir() обходит содержимое, .name отдаёт имена файлов, .exists() и .is_dir() проверяют состояние. Всё, что ты умеешь с путями, работает здесь без импорта лишнего — путь уже собран и папка уже создана.
Как делали до tmp_path
Чтобы оценить фикстуру, вспомни ручное решение из стандартной библиотеки: создать временную папку, не забыть удалить её в конце, причём даже если тест упадёт посередине:
import shutil
import tempfile
def test_save_old_way():
folder = tempfile.mkdtemp() # создаём сами
try:
path = folder + "/report.txt"
with open(path, "w", encoding="utf-8") as f:
f.write("данные")
assert open(path, encoding="utf-8").read() == "данные"
finally:
shutil.rmtree(folder) # убираем сами, даже при падении
Сравни объём: три служебных строки против одного слова в аргументах. И это не считая ловушек: ручная версия с одинаковой папкой на модуль снова устроит гонку между тестами. Старый код с tempfile встречается в проектах постоянно — теперь ты узнаешь его и поймёшь, что перед тобой до-pytest эпоха.
Заглядываем в tmp_path: флаг -s
Как выглядит та самая уникальная папка? Флаг -s выключает перехват вывода — print из теста попадает прямо в консоль, и мы можем напечатать имя каталога:
from pathlib import Path
Path("test_show.py").write_text('''
def test_show_folder(tmp_path):
print("моя папка:", tmp_path.name)
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_show.py", "-s", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
============================= test session starts ============================= collected 1 item test_show.py моя папка: test_show_folder0 . ============================== 1 passed in 0.03s ============================== exit: 0
Флаг -s пригождается и при отладке: если упавший тест непонятен, добавь временные print и запусти файл с -s — вся печать окажется перед глазами, как в обычном скрипте. Починил — убери -s обратно: перехваченный вывод держит отчёт чистым при сотнях тестов в наборе.
Обе фикстуры в одном тесте
Фикстуры свободно комбинируются: перечисли сколько нужно через запятую. Функция, которая пишет файл и сообщает об этом print-ом, проверяется одним тестом с двумя аргументами:
from pathlib import Path
Path("report.py").write_text('''
def save_report(path, lines):
with open(path, "w", encoding="utf-8") as f:
for line in lines:
print(line, file=f)
print("Сохранено:", path.name)
''', encoding="utf-8")
Path("test_report.py").write_text('''
from report import save_report
def test_save_reports_and_tells(tmp_path, capsys):
target = tmp_path / "report.txt"
save_report(target, ["Строка 1", "Строка 2"])
out = capsys.readouterr().out
assert "Сохранено: report.txt" in out
assert target.read_text(encoding="utf-8").splitlines() == ["Строка 1", "Строка 2"]
''', encoding="utf-8")
import pytest
rc = pytest.main(["test_report.py", "-q", "--no-header", "-p", "no:cacheprovider"])
print("exit:", int(rc))
. [100%] 1 passed in 0.03s exit: 0
Один тест проверил и файл на диске, и сообщение о сохранении. Заметь порядок: readouterr вызван один раз, после всей печати, — с учётом питфолла про опустошение буфера. Такие «двухфронтовые» тесты — будни: побочный эффект функции почти всегда имеет и файловую, и выводную составляющую.
| Фикстура | Что даёт тесту | Типичный вопрос |
|---|---|---|
| capsys | перехваченный print: readouterr().out и .err | что функция напечатала? |
| tmp_path | уникальный временный каталог, pathlib.Path | куда писать файлы, чтобы не сорить? |
| monkeypatch | подмены функций и переменных окружения | как изолировать внешний мир? — урок 12 |
| capsysbinary | вывод в байтах для бинарных потоков | что функция вывела в бинарный поток? |
Что дальше
Ты забрал со склада pytest два готовых инструмента: capsys проверяет печать через readouterr и его поля out и err, tmp_path выдаёт чистую временную папку и сам убирает за тестом. Ни декораторов, ни импортов — аргументы теста. Следующий урок — про третью встроенную фикстуру, самую хитрую: monkeypatch подменяет функции и переменные окружения на время теста и восстанавливает всё обратно. А в уроке 15 собственные фикстуры переедут в conftest.py, чтобы работать для всей папки тестов разом — и в финальном проекте capsys с tmp_path встретятся снова в тестах для JSON и файлов.
Лучшие фикстуры — те, что не пришлось писать: имя в аргументах теста, и pytest уже приготовил перехват вывода и чистую папку.
Сначала предскажи ответ в голове — это главный навык программиста.
printed = []
def fake_print(*args):
printed.append(" ".join(str(a) for a in args))
fake_print("Итого:", 470)
fake_print("=== Чек ===")
out = chr(10).join(printed)
print("Итого: 470" in out, len(out.splitlines()))
import pathlib
base = pathlib.PurePath("tmp", "t1")
target = base / "report.txt"
print(str(target).replace(chr(92), "/"))
print(target.name)
1. Как тест получает фикстуру capsys?
2. Что возвращает capsys.readouterr()?
3. Тест вызвал capsys.readouterr() дважды без новых print. Что вернёт второй вызов?
4. Что такое tmp_path в тесте?
5. Кто убирает временные каталоги, созданные tmp_path?
6. Что даёт флаг -s при запуске pytest?
Функция save_note(path, text) записывает текст в файл по пути path. Напиши тест с tmp_path: сохрани заметку «купить молоко» в файл note.txt, проверь, что файл существует, и прочитай содержимое обратно — оно должно совпадать с сохранённым.
Как проверить, что функция напечатала нужный текст, в pytest?
Фикстурой capsys: добавь её в аргументы теста, вызови функцию и забери вывод через capsys.readouterr().out — в этом поле лежит всё, что ушло в print. Дальше обычный assert: «Итого» in captured.out. Поток ошибок доступен там же в поле err.
Что такое tmp_path в pytest и зачем он нужен?
tmp_path — встроенная фикстура, которая передаёт тесту pathlib.Path на уникальный пустой временный каталог. Тест пишет туда файлы, читает их обратно и не оставляет мусора: pytest удаляет временные каталоги после прогона. Уникальность папки на каждый тест снимает гонки за общий файл.
Почему второй вызов capsys.readouterr() возвращает пустую строку?
readouterr опустошает буфер: всё, что накопилось, уходит в результат первого вызова. Второй вызов без новых print видит пустоту. Сохрани результат первого вызова в переменную — captured = capsys.readouterr() — и делай все проверки по captured.out и captured.err.
Можно ли использовать несколько фикстур в одном тесте?
Да, перечисли их через запятую в аргументах: def test_save(tmp_path, capsys):. pytest подставит каждую по имени. Комбинация tmp_path и capsys — частый дуэт: функция пишет файл и печатает подтверждение, тест проверяет и то и другое.
Куда пишет файлы tmp_path и остаются ли они на диске?
В системный временный каталог платформы: pytest создаёт подпапки вида test_report0 с счётчиком прогонов и удаляет их сам. В браузерной песочнице путь живёт в виртуальной файловой системе. Важно другое: в папке проекта файловых следов от тестов не остаётся, а у каждого теста — своя чистая папка.
Понравился урок? Сошлитесь на него
«Запомни рефлекс: тест с файлом — значит тест с tmp_path в аргументах.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
pytest · Урок 5
Фикстуры pytest: @pytest.fixture для подготовки данных
Одна и та же корзина собирается в каждом тесте заново — знакомая копипаста. Фикстура описывает подготовку один раз, а pytest сам передаёт результат в тест по имени.
pytest · Урок 12
monkeypatch: подмена функций и окружения в тестах
Функция ходит во внешний мир — за курсом валют, случайностью, переменными окружения — и тест теряет управление. monkeypatch подменяет внешний мир заглушкой на время теста и убирает её сам.
pytest · Урок 15
conftest.py: общие фикстуры для всей папки
Фикстуры перестали быть домашними: conftest.py делает их общими для всей папки — без единого импорта, силами самого pytest.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
Flask · Урок 16
Фикстуры для Flask-тестов: conftest, клиент и временная база
Шестнадцатый урок расширения Flask: каждый тест создаёт клиент сам — пора зафиксировать подготовку в фикстурах, вынести её в conftest.py и раздавать тестам временные базы через tmp_path.
pytest flask фикстуры conftestpytest fixture test_client
Flask · Урок 15
Тестирование Flask: pytest и test_client
Пятнадцатый урок курса Flask — мост в мир автотестов: проверки API из предыдущих уроков становятся тест-функциями pytest с assert, а app.test_client() работает внутри теста без сервера и сети.
flask тестирование pytest test_clientтестирование flask api pytest
pytest · Урок 17
Тесты для JSON и файлов: tmp_path на практике
Сохранить заказы в JSON, прочитать обратно и не разочароваться: round-trip тесты, кириллица с encoding utf-8, битые файлы через pytest.raises и tmp_path — папка, которая достаётся каждому тесту своя.
pytest тестирование файловpytest tmp_path