В unit тесте Go зависимость заменена mock объектом: по какому критерию решить, что вызов зависимости нужно ...

В unit-тесте Go зависимость заменена mock-объектом: по какому критерию решить, что вызов зависимости нужно проверять?

Проходите собеседования с ИИ помощником Hintsage

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

Проверяйте вызов mock только тогда, когда он является частью наблюдаемого контракта тестируемого компонента: например, без него нельзя обеспечить обязательное действие или зафиксировать важное бизнес-правило. Если проверяется лишь внутренняя последовательность вызовов, количество вспомогательных обращений или конкретная реализация, тест становится хрупким.

Главный критерий — изменится ли корректность поведения компонента при нарушении этого взаимодействия. Тест должен в первую очередь проверять результат и состояние, а взаимодействия использовать как проверку необходимого побочного эффекта.

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

Unit-тесты стремятся проверять небольшой компонент изолированно, не подключая базу данных, сеть, очередь или медленный внешний сервис. Для этого реальные зависимости заменяют тестовыми двойниками: заглушками, spy, fake или mock.

Mock особенно удобен, когда важно проверить не только возвращаемое значение, но и обязательное взаимодействие: например, была ли отправлена команда или сохранена запись. Однако чрезмерная фиксация взаимодействий превращает тест в описание текущего устройства кода, а не его контракта.

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

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

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

Обратная ошибка тоже опасна: если вообще не проверять обязательный вызов репозитория, тест может проходить при расчёте правильного результата без фактического сохранения. Такой unit-тест создаёт иллюзию покрытия.

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

Сначала сформулируйте контракт компонента: какие входы он принимает, какой результат возвращает и какие обязательные побочные эффекты выполняет. Проверку результата и ошибок обычно следует делать основной, а проверку mock — только для тех взаимодействий, которые непосредственно следуют из этого контракта.

В Go зависимости обычно задают небольшими интерфейсами. Тестовая реализация или mock реализует такой интерфейс и записывает значимые вызовы, после чего тест проверяет их параметры. Чем меньше интерфейс и чем ближе он к потребителю, тем меньше лишних взаимодействий приходится фиксировать.

package order import "testing" type Saver interface { Save(string) error } type mockSaver struct{ got []string } func (m *mockSaver) Save(id string) error { m.got = append(m.got, id) return nil } func TestCompleteSavesOrder(t *testing.T) { saver := &mockSaver{} completeOrder(saver, "o-7") if len(saver.got) != 1 || saver.got[0] != "o-7" { t.Fatalf("unexpected calls: %v", saver.got) } }

Здесь проверяется обязательное сохранение конкретного заказа, а не внутренний порядок вычислений. В реальном тесте также нужно проверять ошибку Save, если контракт сервиса определяет её обработку: например, возврат ошибки или откат дальнейших действий.

Не следует автоматически требовать ровно один вызов. Такое требование уместно, только если повторный вызов меняет семантику, создаёт побочный эффект или нарушает явный контракт. Если повторные обращения допустимы, проверяйте итоговый эффект либо минимально необходимое свойство взаимодействия.

Stub обычно возвращает заранее заданные данные, не проверяя обращения. Spy записывает факты взаимодействия для последующей проверки. Mock чаще понимают как двойник с заранее заданными ожиданиями и валидацией вызовов; конкретные библиотеки Go могут объединять эти роли, поэтому важнее поведение двойника, а не его название.

Для внешних систем полезно дополнительно иметь интеграционные тесты. Mock хорошо проверяет логику реакции на ответы и ошибки, но не подтверждает совместимость с реальным протоколом, схемой базы данных или настройками сервиса.

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

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

Рассматривались три варианта. Сохранить все ожидания было проще, но это закрепляло детали реализации. Полностью убрать mock-проверки сделало бы тест устойчивым, однако пропустило бы ошибку, при которой событие не отправлялось. Переписать тест на проверку итоговой суммы, обязательной публикации события и обработки ошибок оказалось более сбалансированным решением.

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

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

  1. Дополнительный вопрос: Чем проверка вызова mock отличается от проверки результата и когда она действительно необходима?

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

  2. Дополнительный вопрос: Почему mock с ожиданием точного порядка вызовов может мешать безопасному рефакторингу?

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

  3. Дополнительный вопрос: Почему mock не заменяет интеграционный тест для внешней системы?

    Ответ: Mock воспроизводит только заранее выбранные ответы и правила, заданные тестом. Он не проверяет реальный формат запроса, сериализацию, авторизацию, сетевые таймауты, схему базы данных или семантику внешнего сервиса. Поэтому unit-тест с mock подтверждает логику компонента в изоляции, а интеграционный тест проверяет фактическую границу взаимодействия; эти виды тестов дополняют друг друга.