3. Ручные моки
Многие инженеры, только начав знакомство с языком Go, по привычке из других экосистем сразу же тянутся к тяжеловесным генераторам кода (gomock, mockery), как только в их поле зрения появляется новый интерфейс. Однако в культуре опытных Go-разработчиков исторически сформировалось глубокое уважение к ручным мокам (Hand-written Mocks).
Для Senior-инженера умение быстро и лаконично сконструировать мок своими руками — это не академическое упражнение, а способ держать проект быстрым, архитектурно чистым и независимым от внешних сборочных пайплайнов. Ручной мок на 20 строк прозрачен, лишен скрытой магии и не требует бесконечного перезапуска кодогенерации при каждой правке контракта.
Структура идеального ручного мока
Качественный рукотворный тестовый дублер решает две фундаментальные задачи:
- Stubbing (Поставка ответов): Возвращает детерминированные данные или ошибки для управления потоком исполнения.
- Spying (Шпионаж): Протоколирует каждый входящий вызов (аргументы, порядок, число обращений) для последующей верификации в ассертах.
Рассмотрим проектирование мока для интерфейса почтового транспорта:
type EmailSender interface {
Send(ctx context.Context, to, subject, body string) error
}
Паттерн "Функциональный мок"
Самый гибкий и элегантный способ ручной реализации в Go основан на использовании полей-функций первого класса. Вместо того чтобы жестко фиксировать возвращаемые значения в полях структуры или создавать десятки специализированных структур под каждый случай, мы объявляем функциональные поля. Это дает возможность конфигурировать поведение мока прямо «на лету» внутри подтестов t.Run.
// email_mock_test.go
type EmailSenderMock struct {
// Поле-функция позволяет переопределять поведение в конкретном тест-кейсе
SendFunc func(ctx context.Context, to, subject, body string) error
// Поля для сбора улик и шпионажа (Spying)
Calls []SendCall
}
type SendCall struct {
To, Subject, Body string
}
func (m *EmailSenderMock) Send(ctx context.Context, to, subject, body string) error {
// 1. Протоколируем факт и аргументы вызова
m.Calls = append(m.Calls, SendCall{to, subject, body})
// 2. Делегируем исполнение пользовательской функции, если она задана
if m.SendFunc != nil {
return m.SendFunc(ctx, to, subject, body)
}
return nil // Дефолтное безопасное поведение
}
flowchart TD
subgraph TestScenario["Тестовый сценарий (t.Run)"]
Init["Инициализация мока:<br/>mock.SendFunc = func(...) error"]
Exec["Вызов тестируемой функции:<br/>NotifyUser(ctx, mock, email)"]
Assert["Проверка инвариантов:<br/>len(mock.Calls) == 1"]
end
subgraph MockStructure["EmailSenderMock"]
Calls["Слайс Calls: []SendCall<br/>(История вызовов для Spying)"]
FuncField["Поле SendFunc: func(...)<br/>(Поведение для Stubbing)"]
Method["Метод Send(ctx, to, ...) error<br/>1. append в Calls<br/>2. вызов SendFunc"]
end
Init --> FuncField
Exec --> Method
Method --> Calls
Method --> FuncField
Exec --> Assert
Calls --> Assert
style TestScenario fill:#0f172a,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
style MockStructure fill:#1e1b4b,stroke:#a855f7,stroke-width:2px,color:#f8fafc
style Method fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#6ee7b7
Использование в тестах
Благодаря функциональному паттерну один и тот же мок безупречно обслуживает любые ветвления логики:
func TestNotifyUser(t *testing.T) {
t.Run("success", func(t *testing.T) {
mock := &EmailSenderMock{
SendFunc: func(ctx context.Context, to, subject, body string) error {
return nil
},
}
err := NotifyUser(context.Background(), mock, "test@test.com")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
// Проверяем инвариант шпионажа: ровно один вызов
if len(mock.Calls) != 1 {
t.Errorf("expected 1 call, got %d", len(mock.Calls))
}
})
t.Run("gateway error", func(t *testing.T) {
mock := &EmailSenderMock{
SendFunc: func(ctx context.Context, to, subject, body string) error {
return errors.New("network error")
},
}
err := NotifyUser(context.Background(), mock, "test@test.com")
if err == nil {
t.Error("expected error, got nil")
}
})
}
Mechanical Sympathy: Слайсы и конкурентность
При проектировании моков для конкурентных подсистем инженер обязан помнить о физическом устройстве динамических массивов и памяти в Go:
- Утечки памяти в долгоживущих моках: Если экземпляр мока сохраняется на протяжении всего тестового цикла (например, объявлен в пакете или инициализирован в функции
TestMain), слайсCallsбудет непрерывно аккумулировать структуры с аргументами, не позволяя сборщику мусора освободить память. В коротких Unit-тестах это несущественно, но в масштабных интеграционных сьютах может вызвать существенное раздувание RSS-памяти процесса. - Гонки данных (Data Races): Срез в Go — это заголовок из трех машинных слов (
Data,Len,Cap). Если тестируемый код вызывает метод мока параллельно из нескольких горутин, операцияm.Calls = append(...)приведет к разрушению структуры заголовка и падению с Data Race.
Преимущества ручных моков перед генераторами
- Кристальная ясность (Zero Magic): Любой член команды может нажать клавишу
F12в IDE и мгновенно прочитать три строчки реализации. Сгенерированные файлы на 500 строк с дебрями пакетаreflectи строковыми сопоставлениями параметров превращают чтение тестов в тяжелый труд. - Мгновенная компиляция: Отсутствуют промежуточные шаги генерации в Makefile или CI-пайплайне.
- Статическая типобезопасность на этапе компиляции: Если вы измените сигнатуру интерфейса, компилятор Go сразу укажет на несоответствие методов в вашем ручном моке. При использовании генераторов моков несоответствие вскроется лишь в рантайме или после ручного повторного запуска команды
go generate.
Когда ручные моки НЕ нужны?
Если вы столкнулись с устаревшим или чужим интерфейсом, насчитывающим 15 и более методов, ручная реализация каждого из них превратится в монотонное перепечатывание шаблонного кода. Это единственный сценарий, когда привлечение внешних генераторов становится неизбежным злом.
Однако, как мы помним из главы 2. Интерфейсы как инструмент тестирования, существование раздутого интерфейса — это прежде всего сигнал о нарушении принципа ISP и повод для проведения архитектурного рефакторинга.
Итог
- Ручной мок — это простая структура, реализующая доменный интерфейс через функциональные поля и слайсы шпионажа.
- Паттерн «Функциональный мок» позволяет гибко конфигурировать поведение прямо в подтестах
t.Runбез создания новых типов. - При параллельном тестировании горутин всегда защищайте слайс
Callsмьютексом от состояния гонки. - Ручные дублеры обеспечивают 100% контроль компилятора, прозрачную навигацию по
F12и избавляют проект от нестабильных генераторов.
Когда интерфейсы системы действительно велики, а ручное написание кода становится экономически нецелесообразным, на помощь приходит официальный инструментарий кодогенерации Go.
О том, как работает флагман генерации моков, мы поговорим в следующей главе: 4. gomock. Генерация моков.