ESPNSources: Campbell, 1st woman to coach full time in NHL, among Devils hiresESPN DeportesMessi y el Inter Miami tienen nuevo entrenadorBollywood HungamaDiljit Dosanjh announces Ahmedabad concert at Narendra Modi Stadium on November 21 after sold-out Wembley showוואלהדיווח: גורמים אמריקניים נפגשו עם נציגי החות'ים - שהבטיחו שלא יתקפו ספינות ישראליותInquirerHouse mourns passing of former lawmaker, Quirino Gov. Dax CuaThe Jerusalem PostLondon Jews warn of security crisis after antisemitic arson attacksVanguardGOKABET Enters Nigeria’s Sports Betting Market With a Sportsbook Built for Serious Fans3DNewsПо Ace Combat 8: Wings of Theve выйдет короткометражка Hour Zero со звёздами «Остаться в живых» и «Кобра Кай» — первый эпизод уже доступенسكاي نيوز عربيةاستدعاء وطرد.. السويد تشدد موقفها تجاه إيران20 MinutenNächster «Hunger Games»-Film: Hier ist der neue TrailerThe South AfricanTatjana Smith makes shock dive back into the poolInteriaRosjanie ostrzelali duński śmigłowiec. Dowódca nagrodzony "za zasługi"
The Daily Newsstand · Free, Always
Wednesday, September 16, 2026

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

Translate

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

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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.