Модуль: Тестирование · Уровень: Middle+/Senior

TL;DR#

В Go тестируемость строится на интерфейсах, объявленных на стороне потребителя (узких, в 1-3 метода), и dependency injection через конструкторы/поля структуры. Терминология: стаб возвращает заранее заданные ответы; мок дополнительно проверяет, как и сколько раз его вызвали (verify expectations); fake — упрощённая рабочая реализация (in-memory БД); spy — записывает вызовы для последующей проверки. Варианты: ручные моки (явные, читаемые, без зависимостей — идиоматичный дефолт для узких интерфейсов), gomock (кодогенерация, строгие expectations, контроль порядка), testify/mock (рефлексия, гибко, но рантайм-ошибки и многословно). Принцип Go: «accept interfaces, return structs», интерфейс определяет тот, кто его использует, а не тот, кто реализует.

Теория#

Интерфейсы для тестируемости — на стороне потребителя#

Антипаттерн (из Java/C#): большой интерфейс рядом с реализацией. Идиома Go: потребитель объявляет ровно то подмножество, что ему нужно.

// package order — потребитель объявляет узкий интерфейс
type PaymentGateway interface {
    Charge(ctx context.Context, amount Money, token string) (TxID, error)
}

type Service struct {
    pay PaymentGateway // зависимость инжектится
}

func NewService(pay PaymentGateway) *Service { return &Service{pay: pay} }

func (s *Service) Checkout(ctx context.Context, o Order) error {
    _, err := s.pay.Charge(ctx, o.Total, o.Token)
    return err
}

Реальный stripe.Client имеет десятки методов, но order зависит лишь от Charge. Это сужает поверхность мока и делает зависимости явными.

Dependency Injection в Go#

DI в Go — это обычно просто передача зависимостей в конструктор/поле, без фреймворков:

svc := NewService(realGateway)        // прод
svc := NewService(&mockGateway{...})  // тест

Для крупных графов зависимостей — google/wire (compile-time codegen) или uber/fx (runtime). На собеседовании важнее показать, что DI — это про инверсию контроля и явные зависимости, а не про конкретный фреймворк.

Ручные моки/стабы#

type stubGateway struct {
    chargeFn func(ctx context.Context, amount Money, token string) (TxID, error)
}

func (s *stubGateway) Charge(ctx context.Context, amount Money, token string) (TxID, error) {
    return s.chargeFn(ctx, amount, token)
}

func TestCheckout_PaymentFails(t *testing.T) {
    svc := NewService(&stubGateway{
        chargeFn: func(_ context.Context, _ Money, _ string) (TxID, error) {
            return "", errors.New("declined")
        },
    })
    if err := svc.Checkout(context.Background(), Order{}); err == nil {
        t.Fatal("expected error")
    }
}

Паттерн «поле-функция» (chargeFn) — гибкий ручной мок: каждый тест задаёт поведение инлайн. Для verify добавляют счётчики/спайны:

type spyGateway struct {
    calls []chargeCall
    ret   error
}
func (s *spyGateway) Charge(_ context.Context, a Money, tok string) (TxID, error) {
    s.calls = append(s.calls, chargeCall{a, tok})
    return "tx1", s.ret
}
// в тесте: assert len(spy.calls)==1, spy.calls[0].amount == ...

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

gomock (go.uber.org/mock — актуальный форк)#

Кодогенерация + строгие expectations.

mockgen -source=gateway.go -destination=mocks/gateway.go -package=mocks
func TestCheckout_gomock(t *testing.T) {
    ctrl := gomock.NewController(t)
    m := mocks.NewMockPaymentGateway(ctrl)

    m.EXPECT().
        Charge(gomock.Any(), Money(100), "tok").
        Return(TxID("tx1"), nil).
        Times(1)

    svc := NewService(m)
    _ = svc.Checkout(context.Background(), Order{Total: 100, Token: "tok"})
    // ctrl автоматически проверит ожидания на t.Cleanup (в новых версиях)
}
  • EXPECT() задаёт ожидаемый вызов с матчерами аргументов и числом вызовов (Times, AnyTimes, MinTimes).
  • gomock.InOrder(...) контролирует порядок.
  • Незаматченные обязательные вызовы → fail. Неожиданные вызовы → немедленный fail.
  • Плюсы: строгая verification, контроль порядка, матчеры, генерация из интерфейса. Минусы: кодоген в pipeline, многословность, легко переспецифицировать тест (хрупкость к рефакторингу).

testify/mock#

Рефлексивный мок без кодогенерации.

type MockGateway struct{ mock.Mock }

func (m *MockGateway) Charge(ctx context.Context, a Money, tok string) (TxID, error) {
    args := m.Called(ctx, a, tok)
    return args.Get(0).(TxID), args.Error(1)
}

func TestCheckout_testify(t *testing.T) {
    m := new(MockGateway)
    m.On("Charge", mock.Anything, Money(100), "tok").Return(TxID("tx1"), nil)

    svc := NewService(m)
    _ = svc.Checkout(context.Background(), Order{Total: 100, Token: "tok"})
    m.AssertExpectations(t)
}
  • Поведение задаётся через .On("MethodName", args...).Return(...).
  • AssertExpectations, AssertCalled, AssertNumberOfCalls.
  • Плюсы: без кодогена, гибко, знакомо. Минусы: имена методов строками → опечатки и рассинхрон ловятся в рантайме, args.Get(0).(T) — type assertion без compile-time проверки, многословные реализации методов.

Сравнение#

Ручныеgomocktestify/mock
Зависимостинетcodegen toolбиблиотека
Проверка сигнатурcompile-timecompile-time (codegen)runtime (строки)
Verify expectationsрукамивстроено, строговстроено
Контроль порядкарукамиInOrderруками
Бойлерплейтсредний (растёт с интерфейсом)низкий (генерится)высокий (методы руками)
Хрупкость тестанизкаявысокая (переспецификация)средняя

Рекомендация (идиома Go): узкий интерфейс (1-3 метода) → ручной мок. Широкий интерфейс или нужна строгая verification/порядок → gomock. testify/mock — если уже завязаны на testify-экосистему и не хотите codegen.

Когда мок не нужен#

  • Fake вместо мока: in-memory реализация репозитория часто лучше мока — тестирует через реальное поведение, не привязана к последовательности вызовов, переиспользуется.
  • Чистые функции не требуют моков вовсе.
  • Не мокайте то, что не владеете напрямую (сторонние типы) — оберните в свой узкий интерфейс и мокайте его.
  • Не мокайте стандартную библиотеку/БД-драйвер — используйте testcontainers/реальную зависимость для интеграционных тестов.

Подводные камни / gotchas#

  • Over-mocking превращает тест в зеркало реализации: проверяете, что код вызвал такие-то методы в таком порядке, а не что он даёт правильный результат. Рефакторинг ломает тесты при корректном поведении.
  • testify/mock строковые имена — рассинхрон с реальным методом ловится в рантайме, не компилятором. Переименовали метод — мок молча устарел.
  • gomock переспецификация — указали точные аргументы/Times там, где это неважно, → хрупкость. Используйте gomock.Any()/AnyTimes() для несущественного.
  • Широкие интерфейсы провоцируют мок-ад. Сужайте интерфейсы на стороне потребителя (ISP).
  • Забыли verifym.AssertExpectations(t) / ctrl.Finish(). В новых gomock контроллер регистрируется через t.Cleanup автоматически, в старых нужен явный defer ctrl.Finish().
  • Мок возвращает не то по умолчанию — незаданный вызов в testify паникует, в gomock — fail. Проверяйте контракты.
  • Конкурентность: моки с накоплением вызовов в срез не потокобезопасны — при тестировании конкурентного кода защищайте мьютексом.
  • Интерфейс ради мока. Не плодите интерфейсы только чтобы что-то замокать — это запах. Интерфейс должен иметь смысл в дизайне.

Вопросы на собеседовании#

В: В чём разница между стабом, моком, fake и spy? О: Стаб отдаёт заранее заданные ответы. Мок дополнительно верифицирует взаимодействие (какие методы, с какими аргументами, сколько раз). Fake — упрощённая рабочая реализация (in-memory БД). Spy записывает вызовы для последующей проверки. Различие мок/стаб — в том, проверяем ли мы поведение (interaction) или только подменяем ответ (state).

В: Почему в Go интерфейсы объявляют на стороне потребителя? О: Чтобы зависеть от минимального контракта (ISP), сузить поверхность мока и не тащить лишние методы. «Accept interfaces, return structs»: реализация возвращает конкретный тип, а потребитель объявляет узкий интерфейс под свои нужды. Это уменьшает связанность и упрощает тестирование.

В: Когда предпочесть ручной мок, а когда gomock/testify? О: Узкий интерфейс (1-3 метода) — ручной мок: читаемо, без зависимостей, compile-time контроль сигнатур. Широкий интерфейс или нужна строгая verification и контроль порядка — gomock (кодоген, строгие expectations). testify/mock — если уже в testify-экосистеме и не хотим codegen, ценой рантайм-проверок по строковым именам.

В: Чем опасен over-mocking? О: Тест начинает проверять реализацию, а не поведение: ассертит конкретную последовательность вызовов. Любой корректный рефакторинг ломает такие тесты, они дают ложные срабатывания и тормозят изменения. Часто лучше fake или проверка результата вместо взаимодействий.

В: Какой главный недостаток testify/mock по сравнению с gomock? О: Имена методов задаются строками (.On("Charge", ...)) и возвраты через args.Get(0).(T) — рассинхрон с интерфейсом и неверные типы ловятся в рантайме, а не компилятором. gomock генерится из интерфейса и проверяется на этапе компиляции.

В: Что такое DI в Go и нужен ли фреймворк? О: DI — передача зависимостей извне (через конструктор/поле) вместо создания внутри, для инверсии контроля и подмены в тестах. В Go обычно достаточно ручной передачи в конструктор. Для больших графов — wire (compile-time codegen) или fx (runtime), но это опционально.

В: Когда мок не нужен и что использовать вместо него? О: Для чистых функций — ничего. Для репозиториев часто лучше fake (in-memory) — тестирует через реальное поведение и не хрупок к порядку вызовов. Для БД/внешних сервисов в интеграционных тестах — реальная зависимость через testcontainers. Сторонние типы оборачивают в свой узкий интерфейс и мокают его.

В: Как тестировать код, зависящий от времени/рандома? О: Инжектить зависимость: интерфейс Clock с методом Now() (или функция now func() time.Time), источник случайности как io.Reader/*rand.Rand. В тесте подменять детерминированной реализацией. Не дёргать time.Now()/rand напрямую в логике.

На что копают на senior+#

  • Дизайн контрактов: ISP, узкие интерфейсы на стороне потребителя, «accept interfaces return structs», отказ от интерфейсов-ради-мока.
  • State vs interaction testing: понимание, когда проверять результат (предпочтительно), а когда взаимодействие; вред over-mocking для рефакторинга.
  • Fakes как первый выбор: in-memory реализации, contract tests (один набор тестов против fake и реальной реализации).
  • Тестируемость дизайна: инъекция времени/рандома/UUID, гексагональная архитектура/порты-адаптеры, границы для моков.
  • Trade-offs инструментов: compile-time vs runtime безопасность, хрупкость переспецифицированных моков, цена кодогена в pipeline.
  • Конкурентность: потокобезопасность моков, верификация в конкурентных тестах, -race.