Модуль: Тестирование · Уровень: 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=mocksfunc 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 проверки, многословные реализации методов.
Сравнение#
| Ручные | gomock | testify/mock | |
|---|---|---|---|
| Зависимости | нет | codegen tool | библиотека |
| Проверка сигнатур | compile-time | compile-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).
- Забыли verify —
m.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.