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

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

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

Встроенные фикстуры: 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:

содержимое captured.out после первого теста
=== Чек ===
Кофе - 350 руб.
Итого: 350 руб.
Это значение captured.out из теста test_receipt_has_total: три строки, склеенные переводами строк. С ним и работает assert на подстроку «Итого: 350 руб.».

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

err: поток ошибок тоже перехватывается

Половина служебных сообщений живёт не в стандартном выводе, а в потоке ошибок: предупреждения, логи о сбоях, кричащие баннеры. Они печатаются аргументом file=sys.stderr — и capsys складывает их в то же самое поле err. Тест на предупреждающую функцию:

проверяем предупреждение в stderr
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)            # убираем сами, даже при падении
Так пишут без pytest-фикстур: mkdtemp создаёт папку, rmtree в finally удаляет её при любом исходе. Забудешь finally — папки копятся; поставишь удаление без finally — при падении теста мусор останется. tmp_path делает всё это сам.

Сравни объём: три служебных строки против одного слова в аргументах. И это не считая ловушек: ручная версия с одинаковой папкой на модуль снова устроит гонку между тестами. Старый код с 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-ом, проверяется одним тестом с двумя аргументами:

tmp_path и capsys вместе
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)
Проверь себя
0 / 6

1. Как тест получает фикстуру capsys?

2. Что возвращает capsys.readouterr()?

3. Тест вызвал capsys.readouterr() дважды без новых print. Что вернёт второй вызов?

4. Что такое tmp_path в тесте?

5. Кто убирает временные каталоги, созданные tmp_path?

6. Что даёт флаг -s при запуске pytest?

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

Функция save_note(path, text) записывает текст в файл по пути path. Напиши тест с tmp_path: сохрани заметку «купить молоко» в файл note.txt, проверь, что файл существует, и прочитай содержимое обратно — оно должно совпадать с сохранённым.

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

Как проверить, что функция напечатала нужный текст, в 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

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

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