Почему patch не работает: подменяем имя в месте использования
В тесте подменили функцию часов, но расчёт продолжает брать настоящее время. Обычно проблема в двух разных именах: библиотека определила функцию, а ваш модуль уже сохранил собственную ссылку на неё.
Проследить присваивание при импорте
Конструкция from source import now находит объект source.now и связывает его с именем now в импортирующем модуле. Последующая подмена source.now меняет атрибут source. Ранее сохранённое имя в другом модуле от этого не переназначается. patch должен менять тот атрибут, который код читает в момент выполнения.
При import source и вызове source.now() поиск идёт через объект модуля. При from source import now и вызове now() поиск идёт через глобальное имя потребителя. Оба варианта импорта законны; тесту важно повторить фактический маршрут поиска. Поэтому совет «подменить исходную функцию» без чтения импорта часто даёт бесполезный мок.
Воспроизвести два модуля локально
Пример создаёт source и consumer как настоящие объекты ModuleType. exec выполняет обычный оператор импорта в пространстве consumer. Это позволяет показать поиск имени в одном самодостаточном файле. В проекте consumer был бы обычным файлом, например report.py, а целью подмены стало бы report.now.
Первый patch намеренно показывает, что подмена определения не затронула уже импортированную ссылку. Второй меняет имя потребителя и влияет на вычисленный результат. Оба ожидания проверяются assert, поэтому результат не зависит от времени компьютера.
import sys
from types import ModuleType
from unittest.mock import patch
source = ModuleType("article_clock_source")
source.now = lambda: 100
consumer = ModuleType("article_clock_consumer")
with patch.dict(sys.modules, {source.__name__: source}):
exec("from article_clock_source import now\n"
"def expires(ttl):\n return now() + ttl\n", consumer.__dict__)
with patch.object(source, "now", return_value=500):
assert consumer.expires(10) == 110
with patch.object(consumer, "now", return_value=500) as mocked:
assert consumer.expires(10) == 510
mocked.assert_called_once_with()
assert consumer.expires(10) == 110
print("consumer lookup patched and restored")Сузить участок подмены
Контекст with возвращает прежнюю ссылку после блока. Это особенно полезно для модулей, которые загружены один раз на весь запуск тестов: забытая подмена иначе меняет соседние проверки. Не исправляйте промах по имени глобальным patch всего пакета. Сначала найдите выражение вызова и определите, какой объект содержит искомый атрибут.
Если функция получает часы явным параметром, поиск становится проще: передайте тестовую зависимость напрямую. patch нужен для уже существующей границы, где импорт скрывает источник. При рефакторинге можно сохранить покрытие подменой, затем вынести зависимость в аргумент и сократить глобальное вмешательство.
Потренировать управляемую подмену
В открытом уроке «Monkeypatch и mock в pytest» разберите замену зависимостей. Вернитесь к своему импорту и назовите точное имя, которое читает функция. Откройте программу курса, чтобы продолжить тестируемым дизайном и явными зависимостями.
Продолжить на практике
- Python для продвинутых: ООП, типизация и тестирование ↗
Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.
