Программирование PythonТестированиеРазработчик Python, отвечающий за автоматизированное тестирование

Разработчик проверяет исходные аргументы мока после изменения переданного словаря. Как завершится тест и по...

Разработчик проверяет исходные аргументы мока после изменения переданного словаря. Как завершится тест и почему?

from unittest.mock import Mock

save = Mock()
data = {"status": "new"}

save(data)
data["status"] = "sent"

save.assert_called_once_with({"status": "new"})
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Тест завершится ошибкой: Mock хранит ссылку на переданный словарь, а не его копию. Поэтому к моменту проверки фактический вызов будет выглядеть как call({'status': 'sent'}), и ожидание {'status': 'new'} не совпадёт.

Исторический контекст

Mock-объекты предназначены не только для подмены зависимостей, но и для записи взаимодействий с ними. Для универсальности и минимальных накладных расходов они обычно сохраняют переданные аргументы как объекты Python, не создавая автоматические снимки их состояния.

Это означает, что проверка вызова отражает состояние аргумента на момент проверки, если вызывающий код успел изменить этот объект после вызова.

Постановка проблемы

В примере data и объект, сохранённый в save.call_args, ссылаются на один и тот же словарь. После присваивания data["status"] = "sent" изменяется и значение, видимое через журнал вызовов.

Неверное ожидание особенно опасно, когда тест проверяет DTO, настройки или payload, который затем изменяется production-кодом. Тест может либо неожиданно упасть, либо, наоборот, подтвердить уже изменённое состояние, хотя требовалось проверить исходные данные.

Подробное решение

Mock сохраняет аргументы вызова в атрибутах вроде call_args, call_args_list и использует их при работе assert_called_once_with. Для изменяемых объектов сохраняется ссылка:

from unittest.mock import Mock save = Mock() data = {"status": "new"} save(data) data["status"] = "sent" print(save.call_args) # call({'status': 'sent'})

Если нужно проверить состояние до изменения, выполните assertion сразу после вызова. Если необходимо сохранить именно снимок, передайте в mock копию или настройте side_effect, который будет копировать аргументы при каждом вызове. Выбор между поверхностной и глубокой копией зависит от того, могут ли изменяться вложенные структуры.

Автоматическое копирование всех аргументов не является универсальным решением: оно может быть дорогим, менять семантику теста и не работать для объектов, которые нельзя корректно копировать. Поэтому границу, на которой нужен снимок данных, следует задавать явно.

Ситуация из практики

Сервис формирует словарь заказа, вызывает репозиторий, а затем добавляет в тот же словарь служебное поле. В тесте нужно удостовериться, что репозиторий получил заказ без этого поля.

Вариант с проверкой в самом конце ненадёжен: сохранённый аргумент уже изменён. Проверка сразу после вызова проста, но может быть неудобна при сложной последующей подготовке. Передача в репозиторий copy.deepcopy(payload) надёжно фиксирует состояние, но переносит деталь копирования в production-код.

Практичнее оставить production-код без искусственной копии, а в тесте использовать side_effect, который сохраняет копии аргументов:

from copy import deepcopy from unittest.mock import Mock snapshots = [] save = Mock(side_effect=lambda payload: snapshots.append(deepcopy(payload))) order = {"id": 7} save(order) order["status"] = "sent" assert snapshots == [{"id": 7}]

Так тест явно фиксирует момент взаимодействия и не меняет поведение проверяемого кода.

Что кандидаты часто упускают

  1. Изменится ли поведение для неизменяемого аргумента, например строки или числа?

    Нет. Строки, числа и кортежи с неизменяемым содержимым нельзя изменить на месте. Переназначение локальной переменной создаёт другую ссылку и не меняет объект, уже сохранённый mock. Однако кортеж может содержать изменяемый объект, например список, и тогда изменение вложенного списка будет видно через call_args.

  2. Достаточно ли использовать dict.copy() для вложенного payload?

    Не всегда. dict.copy() создаёт поверхностную копию: вложенные списки, словари и пользовательские объекты остаются общими. Если после вызова изменяется вложенная структура, для независимого снимка потребуется copy.deepcopy() либо специально выбранное копирование только нужных уровней.

  3. Можно ли проверить исходные аргументы через call_args после изменения объекта?

    Нет, если mock уже сохранил только ссылку и отдельный снимок заранее не создавался. Можно проверить вызов до мутации, сохранить копию аргументов в side_effect или передавать в mock копию изначально. Простое чтение call_args после изменения не восстановит прежнее состояние.