
На днях подходит ко мне коллега с вопросом: «Слушай, а как в 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, архитектуру и тесты, то милости прошу в канал ?
MountainGoat
С одной стороны да, а с другой – ну все же видели шутки про enterprise-friendly Hello world на пятьдесят классов. Когда time.now приходится выносить в отдельный класс-генератор, с отдельно описанным интерфейсом, и ещё с моком (уже 3 сущности) только ради теста – начинается плак-плак. Даже если ИИ заставить это всё писать.