golang

Monkey patching? В Go? Серьёзно? Или как писать тесты и не сойти сума

  • суббота, 29 ноября 2025 г. в 00:00:04
https://habr.com/ru/articles/971190/

На днях подходит ко мне коллега с вопросом: «Слушай, а как в 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()RandomGenerator

  • os.Getenv()ConfigProvider

  • http.Get()HTTPClient

  • Даже fmt.Println() можно обернуть в Logger!

Правило простое: если функция имеет побочные эффекты или недетерминированное поведение — оборачивайте в интерфейс

Выводы

  1. Чистая архитектура = тестируемость — когда все зависимости явные и передаются через конструктор, их легко подменить моками

  2. Глобальные функции — враг тестовtime.Now(), uuid.New() и прочие делают тесты недетерминированными

  3. Интерфейсы — наше всё — оборачивайте внешние зависимости, и ваш код станет тестируемым автоматически

  4. Моки = контроль — хотите проверить, что будет при сбое генератора ID? С моками можно эмулировать любое поведение

И помните: если писать тесты сложно — проблема не в тестах, а в архитектуре. Правильная архитектура делает тесты простыми и приятными.


P.S. Если кто-то скажет, что это оверинжиниринг для простого uuid.New() — попросите их протестировать код, который генерирует уникальные коды с префиксом на основе времени и счётчика. А потом посмотрите, как они будут страдать с time.Sleep() в тестах :)

P.P.S. Ну и как обычно — если хочешь видеть больше контента про Go, архитектуру и тесты, то милости прошу в канал 🙂