Привет, Хабр!
История начинается с безобидного PR. В платёжный модуль добавили ключ идемпотентности, и у функции charge появился обязательный именованный аргумент.
Вызов в биллинге обновили, тесты прогнали, всё зелёное — мержим.
Через сорок минут после выката корзина отдаёт пятисотые, а в логах висит:
TypeError: charge() missing 1 required keyword-only argument
Оказалось, что вызовов было три, а обновили два. Обычная невнимательность, которую тест должен ловить за секунду, если тест вообще смотрит на код.
Наш смотрел на мок, а мок про новую сигнатуру ничего не знал и спокойно принял вызов по‑старому.
Претензий к самому подходу даже нет: без подмены платёжки тесты ходили бы в чужой API, а жить с этим невозможно.
Вопрос в другом:
Насколько близко подмена держится к оригиналу, и что происходит, когда она от него отстаёт.
Мок принял вызов, который прод принять не может
Тот самый тест: функция buy считает сумму корзины и зовёт charge, а charge замокан обычным patch.
CART = [{"price": 100}, {"price": 200}] with patch("checkout.charge") as m: m.return_value = "ch_1" print("обычный patch:", buy(CART)) m.assert_called_once_with(300) print("assert_called_once_with(300) прошёл")
Сигнатура в проде к этому моменту выглядит уже иначе:
обычный patch: ch_1 assert_called_once_with(300) прошёл прод: TypeError charge() missing 1 required keyword-only argument: 'idempotency_key'
А вызов внутри buy остался старым:
charge(total)
Вот что печатает тест и что делает настоящий код:
обычный patch: ch_1 assert_called_once_with(300) прошёл прод: TypeError charge() missing 1 required keyword-only argument: 'idempotency_key'
Тест не только прошёл, но и подтвердил утверждением, что вызов был ровно с теми аргументами, с какими надо.
Только «надо» здесь определяет он сам, а не функция, которую изображает.
Дело в том, что Mock по умолчанию соглашается вообще на всё: связи с оригиналом у него нет, аргументы он складывает в call_args, а возвращает то, что ему велели.
with patch("checkout.charge") as m: m(300, 1, 2, 3, wat=True) print("приняло, call_args =", m.call_args)
приняло, call_args = call(300, 1, 2, 3, wat=True)
Исправляется это одним autospec=True.
Мок тогда строится по настоящей сигнатуре и проверяет вызовы как интерпретатор, поэтому тест падает там же, где упал бы прод, и почти с тем же текстом:
autospec: TypeError missing a required argument: 'idempotency_key'
За удобство приходится платить.
autospec рекурсивно обходит объект и строит по нему целое дерево моков, а Mock(spec=...) только запоминает список имён.
Разрыв между ними поэтому зависит от того, сколько в классе методов:
методов 5: Mock(spec=) 161 мкс | create_autospec 2476 мкс | 15x методов 20: Mock(spec=) 152 мкс | create_autospec 8360 мкс | 55x методов 60: Mock(spec=) 195 мкс | create_autospec 25308 мкс | 130x
Mock(spec=) стоит примерно одинаково независимо от размера класса, а autospec добавляет около четырёх десятых миллисекунды на каждый метод.
В тесте, который создаёт мок один раз, разницы никто не заметит.
А в параметризованном на двести случаев по классу с полусотней методов набегает несколько секунд.
Вторая часть цены — динамика.
autospec смотрит на класс, а не на живой экземпляр, поэтому всё, что заводится в init, для него просто не существует:
class WithInit: def __init__(self): self.conn = "живой атрибут" def ping(self): return True
ping есть? True conn есть? AttributeError: Mock object has no attribute 'conn'
Со своим кодом это мешает редко, а вот с чужими библиотеками, где половина интерфейса собирается в конструкторе или через дескрипторы, приходится выбирать: мокать экземпляр или снимать autospec.
Патч приклеивается туда, где имя ищут, а не где его определили
Пока разбирались с первым, вылезло следующее: в соседнем тестовом файле тот же charge мокали иначе, по месту, где функция объявлена.
# payments.py def charge(amount, currency="RUB"): ... # checkout.py from payments import charge def buy(cart): return charge(sum(i["price"] for i in cart))
Тесты отличаются одной строкой:
with patch("payments.charge") as m: # там, где определено m.return_value = "ch_1" buy(CART) with patch("checkout.charge") as m: # там, где используется m.return_value = "ch_1" print("результат:", buy(CART)) print("вызван с:", m.call_args)
реальная функция: ConnectionError результат: ch_1 вызван с: call(300)
Первый вариант ушёл в сеть.
Строка:
from payments import charge
Выполняется при импорте checkout и кладёт ссылку на функцию в его собственное пространство имён.
Подмена в payments после этого меняет имя только в модуле‑источнике, а checkout.charge продолжает указывать на исходный объект.
Заметить такое трудно как раз потому, что тест не падает с чем‑то внятным.
Он падает по таймауту, по ConnectionError, по отказу от сети в CI — и всё это выглядит инфраструктурной бедой, а не ошибкой в тесте.
Тут же лежит и объяснение, почему привычный совет:
«Патчить по месту использования»
Иногда не срабатывает.
Всё зависит от того, как написан импорт.
Если в тестируемом модуле стоит:
import payments
А вызов идёт через точку, ссылка разрешается в момент вызова, а не в момент импорта:
import payments + patch('payments.charge') -> ch_1
Теперь тот же самый патч работает:
import payments + patch('payments.charge') -> ch_1
Тот же patch("payments.charge"), который в прошлом примере ушёл в сеть, теперь отрабатывает как надо.
Поэтому правило точнее звучит так:
Целься в то имя, которое код разрешает в момент вызова.
При:
from x import y
это:
тестируемый_модуль.y
При:
import x
это уже:
x.y
Любой мок истинен, и ветка «счёт заблокирован» выбирается сама
Функция снятия денег выглядит так:
def withdraw(acc, amount): if acc.is_blocked(): raise RuntimeError("счёт заблокирован") if acc.balance() < amount: raise RuntimeError("недостаточно средств") return amount
Тест подсовывает ей Mock() и ждёт успешного снятия:
acc = Mock() withdraw(acc, 100)
Mock: RuntimeError счёт заблокирован
Вызов:
acc.is_blocked()
Вернул новый мок, а любой мок истинен:
bool(Mock()) bool(MagicMock())
Одинаково дают: True.
Условие сработало, функция ушла в первую ветку, и до второй строки дело не дошло вообще.
balance в этом тесте не проверялся ни разу.
От этого, что важно, не спасает и autospec.
Он сверяет сигнатуры, а про типы возвращаемых значений не знает ничего.
acc = create_autospec(Account, instance=True) print(type(acc.is_blocked()).__name__) # MagicMock
Аннотация def is_blocked(self) -> bool в исходнике есть, но create_autospec её игнорирует и всё равно отдаёт MagicMock.
Значит, каждое значение, от которого зависит ветвление, приходится задавать руками через acc.is_blocked.return_value = False.
Правило отсюда: если тестируемый код что‑то спрашивает у мока и на ответе принимает решение, ответ обязан быть настоящим значением, а не моком.
Что spec всё‑таки умеет, так это ловить опечатки в именах методов, потому что без него мок отвечает на любое обращение:
Mock().is_blockd() # вернёт новый Mock Mock(spec=Account).is_blockd() # AttributeError
spec: Mock object has no attribute 'is_blockd' без spec: Mock
Ступенькой выше стоит spec_set.
Он запрещает ещё и присваивать атрибуты, которых у оригинала нет.
Это тот случай, когда тест дописывает моку поле, поле потом переезжает в код, а в самом классе его так и не заводят.
spec_set: Mock object has no attribute 'new_attr' spec: присвоение нового атрибута прошло
Получается трёхступенчатая связка:
Инструмент | Что проверяет |
|---|---|
| имена методов и атрибутов |
| имена + запрет новых атрибутов |
| всё выше + сигнатуры методов |
Про возвращаемые значения не знает ни одна из трёх.
Патч, который пережил свой тест
В начале модуля лежала строчка, поставленная кем‑то ради удобства.
p = patch("svc.sync_fetch", return_value={"ok": "MOCK"}) p.start() def test_a(): print("test_a:", svc.sync_fetch("u")) def test_b(): print("test_b:", svc.sync_fetch("u"))
test_a: {'ok': 'MOCK'} test_b: {'ok': 'MOCK'}
test_b про мок ничего не знает и не просил его.
Но start() без stop() подменяет атрибут до конца процесса, так что подменённую функцию получат все тесты, попавшие в прогон следом за этим файлом.
Хуже всего, что симптом зависит от порядка.
Локально разработчик гоняет один файл и не видит ничего.
В CI файлы идут алфавитно, и протёкший патч достаётся тому, кто оказался ниже по списку.
Стоит кому‑то переименовать файл — и сломается совсем другой тест, а виноватым окажется автор переименования.
Защищает от всего этого одна форма:
У каждого
start()должен бытьstop().
@pytest.fixture def mocked(): p = patch("svc.sync_fetch", return_value={"ok": "MOCK"}) yield p.start() p.stop()
test_a: {'ok': 'MOCK'} test_b: {'ok': True}
Зелёный assert_called_once_with ничего не говорит про результат
Тест из первого раздела проверял, что charge позвали с суммой 300.
Но проверял на деле другое — что мок записал в call_args кортеж:
(300,)
Эти два утверждения совпадают до того момента, когда настоящая функция начинает требовать что‑то ещё.
Есть, впрочем, и хорошая часть.
Старый совет:
«Бойтесь опечатки в
assert_called_once_with, мок молча вернёт вам новый мок»
уже неверен.
Современный Mock ловит опечатки сам.
m = Mock() for name in ("assret_called_once", "asert_called_with", "called_once", "has_been_called"): try: getattr(m, name)() print(f"{name:22} -> прошло молча") except AttributeError: print(f"{name:22} -> AttributeError")
assret_called_once -> AttributeError asert_called_with -> AttributeError called_once -> AttributeError has_been_called -> прошло молча
В unittest/mock.py защита устроена двумя способами.
Имена, начинающиеся на:
assert assret asert aseert assrt
Отсекаются по префиксу.
Рядом лежит список настоящих методов без приставки assert_, оттуда и:
called_once
Всё, что не похоже ни на то ни на другое, по‑прежнему проходит молча.
has_been_called — тому пример.
Но опечатка тут не главная беда, и защита от неё сути не меняет: утверждение про вызов остаётся утверждением про вызов.
Вместо мока подставляем маленькую честную реализацию: она делает вид, что работает, и запоминает, что с ней сделали.
class FakeGateway: def __init__(self): self.charges = [] def charge(self, amount, currency="RUB", *, idempotency_key): self.charges.append((amount, currency, idempotency_key)) return f"ch_{len(self.charges)}"
Проверять теперь можно результат, а не протокол общения:
id: ch_1 списания: [(300, 'RUB', 'k1')]
И та самая ошибка из первого раздела ловится тут без всякого autospec, просто потому что у фейка настоящая сигнатура, написанная руками:
фейк поймал: FakeGateway.charge() missing 1 required keyword-only argument: 'idempotency_key'
Плата за это в том, что фейк надо писать и потом за ним следить.
Мок запомнил список, код его очистил, утверждение упало
Обратная сторона того же — утверждение, которое падает, хотя код отработал верно.
Мок хранит аргументы по ссылке, а не копией, поэтому если после вызова аргумент изменили, call_args покажет уже изменённое.
send = Mock() def process(batch): send(batch) # мок запомнил ссылку на список batch.clear() # тот же список очищается дальше по коду process(["a", "b"]) print("call_args:", send.call_args) send.assert_called_once_with(["a", "b"])
call_args: call([]) AssertionError: expected call not found.
Никакой ошибки в коде нет, send действительно позвали с двумя элементами. Просто мок держит тот же объект, который потом очистили, и показывает его нынешнее состояние. С батчами, буферами и переиспользуемыми словарями такое встречается регулярно, а ищется плохо.
В отчёте видно call([]), и первая мысль всегда про пустой батч в бизнес‑логике.
Обойти можно моком, который копирует аргументы на входе:
from copy import deepcopy class CopyingMock(MagicMock): def __call__(self, /, *args, **kwargs): args = deepcopy(args) kwargs = deepcopy(kwargs) return super().__call__(*args, **kwargs)
CopyingMock call_args: call(['a', 'b']) утверждение прошло
Рецепт лежит в документации unittest.mock, в разделе с примерами. Там же рядом второй вариант: проверять аргументы прямо внутри side_effect, пока их ещё не изменили.
Асинхронную функцию patch подменяет правильно, а руки — нет
Есть ещё одно место, которое в эту историю не попало, но соседствует с ней вплотную. Мок асинхронной функции должен быть awaitable, и начиная с версии 3.8 patch разбирается с этим сам:
with patch("svc.fetch") as m: # async def fetch(url) print(type(m).__name__) # AsyncMock with patch("svc.sync_fetch") as m: # обычная функция print(type(m).__name__) # MagicMock
Ломается такое тогда, когда объект подставляют руками через new=, потому что автоопределение при этом выключается — определять уже нечего.
with patch("svc.fetch", Mock(return_value={"ok": True})): await svc.fetch("u")
TypeError: object dict can't be used in 'await' expression
Ошибка внятная, и в этом её плюс: тест падает сразу, а не притворяется зелёным. Из всего разобранного это единственное, что ловится с первого прогона.
Настоящая функция и её мок задумывались как одно и то же, а живут дальше порознь. Отсюда вывод всей статьи: мок должен знать про оригинал как можно больше и придумывать от себя как можно меньше.

Хорошие автотесты должны давать уверенность в изменениях, а не просто добавлять количество зелёных запусков. Если моки скрывают ошибки, а тесты проверяют не поведение системы, а детали реализации, команда узнаёт о проблемах уже после выката.
На открытых уроках разберём практики, которые помогают писать более надёжные тесты:
22 сентября в 20:00. «Playwright JS: как быстро начать писать автотесты?». Записаться
21 октября в 20:00. «Написание тестов для Андроид в эпоху ИИ». Записаться
А полный список бесплатных уроков сентября вы найдете в дайджесте.

