Один мок — три проблемы

В этой статье я расскажу о трёх проблемах, с которыми столкнулся при использовании моков:
Потерянные инварианты
Требования в голове
Немые тесты
Но сначала - небольшая предыстория.
Почему мне не нравились моки
Долгое время я не любил моки. Именно Mock - не Fake, Stub, Spy и другие виды тестовых дублёров.
Во многом потому, что видел, как ими подменяют почти всё подряд:
Я тестирую класс X. То, что внутри него используется класс Y, мне не важно: работу Y должны проверять другие тесты.
В этом есть доля правды. Тест класса X действительно не обязан повторно проверять все ветки класса Y.
Но из этого не следует, что существование правил класса Y можно полностью игнорировать.
На тот момент мне не хватало знаний, чтобы предметно объяснить, почему мокирование всего подряд кажется мне неправильным. Поэтому я начал глубже разбираться в тестировании и прочитал книгу Владимира Хорикова «Принципы юнит-тестирования».
Эта книга помогла мне лучше понять различие между классической и лондонской школами юнит-тестирования.
Упрощённо:
классическая школа изолирует тесты друг от друга;
лондонская школа стремится изолировать тестируемый объект от его зависимостей.
После первого прочтения лондонский подход показался мне вполне естественным. В наших сервисах основными зависимостями были репозитории и клиенты внешних систем. Репозитории мы заменяли Fake-реализациями, а клиенты - моками.
Для таких границ это работало нормально.
Проблемы стали заметны позже, когда появились более сложные сценарии: сервисы уровня приложения, доменные сервисы, калькуляторы, менеджеры и другие классы, внутри которых находились бизнес-правила.
Именно тогда я смог сформулировать, почему мокировать собственные классы приложения опасно.
Главная причина - потерянные инварианты.
1. Потерянные инварианты
Рассмотрим упрощённый вымышленный пример.
У нас есть сервис создания пользователя. В нём действует бизнес-правило: пользователю должно быть не меньше 18 лет.
class UserServiceApp:
def __init__(self, user_repository: BaseUserRepository) -> None:
self._user_repository = user_repository
def create(self, user_data: CreateUserDTO) -> None:
self._check_user_data(user_data)
self._user_repository.add(user_data)
def update(self, user_data: UpdateUserDTO) -> None:
self._check_user_data(user_data)
self._user_repository.update(user_data)
def _check_user_data(self, user_data: CreateUserDTO | UpdateUserDTO) -> None:
if user_data.age < 18:
raise DomainLogicError("Мы работаем только с совершеннолетними.")Этот класс легко протестировать, передав ему Fake-реализацию репозитория.
Да, проверку возраста можно - и нужно - перенести в фабричный метод самой сущности. В этом примере я намеренно оставляю её в сервисе, чтобы проблема была хорошо видна.
Позже мы ещё вернёмся к этому возражению.
Пока всё выглядит хорошо:
в системе есть правило «пользователю должно быть 18 лет»;
правило выполняется;
на него написаны тесты.
Но сколько других сценариев используют этот сервис?
В большой системе создание или обновление пользователя может вызываться из десятков разных use cases:
class RegisterUserUseCase: ... # пользователь регистрируется сам
class AdminCreateUserUseCase: ... # менеджер создаёт его в админке,
# логика вокруг создания другая
class AcceptInvitationUseCase: ... # приглашение в компанию:
# пользователь создаётся при принятии
class CreateReferralUserUseCase: ... # регистрация по реферальной ссылке
...
# И это только создание. Обновление профиля, смена почты,
# блокировка, импорт - отдельные сценарии.
# В каждом тесте мок UserServiceApp снова выкинет те же правила.В тесте мы обычно создаём мок сервиса и проверяем, что он был вызван с ожидаемыми аргументами:
def test_...():
user_service_app_mock = Mock(spec=UserServiceApp)
use_case = CreateUserUseCase(user_service_app=user_service_app_mock)
got = use_case.execute(...)
assert user_service_app_mock.assert_called_once_with(...)
...Но что если тестовыми данными будет пользователь, которому 17 лет:
def test_create_user() -> None:
user_service_app_mock = Mock(spec=UserServiceApp)
use_case = CreateUserUseCase(user_service_app=user_service_app_mock)
use_case.execute(user_data=CreateUserData(age=17, name="Иван"))
user_service_app_mock.create.assert_called_once_with(
CreateUserData(age=17, name="Иван"),
)
...Запускаем тесты - и они проходят.
============================= test session starts ==============================
platform linux -- Python 3.x.x, pytest-x.y.z, pluggy-x.y.z
rootdir: /path/to/project
collected 1 item
test_....py . [100%]
============================== 1 passed in 0.02s ===============================Формально всё корректно: use case вызвал зависимость с ожидаемыми аргументами, а мок подтвердил вызов.
Но с точки зрения поведения системы тест врёт.
В production такой вызов закончится ошибкой: настоящий UserServiceApp не работает с пользователями младше 18 лет. Мок об этом правиле ничего не знает и принимает любые данные.
Мы проверили взаимодействие классов, но потеряли допустимость этого взаимодействия.
И это не ограничивается возрастом пользователя. В реальной системе существуют десятки или сотни правил:
заказ нельзя оформить при наличии задолженности;
email пользователя должен быть уникальным;
сумма операции не должна превышать доступный лимит;
статус сущности должен разрешать переход;
один и тот же ресурс нельзя зарезервировать дважды;
операция должна учитывать состояние другого агрегата.
Часть таких правил можно и нужно инкапсулировать внутри сущностей. Но далеко не каждое правило помещается в конструктор или фабрику одного объекта. Некоторые инварианты зависят от состояния других сущностей, хранилища или доменного сервиса.
Когда мы подменяем такой объект моком, его правила исчезают из теста.
Каждый мок собственного класса с бизнес-правилами потенциально ворует инварианты у тестируемого сценария.
Тест остаётся зелёным, но больше не гарантирует, что проверяемый вызов вообще допустим в настоящей системе.
Это первая проблема - потерянные инварианты.
2. Требования в голове
Предположим, мы понимаем проблему, но не готовы менять подход. Вместо использования настоящего сервиса решаем вручную следить за тем, чтобы в мок попадали только корректные данные.
Мы идём по тестам и меняем возраст пользователей на 18 лет или больше.
Возможно, даже создаём тестовую фабрику:
make_adult_user()
Теперь знание о минимальном возрасте хотя бы не размазано по десяткам тестов.
Если бы это был единственный инвариант системы, такого решения, возможно, было бы достаточно.
Но в реальном приложении правил гораздо больше. Неужели для каждого из них мы создадим отдельную фабрику?
Даже если создадим, разработчик всё равно должен знать:
какую фабрику выбрать;
какие правила действуют в вызываемой зависимости;
какие сочетания данных допустимы;
какие правила фабрика уже учитывает, а какие ещё нет.
Можно возложить контроль на самых опытных разработчиков. Пусть они следят за каждым pull request и проверяют, что во все моки передаются корректные данные.
Но надолго ли их хватит?
Мы разделяли систему на классы и распределяли ответственность между объектами именно для того, чтобы не держать всю систему в голове одновременно.
Однако после мокирования собственных классов ответственность никуда не исчезает. Она просто переезжает из исполняемого кода в память разработчика.
Класс больше не защищает тест своими проверками. Значит, человек должен помнить эти проверки сам.
Мы как будто продолжаем использовать ООП, инкапсуляцию и разделение ответственности, но в тестах добровольно отказываемся от значительной части их пользы.
Чем больше система, тем больше контекста приходится удерживать.
Тест перестаёт использовать знания системы и начинает зависеть от знаний автора теста.
Это вторая проблема - требования в голове.
3. Немые тесты
Теперь представим, что команде некоторое время удаётся поддерживать тестовые данные вручную.
Но есть ещё одна, не всем очевидная проблема - бизнес-правила меняются.
Сегодня минимальный возраст - 18 лет. Завтра из-за законодательства или нового требования он становится равен 21 году.
Мы меняем проверку в UserServiceApp:
Тесты самого сервиса, скорее всего, упадут. Они используют настоящий объект и поэтому увидят изменение его поведения.
А что произойдёт с тестами use cases, в которых этот сервис заменён моком?
Ничего.
Они продолжат передавать пользователей возрастом 18 лет и останутся зелёными.
Мок не знает, что контракт настоящего класса изменился. Между ним и реальным объектом нет обратной связи.
В лучшем случае команда вспомнит про общую фабрику и обновит её. Но для этого сначала нужно знать, что фабрика существует и какие тесты от неё зависят.
В худшем случае данные захардкожены в десятках тестов. Тогда часть тестового набора продолжит описывать уже несуществующее поведение системы.
Такие тесты особенно опасны не потому, что падают, а потому, что не падают, когда должны.
Они выглядят актуальными. Они проходят в CI. Разработчик открывает их, чтобы разобраться в поведении системы, и получает устаревшую информацию.
Тесты не видят изменение настоящего класса - и поэтому молчат.
Это третья проблема - немые тесты.
Значит ли это, что моки использовать нельзя?
Нет!
Проблема не в самом Mock, а в выборе границы, на которой он используется.
Мок хорошо подходит для зависимостей, находящихся за пределами процесса или системы:
HTTP-клиента стороннего API;
почтового провайдера;
платёжного шлюза;
брокера сообщений;
и т. п.
Запускать реальные внешние системы в каждом юнит-тесте действительно не нужно.
Но если зависимость - это наш собственный доменный сервис или класс приложения, содержащий бизнес-правила, его часто безопаснее оставить настоящим.
При этом инфраструктуру под ним можно заменить контролируемыми тестовыми реализациями:
репозиторий -
Fake;внешний клиент(а лучше его транспорт) -
Mock;доменный сервис - настоящий;
тестируемый сценарий - настоящий.
Тогда тест использует те же бизнес-правила, что и production-код, но остаётся быстрым и детерминированным.
Вывод
Один мок собственного класса с бизнес-логикой способен создать сразу три проблемы:
Потерянные инварианты - мок принимает данные, которые настоящий объект отверг бы.
Требования в голове - разработчик вынужден помнить правила замоканного класса и вручную воспроизводить их в тестах.
Немые тесты - когда реальный контракт меняется, зависимые тесты продолжают проходить.
Поэтому перед созданием очередного мока я задаю себе вопрос:
Я подменяю внешнюю границу системы или собственный объект, который знает что-то важное о бизнесе?
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.