
На днях подходит ко мне коллега с вопросом: «Слушай, а как в Go сделать замену логики функции в тесте?»
Я уточняю, что он имеет в виду. А он такой: «Ну, хочу monkey patching, чтобы подменять функции из коробки. Типа time.Now возвращала фиксированное время, uuid.New конкретный ID. Чтобы удобно тестироваться».
И тут я, конечно, немного завис :D
Да, технически в Go есть способы делать monkey patching (еще и есть библиотека) через unsafe, через подмену указателей на функции в рантайме. Но это настолько хрупкое и непредсказуемое решение, что я бы не советовал тащить его в продакшен-код. Особенно когда есть нормальный, идиоматичный способ решить эту задачу.
Так что сегодня расскажу, как правильно делать то, что коллега хотел сделать через monkey patching. Спойлер: через интерфейсы и чистую архитектуру. И это будет не просто «работать», а ещё и читаться нормально.
Зачем нужна чистая архитектура?
Давайте сразу договоримся — если у вас вся бизнес-логика размазана по хендлерам HTTP, а работа с базой данных прямо в контроллерах, то вы создаёте себе проблемы на ровном месте.
Слоистость, адаптеры и линия связей
Чистая архитектура — это как слоёный пирог, только вместо крема между слоями у нас интерфейсы. И самое важное правило: зависимости всегда направлены внутрь
То есть ваша бизнес-логика (домен) вообще не знает, откуда к ней приходят данные, к примеру из HTTP-запроса, из gRPC, из консоли или вообще из телеграм-бота.
// Вот так выглядит типичный слой домена type UserService struct { repo UserRepository // <- это интерфейс, а не конкретная реализация! } // А вот так НЕ надо делать type BadUserService struct { db *sql.DB // <- привет, нетестируемый код! }
Уменьшение когнитивной нагрузки
И еще одно из самых важных, когда вы работаете с бизнес-логикой, вам не нужно думать о том, как устроена база данных. Когда пишете HTTP-хендлеры — не надо знать детали бизнес-логики. Каждый слой решает свои задачи.
Представьте: вы новый разработчик в команде. Вам дали задачу: «Добавь валидацию email при регистрации». В проекте с чистой архитектурой вы идёте в слой домена, находите UserService, и всё - можно работать. А в проекте- апше? Удачи найти, где там вообще происходит регистрация среди 500 строк SQL-запросов в HTTP-хендлере :)
Переиспользуемость
А теперь представьте, что завтра вашей команде пришло осознания, что mongo в вашем проекте плохо стало ложится на бизнес структуру и приходится нормализовывать ее
В чистой архитектуре это буквально написание нового адаптера, который дёргает тот же самый сервисный слой. А если у вас логика в HTTP-хендлерах?
И вот мы добрались до самого важного. Главный бенефит чистой архитектуры это тестируемость
Почему? Потому что все зависимости это интерфейсы, которые можно легко замокать
Главные враги тестируемости
Но есть нюанс, даже с чистой архитектурой можно написать нетестируемый код, достаточно просто начать пользоваться синглтонами и функциями внешних пакетов
Как туда вписываются функции?
Вот смотрите, типичный код, который кажется нормальным:
func CreateUser(name string) (*User, error) { user := &User{ ID: uuid.New() // <- проблема №1 Name: name, CreatedAt: time.Now() // <- проблема №2 return user, nil }
А теперь попробуйте написать тест, который проверяет, что ID пользователя равен конкретному значению. Или что CreatedAt равен конкретному времени.
Спойлер: не получится. Потому что uuid.New каждый раз генерирует новый ID, а time.Now возвращает текущее время.
И вот ваш тест превращается в... ЭТО:
func TestCreateUser(t *testing.T) { user, _ := CreateUser("John") // Ну... проверим, что ID не пустой? assert.NotEmpty(t, user.ID) // И что время создания... э... недавнее? assert.WithinDuration(t, time.Now(), user.CreatedAt, time.Second) }
Вы не тестируете логику, вы тестируете, что стандартная библиотека Go работает :)
Создаём обёртки
А теперь смотрите, как надо:
uuid.New → IDGenerator
// Определяем интерфейс type IDGenerator interface { Generate() (uuid.UUID, error) } // Реальная реализация type UUIDGenerator struct{} func (g *UUIDGenerator) Generate() (uuid.UUID, error) { return uuid.New(), nil } // Мок для тестов type MockIDGenerator struct { ID uuid.UUID } func (m *MockIDGenerator) Generate() (uuid.UUID, error) { return m.ID, nil }
time.Now → Clock
// Интерфейс для работы со временем type Clock interface { Now() time.Time } // Реальная реализация type RealClock struct{} func (c *RealClock) Now() time.Time { return time.Now() } // Мок для тестов type MockClock struct { CurrentTime time.Time } func (m *MockClock) Now() time.Time { return m.CurrentTime }
И теперь наш сервис выглядит так:
type UserService struct { idGen IDGenerator clock Clock repo UserRepository } func (s *UserService) CreateUser(name string) (*User, error) { id, err := s.idGen.Generate() if err != nil { return nil, fmt.Errorf("s.idGen.Generate: %w", err) } user := &User{ ID: id, Name: name, CreatedAt: s.clock.Now(), } return s.repo.Save(user) }
А теперь магия!
Смотрите, какие красивые тесты можно писать:
func TestCreateUser(t *testing.T) { // Подготавливаем моки fixedID := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000") fixedTime := time.Date(1996, time.April, 10, 3, 0, 0, 0, time.UTC) mockIDGen := &MockIDGenerator{ID: fixedID} mockClock := &MockClock{CurrentTime: fixedTime} mockRepo := &MockUserRepository{} service := &UserService{ idGen: mockIDGen, clock: mockClock, repo: mockRepo, } user, err := service.CreateUser("John") // Теперь мы можем проверить КОНКРЕТНЫЕ значения :) assert.NoError(t, err) assert.Equal(t, fixedID, user.ID) assert.Equal(t, "John", user.Name) assert.Equal(t, fixedTime, user.CreatedAt) }
Видите разницу? Теперь тест действительно проверяет логику, а не надеется на удачу!
И знаете, что ещё круто? Можно тестировать edge cases:
func TestCreateUser_WhenIDGeneratorFails(t *testing.T) { failingIDGen := &FailingIDGenerator{ Error: errors.New("генератор сломался"), } service := &UserService{idGen: failingIDGen} _, err := service.CreateUser("John") assert.Error(t, err) assert.Contains(t, err.Error(), "генератор сломался") }
Попробуйте такое протестировать с глобальным uuid.New()
А что насчёт других функций?
Тот же принцип работает для всего:
rand.Intn()→RandomGeneratoros.Getenv()→ConfigProviderhttp.Get()→HTTPClientДаже
fmt.Println()можно обернуть вLogger!
Правило простое: если функция имеет побочные эффекты или недетерминированное поведение — оборачивайте в интерфейс
Выводы
Чистая архитектура = тестируемость — когда все зависимости явные и передаются через конструктор, их легко подменить моками
Глобальные функции — враг тестов —
time.Now(),uuid.New()и прочие делают тесты недетерминированнымиИнтерфейсы — наше всё — оборачивайте внешние зависимости, и ваш код станет тестируемым автоматически
Моки = контроль — хотите проверить, что будет при сбое генератора ID? С моками можно эмулировать любое поведение
И помните: если писать тесты сложно — проблема не в тестах, а в архитектуре. Правильная архитектура делает тесты простыми и приятными.
P.S. Если кто-то скажет, что это оверинжиниринг для простого uuid.New() — попросите их протестировать код, который генерирует уникальные коды с префиксом на основе времени и счётчика. А потом посмотрите, как они будут страдать с time.Sleep() в тестах :)
P.P.S. Ну и как обычно — если хочешь видеть больше контента про Go, архитектуру и тесты, то милости прошу в канал 🙂
