Как писать моки, чтобы тесты падали вместе с продом: 7 частых ошибок

История начинается с безобидного 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 мкс | 130xMock(spec=) стоит примерно одинаково независимо от размера класса, а autospec добавляет около четырёх десятых миллисекунды на каждый метод.
В тесте, который создаёт мок один раз, разницы никто не заметит.
А в параметризованном на двести случаев по классу с полусотней методов набегает несколько секунд.
Вторая часть цены — динамика.
autospec смотрит на класс, а не на живой экземпляр, поэтому всё, что заводится в init, для него просто не существует:
class WithInit:
def __init__(self):
self.conn = "живой атрибут"
def ping(self): return Trueping есть? 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() # AttributeErrorspec: 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. «Написание тестов для Андроид в эпоху ИИ». Записаться
А полный список бесплатных уроков сентября вы найдете в дайджесте.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.