2025, Oct 21 08:17
Как тестировать файловый ввод‑вывод в Python без подмены builtins.open
Разбираем, почему monkeypatch builtins.open ломает pdb, и показываем безопасные альтернативы: файлы через pytest tmp_path и инъекцию функции открытия.
Подмена встроенных функций вроде open кажется заманчивой, когда функция читает файл, но при отладке это часто оборачивается проблемами. Типичный симптом — TypeError из‑за неожиданного именованного аргумента encoding, когда включается pdb. Ниже — что происходит и как тестировать файловый ввод‑вывод, не конфликтуя с отладчиком.
Минимальный пример
Функция читает файл и подсчитывает уникальные буквенные символы. В тесте builtins.open заменяется лямбдой, возвращающей поток в памяти. Как только вмешивается pdb, тест падает с TypeError.
import builtins
import io
import pytest
def tally_unique_alpha_from_file(p: str) -> int:
    with open(p) as fh:
        return len(set(ch.lower() for ch in fh.read() if ch.isalpha()))
def test_counts(monkeypatch: pytest.MonkeyPatch):
    src = "Quick frog or something"
    monkeypatch.setattr(builtins, "open", lambda _p: io.StringIO(src))
    assert tally_unique_alpha_from_file("blah.txt") == 15
Почему это ломается
Лямбда‑замена принимает только один позиционный аргумент. Настоящий open поддерживает больше параметров, включая encoding. Когда вы заходите в pdb (например, через pytest --pdb или pdb.set_trace()), сам pdb вызывает open, чтобы прочитать свой rc‑файл, вроде ~/.pdbrc, с явным указанием кодировки. Этот вызов попадает в лямбду, которая не принимает encoding, и Python выбрасывает TypeError. Иными словами, тест подменил глобальный примитив, которым пользуется отладчик, из‑за чего сломались посторонние инструменты.
Безопаснее: использовать реальный временный файл
Чтобы протестировать код, читающий файл, ничего мокать или подменять не нужно. Pytest предоставляет tmp_path для работы с временными директориями. Создайте настоящий файл, запишите нужное содержимое и передайте путь в функцию. Так отладчик и остальная среда остаются нетронутыми.
import pathlib
def tally_unique_alpha_from_file(p: str) -> int:
    with open(p) as fh:
        return len(set(ch.lower() for ch in fh.read() if ch.isalpha()))
def test_counts(tmp_path: pathlib.Path):
    fpath = tmp_path / "blah.txt"
    fpath.write_text("Quick frog or something")
    assert tally_unique_alpha_from_file(str(fpath)) == 15
По умолчанию pytest сохраняет каталоги от последних трёх прогонов тестов, прежде чем начать их очищать, а при падении теста вы увидите значения фикстур в выводе — отладка становится проще. Если предпочитаете инструменты стандартной библиотеки, подойдёт и интеграционный тест с временным файлом.
Альтернатива: инъекция функции открытия
Если нужно контролировать способ открытия файлов, не трогая builtins, инвертируйте зависимость и принимайте функцию‑открыватель. По умолчанию функция всё равно использует open, но тесты могут передать другой вызываемый объект. Контракт становится явным, а глобальные побочные эффекты исчезают.
from collections.abc import Callable
from io import StringIO
from typing import TextIO
from unittest.mock import Mock
def tally_unique_alpha_from_file(
    p: str, *, open_fn: Callable[[str], TextIO] = open
) -> int:
    with open_fn(p) as fh:
        return len(set(ch.lower() for ch in fh.read() if ch.isalpha()))
def test_counts_with_mock():
    fake_open = Mock(spec=open)
    fake_open.return_value = StringIO("Quick frog or something")
    assert tally_unique_alpha_from_file("blah.txt", open_fn=fake_open) == 15
Такой подход инвертирует зависимость и одновременно выделяет интерфейс, чётко показывая, как именно функция собирается использовать «открыватель». Никакого monkeypatching — и никаких сюрпризов для pdb или других инструментов.
Почему это важно
Мокать то, чем вы не владеете, особенно builtins, — рискованно: можно сломать несвязанные части среды выполнения, такие как отладчики, тестовые раннеры или библиотеки, рассчитывающие на стандартное поведение. Тесты, которые работают с реальными файлами через временные пути, надёжнее и понятнее. Один нюанс: временные файлы не подскажут, сколько раз файл открывали — это бывает важно для проверки кеширования, — но для большинства задач ввода‑вывода они делают тесты проще и стабильнее.
Выводы
Если вам важны только данные файла, отдавайте предпочтение реальным временным файлам через pytest.tmp_path или обычным временным файлам. Если нужно наблюдать или управлять тем, как файл открывается, передавайте функцию‑открыватель вместо изменения builtins. Оба подхода позволяют отлаживаться с pdb, не сталкиваясь с его внутренними вызовами open с указанием кодировки.
Статья основана на вопросе на StackOverflow от Dominik Kaszewski и ответе от jonrsharpe.