Программирование GoТестированиеGo-разработчик, отвечающий за проектирование сервисов и их unit-тестов

Зачем потребитель зависимости в Go обычно объявляет собственный интерфейс для mock тестов?

Зачем потребитель зависимости в Go обычно объявляет собственный интерфейс для mock-тестов?

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

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

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

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

В Go интерфейсы реализуются неявно: типу не нужно заранее объявлять намерение реализовать интерфейс. Поэтому интерфейс удобно определять рядом с кодом, который использует поведение, а не обязательно в пакете реализации.

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

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

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

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

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

Интерфейс следует размещать в пакете-потребителе и делать настолько маленьким, насколько позволяет его код. Например, если сервису нужен только поиск записи, ему не требуется зависеть от полного интерфейса хранилища с операциями сохранения, удаления и транзакциями.

package service import "testing" type Finder interface { Find(string) (string, error) } type mockFinder struct{ value string } func (m mockFinder) Find(string) (string, error) { return m.value, nil } func TestLoad(t *testing.T) { f := mockFinder{value: "ok"} got, err := load(f, "id") if err != nil || got != "ok" { t.Fatalf("got %q, %v", got, err) } }

Здесь service владеет контрактом Finder, потому что именно ему известно, какое поведение необходимо. Реальный тип хранилища автоматически подходит этому интерфейсу, а тест использует простой mock без реализации нерелевантных методов.

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

Полезная дополнительная проверка — compile-time assertion в пакете реализации, если важно явно зафиксировать совместимость с интерфейсом потребителя. Но это создаёт зависимость реализации от пакета потребителя, поэтому её стоит применять осознанно, особенно чтобы не получить циклический импорт.

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

Сервис заказов зависел от интерфейса Repository, объявленного в пакете базы данных. В интерфейсе было двенадцать методов, хотя сервис использовал только Find и Save. После добавления метода миграции тестовый mock перестал компилироваться, хотя поведение сервиса не изменилось.

Рассматривались два варианта. Можно было расширить mock новым методом, но это сохраняло связанность с чужим большим интерфейсом и увеличивало стоимость будущих изменений. Можно было оставить интерфейс в пакете базы данных, но выделить отдельный маленький интерфейс — это уменьшало mock, однако потребитель всё ещё зависел бы от структуры API производителя.

Выбрали интерфейсы в пакете сервиса: OrderReader с Find и OrderWriter с Save. Реальный репозиторий стал неявно удовлетворять обоим контрактам, а тестовые doubles реализовали только необходимые операции. В результате изменение API базы данных больше не ломало тесты сервиса без изменения используемого поведения.

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

  1. Маленький интерфейс автоматически делает mock корректным?

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

  1. Нужно ли объявлять интерфейс для каждой зависимости только потому, что её подменяют в unit-тесте?

Нет. Абстракция оправдана, когда она выражает устойчивую границу поведения или действительно нужна для изоляции. Механическая генерация интерфейсов для каждого типа усложняет код и может скрыть простую зависимость. Иногда достаточно передать небольшую функцию, если зависимость сводится к одной операции.

  1. Кто должен изменять интерфейс при добавлении новой возможности реализации?

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