Привет, Хабр!

История начинается с безобидного 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:     присвоение нового атрибута прошло

Получается трёхступенчатая связка:

Инструмент

Что проверяет

spec

имена методов и атрибутов

spec_set

имена + запрет новых атрибутов

autospec

всё выше + сигнатуры методов

Про возвращаемые значения не знает ни одна из трёх.

Патч, который пережил свой тест

В начале модуля лежала строчка, поставленная кем‑то ради удобства.

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. «Написание тестов для Андроид в эпоху ИИ». Записаться

А полный список бесплатных уроков сентября вы найдете в дайджесте.