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

Комментарии (13)


  1. MountainGoat
    28.11.2025 15:45

    С одной стороны да, а с другой – ну все же видели шутки про enterprise-friendly Hello world на пятьдесят классов. Когда time.now приходится выносить в отдельный класс-генератор, с отдельно описанным интерфейсом, и ещё с моком (уже 3 сущности) только ради теста – начинается плак-плак. Даже если ИИ заставить это всё писать.


  1. Roman_Cherkasov
    28.11.2025 15:45

    А теперь попробуйте написать тест, который проверяет, что ID пользователя равен конкретному значению. Или что CreatedAt равен конкретному времени

    Я может уже сонный конечно или глуповат... Но для чего? Какой кейс это покрывает? Есть сомнения что uuid.New() сгенерирует UUID? или что time.Now()вернет текущее время?


    1. cs0ip
      28.11.2025 15:45

      Это просто пример. Автор в конце пишет про более реалистичные кейсы, когда UUID должен следовать определенным правилам. В дополнение можно привести кейс, когда UUID должен быть сортируемый по времени создания


      1. Roman_Cherkasov
        28.11.2025 15:45

        Я ждал комментарий про "просто пример". Это не просто пример, это плохой пример.
        Проблема таких примеров в том что люди их запоминают. А потом делают вот то что автор делает у себя в статье. Но если автор понимает для чего он это делает (предположим ему действительно надо "генерирует уникальные коды с префиксом на основе времени и счётчика") то люди занесут это без понимания, к себе в проекты. Потому что "в статье на хабре видел".

        У статьи тег "туториал". И ни где не написано о том что делать так конечно не надо, это только для примера.

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

        Этот кусок был дописан после того как я оставил комментарий, вместо того чтобы ответить на комментарий). Но все равно отвечу, что префикс можно и скорее всего нужно хранить в отдельном поле и тестировать правильность его создания, без учета UUID. Плохие архитектурные решения - не повод "превозмогать" и придумывать как обойти это. Обнаружение таких мест - повод задуматься о том где мы не туда свернули и исправить это на уровень выше.


        1. ZergsLaw Автор
          28.11.2025 15:45

          Стоп, вот то, что я кусок дописал после коммента - чистая ложь, он был изначально, хоть админов хабра вызывай и проси историю изменений

          А теперь по поводу твоего коммента: нет, тебе нужно не проверять time.Now - но тебе нужно сделать так, тчообы вызов этой функции не усложнял бы тестирования твоей бизнес логики


          1. Roman_Cherkasov
            28.11.2025 15:45

            О нет, оболгал автора. Посыпаю голову пеплом. Сорян, значит действительно - сонный был и не внимательно читал.

            Но это не отменяет основной претензии - пример плохой и в статью с тегом туториал - не годится


  1. MyraJKee
    28.11.2025 15:45

    Честно говоря тесты в golang довольно утомительно писать руками. И ценность этих проверок сомнительная. У микросервисов обычно не сильно сложная бизнес-логика. Поэтому иногда кажется что многие тесты пишутся ради тестов.


  1. oleganatolievich
    28.11.2025 15:45

    Для времени уже завезли sync Test в Go 1.25


  1. FuN_ViT
    28.11.2025 15:45

    Поздравляю.

    Теперь вы тестируете свои моки!

    Вместо этого стоило проверить, что user.ID!=uuid.Nil (и повторные вызовы CreateUser устанавливают новые значения), а user.CreatedAt>=time.Now (запомненный в начале теста).


    1. Deosis
      28.11.2025 15:45

      А ещё Можно тестировать edge cases, которые появились из-за введения таких интерфейсов.


  1. Sitro23
    28.11.2025 15:45

    Следует отметить, что когда приходит высокая нагрузка, то чистой архитектуре стоит немного подвинуться


    1. ZergsLaw Автор
      28.11.2025 15:45

      Подскажешь, а какая у тебя высокая нагрузка, чтобы ты мог бы столкнутся с проблемами скорости из-за перекладывания структур между слоями, а не упор в БД? :)


      1. Sitro23
        28.11.2025 15:45

        У меня нет высокой нагрузки, поэтому я ни в чём себе не отказываю) БД может не использоваться сильно в высоконагруженной системе.