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

Roman_Cherkasov
28.11.2025 15:45А теперь попробуйте написать тест, который проверяет, что ID пользователя равен конкретному значению. Или что
CreatedAtравен конкретному времениЯ может уже сонный конечно или глуповат... Но для чего? Какой кейс это покрывает? Есть сомнения что uuid.New() сгенерирует UUID? или что
time.Now()вернет текущее время?
cs0ip
28.11.2025 15:45Это просто пример. Автор в конце пишет про более реалистичные кейсы, когда UUID должен следовать определенным правилам. В дополнение можно привести кейс, когда UUID должен быть сортируемый по времени создания

Roman_Cherkasov
28.11.2025 15:45Я ждал комментарий про "просто пример". Это не просто пример, это плохой пример.
Проблема таких примеров в том что люди их запоминают. А потом делают вот то что автор делает у себя в статье. Но если автор понимает для чего он это делает (предположим ему действительно надо "генерирует уникальные коды с префиксом на основе времени и счётчика") то люди занесут это без понимания, к себе в проекты. Потому что "в статье на хабре видел".У статьи тег "туториал". И ни где не написано о том что делать так конечно не надо, это только для примера.
P.S. Если кто-то скажет, что это оверинжиниринг для простого
uuid.New()— попросите их протестировать код, который генерирует уникальные коды с префиксом на основе времени и счётчика. А потом посмотрите, как они будут страдать сtime.Sleep()в тестах :)Этот кусок был дописан после того как я оставил комментарий, вместо того чтобы ответить на комментарий). Но все равно отвечу, что префикс можно и скорее всего нужно хранить в отдельном поле и тестировать правильность его создания, без учета UUID. Плохие архитектурные решения - не повод "превозмогать" и придумывать как обойти это. Обнаружение таких мест - повод задуматься о том где мы не туда свернули и исправить это на уровень выше.

ZergsLaw Автор
28.11.2025 15:45Стоп, вот то, что я кусок дописал после коммента - чистая ложь, он был изначально, хоть админов хабра вызывай и проси историю изменений
А теперь по поводу твоего коммента: нет, тебе нужно не проверять time.Now - но тебе нужно сделать так, тчообы вызов этой функции не усложнял бы тестирования твоей бизнес логики
Roman_Cherkasov
28.11.2025 15:45О нет, оболгал автора. Посыпаю голову пеплом. Сорян, значит действительно - сонный был и не внимательно читал.
Но это не отменяет основной претензии - пример плохой и в статью с тегом туториал - не годится

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

FuN_ViT
28.11.2025 15:45Поздравляю.
Теперь вы тестируете свои моки!
Вместо этого стоило проверить, что user.ID!=uuid.Nil (и повторные вызовы CreateUser устанавливают новые значения), а user.CreatedAt>=time.Now (запомненный в начале теста).
Deosis
28.11.2025 15:45А ещё Можно тестировать edge cases, которые появились из-за введения таких интерфейсов.

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

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

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