Привет, Хабр!
Cегодня рассмотрим, как мокировать зависимости в Go.
Зачем вообще тратить время, чтобы мокировать зависимости? Мокирование — это замена реальных зависимостей на предсказуемые заглушки для изолированного и быстрого тестирования. Вместо реальной БД или внешнего API подставляем stub, mock или fake, которые возвращают заранее определённые результаты или фиксируют вызовы. В Go это реализуется через интерфейсы и dependency injection: определяется контракт (интерфейс) и используем его в коде, а в тестах подставляем нужную заглушку.
Интерфейсы и Dependency Injection
Начнём с основ. В Go интерфейсы — это не просто синтаксический сахар, а движущая сила модульного и тестируемого кода. Если код напрямую зависит от конкретной реализации (скажем, доступа к базе данных), то изменения в реализации или тестирование может стать проблемой. Именно поэтому вырезаем эту жёсткую связь, создавая абстрактный контракт, описывающий нужное поведение, и внедряя зависимости через конструкторы. Это и есть Dependency Injection.
Для примера определим интерфейс DB, который содержит один метод Query
, и дальше построим бизнес‑логику так, чтобы она зависела от этого контракта, а не от конкретной реализации. Это позводит подменять реальные объекты на тестовые двойники — stub
, mock
или fake
:
package main import ( "database/sql" "fmt" ) // DB описывает контракт для работы с базой данных. type DB interface { Query(query string, args ...interface{}) (*sql.Rows, error) } // RealDB – реальная реализация для продакшена. type RealDB struct { *sql.DB } func (r *RealDB) Query(query string, args ...interface{}) (*sql.Rows, error) { return r.DB.Query(query, args...) } // Service использует зависимость DB для получения данных. type Service struct { db DB } // NewService внедряет зависимость через интерфейс. func NewService(db DB) *Service { return &Service{db: db} } // GetUserName получает имя пользователя по его ID. func (s *Service) GetUserName(userID int) (string, error) { rows, err := s.db.Query("SELECT name FROM users WHERE id = ?", userID) if err != nil { return "", err } defer rows.Close() if rows.Next() { var name string if err := rows.Scan(&name); err != nil { return "", err } return name, nil } return "", fmt.Errorf("user not found") }
Интерфейс DB определяет набор методов, необходимых для работы с базой данных. При этом Go не требует явного указания, что тип реализует интерфейс — достаточно, чтобы его методы соответствовали контракту.
Объект RealDB
использует стандартный sql.DB для работы с базой. Но в тестах можно подставить другой тип, реализующий интерфейс DB — будь то stub
, который всегда возвращает ошибку, или mock
, который фиксирует вызовы и проверяет параметры, или fake
, который имитирует работу базы данных в оперативной памяти.
Далее рассмотрим, как именно подменять зависимости в тестах с помощью stub
, mock
и fake
, а также рассмотрим инструменты автоматической генерации моков.
stub, mock, fake — выбирайте по ситуации
stub — простая заглушка
stub — это самый простой вид тестового двойника, предназначенный для возвращения заранее определённых значений. Он не следит за тем, как его вызывают, не записывает параметры и вообще не пытается имитировать логику настоящей зависимости. Если цель — проверить, как система реагирует на конкретный ответ, stub — отличный выбор.
stub реализует метод, возвращая фиксированный результат или ошибку. Он не хранит информацию о вызовах, что упрощает реализацию, но не позволяет проверять корректность вызова.
Пример:
package main import ( "database/sql" "errors" "fmt" ) // StubDB реализует интерфейс DB, всегда возвращая заранее заданную ошибку. type StubDB struct{} func (s *StubDB) Query(query string, args ...interface{}) (*sql.Rows, error) { // Всегда возвращаем ошибку для проверки обработки ошибок в коде. return nil, errors.New("stub: метод Query не реализован") } func Example_GetUserName_Error() { service := NewService(&StubDB{}) name, err := service.GetUserName(1) if err == nil { panic("ожидалась ошибка, но её не получили") } fmt.Printf("Ошибка: %s, Имя: %s\n", err.Error(), name) }
StubDB — это простейшая заглушка, которая не заботится о входных параметрах, а просто выбрасывает ошибку.
mock — всё запоминает
mock — это уже следующий уровень. Здесь не только возвращаем фиксированные значения, но и записываем, как именно происходят вызовы. Такой двойник помогает проверить, что метод вызывается с нужными параметрами, и что последовательность вызовов соответствует ожиданиям. Часто для этого используют библиотеки вроде gomock или testify/mock, но можно написать и свой mock.
mock хранит информацию о том, какие параметры были переданы, сколько раз метод был вызван и т. п. Можно проверить, что код вызывает методы зависимости с правильными аргументами.
Пример:
package main import ( "database/sql" "fmt" "reflect" ) // MockDB фиксирует вызовы метода Query и сохраняет переданные параметры. type MockDB struct { Called bool ExpectedQuery string ExpectedArgs []interface{} } func (m *MockDB) Query(query string, args ...interface{}) (*sql.Rows, error) { m.Called = true m.ExpectedQuery = query m.ExpectedArgs = args // Для простоты возвращаем пустой sql.Rows; в реальном тесте можно симулировать поведение return &sql.Rows{}, nil } func Example_GetUserName_Mock() { mockDB := &MockDB{} service := NewService(mockDB) _, _ = service.GetUserName(1) // Проверяем, что метод был вызван if !mockDB.Called { panic("метод Query не был вызван") } // Проверяем, что запрос соответствует ожидаемому if mockDB.ExpectedQuery != "SELECT name FROM users WHERE id = ?" { panic("запрос не соответствует ожидаемому") } // Проверяем, что аргументы вызова верны if !reflect.DeepEqual(mockDB.ExpectedArgs, []interface{}{1}) { panic("аргументы запроса не совпадают с ожидаемыми") } fmt.Println("Mock вызван успешно с:", mockDB.ExpectedQuery) }
MockDB записывает факт вызова и параметры, позволяя проверить, как именно работает код при взаимодействии с зависимостью.
fake
fake — это полноценная симуляция системы, только работающая в оперативной памяти. fake по сути является упрощённой копией настоящей зависимости. Например, fake база данных может использовать in‑memory map, чтобы имитировать реальные операции.
Пример:
package main import ( "database/sql" "fmt" ) // FakeDB симулирует работу базы данных с использованием in-memory map. type FakeDB struct { data map[int]string } // NewFakeDB создаёт новый fake с предзагруженными данными. func NewFakeDB() *FakeDB { return &FakeDB{ data: map[int]string{ 1: "Alice", 2: "Bob", }, } } func (f *FakeDB) Query(query string, args ...interface{}) (*sql.Rows, error) { // Извлекаем userID из аргументов и проверяем тип. userID, ok := args[0].(int) if !ok { return nil, fmt.Errorf("ожидался int, получили %T", args[0]) } // Если пользователь найден – имитируем успешный ответ. if name, exists := f.data[userID]; exists { fmt.Printf("Найден пользователь: %s\n", name) // Здесь можно создать искусственный объект sql.Rows, // но для упрощения демонстрации возвращаем nil. return nil, nil } return nil, fmt.Errorf("пользователь с id %d не найден", userID) } func Example_GetUserName_Fake() { fakeDB := NewFakeDB() service := NewService(fakeDB) name, err := service.GetUserName(1) if err != nil { panic(err) } fmt.Println("Имя пользователя:", name) }
FakeDB хранит данные, проверяет входные параметры и возвращает результат, максимаьно приближённый к реальной реализации.
Выбор подхода
-
Stub: если нужно проверить реакцию на конкретный, предопределённый результат, например, на ошибку.
-
Mock: применяйте, когда необходимо убедиться, что взаимодействие с зависимостью происходит корректно — с правильными параметрами и в нужном количестве.
-
Fake: идеален для тестов, где требуется более реалистичное поведение зависимости, чтобы проверить интеграцию различных частей системы в условиях, приближённых к настоящим.
Можно вообще комбинировать эти подходы в зависимости от сложности тестируемого кейса. Иногда достаточно stub‑а для проверки негативного сценария, а иногда требуется полноценный fake, чтобы убедиться, что все части системы правильно взаимодействуют друг с другом.
Генерация моков: gomock и mockery
Gomock
Gomock — это инструмент от команды Go, который генерирует реализации ваших интерфейсов и позволяет задавать ожидания вызовов. Основная идея в том, что вы создаёте контроллер, а затем с помощью метода EXPECT() задаёте, какие вызовы должны произойти, с какими аргументами, и какой результат вернуть.
Установим:
go get github.com/golang/mock/gomock go install github.com/golang/mock/mockgen@latest
Генерация мока:
Предположим, есть интерфейс DB в файле main.go. Запускаем команду:
package main import ( "database/sql" "testing" "github.com/golang/mock/gomock" "myproject/mocks" ) func TestGetUserNameWithGoMock(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockDB := mocks.NewMockDB(ctrl) // Задаем ожидаемое поведение мока. mockDB.EXPECT().Query("SELECT name FROM users WHERE id = ?", 1). Return(&sql.Rows{}, nil) service := NewService(mockDB) _, err := service.GetUserName(1) if err != nil { t.Errorf("ошибка: %v", err) } }
В директории mocks появится файл с автоматически сгенерированным мок‑объектом, который реализует интерфейс DB.
Использование в тестах:
В тестах создаемся контроллер через gomock.NewController(t)
и передаем его в конструктор мока. Затем методом EXPECT()
определяем, какие вызовы должны произойти. Например:
package main import ( "database/sql" "testing" "github.com/golang/mock/gomock" "myproject/mocks" ) func TestGetUserNameWithGoMock(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockDB := mocks.NewMockDB(ctrl) // Задаем ожидаемое поведение мока. mockDB.EXPECT().Query("SELECT name FROM users WHERE id = ?", 1). Return(&sql.Rows{}, nil) service := NewService(mockDB) _, err := service.GetUserName(1) if err != nil { t.Errorf("ошибка: %v", err) } }
EXPECT()
проверяет, что метод Query
вызывается с точным SQL‑запросом и аргументом , а затем возвращает заданное значение. Если вызов не произойдёт или параметры будут другими, тест упадёт.
Mockery — альтернатива
Если хочется альтернативное решение, которое требует минимальной настройки и генерирует моки прямо из интерфейсов, попробуйте mockery.
Установка:
go install github.com/vektra/mockery/v2@latest
Это добавит mockery в $GOPATH
.
Генерация мока:
Выполняем команду:
mockery --name=DB --output=mocks --case=underscore
Команда найдёт интерфейс DB в проекте и сгенерирует файл мока в папке mocks с именем, удобным для использования.
Сгенерированный мок можно использовать аналогично gomock: передавать его в конструкторы, задавать ожидаемое поведение (если интегрируете с библиотеками для ожиданий) и проверять вызовы.
Monkey patching: когда стандартные инструменты не помогают
Monkey patching — это способ динамической подмены кода во время выполнения. В Go он не поддерживается из коробки, но его можно реализовать с помощью библиотеки github.com/bouk/monkey.
Однако monkey patching стоит применять только в тестах и с осознанием всех рисков.
Go не поддерживает динамическую подмену функций на уровне языка, но monkey.Patch делает это с помощью модификации инструкций машинного кода. Фактически, он заменяет вызов функции на другую, передавая управление новому коду. Это работает только в runtime и требует особого обращения.
Допустим, есть функция getCurrentTime()
, которая возвращает текущее Unix‑время. Нам нужно протестировать логику, которая зависит от времени, но без monkey patching тесты будут зависеть от реального времени, что плохо.
Ориг. код:
// Пример с google/wire (упрощённо) package main import "github.com/google/wire" // InitializeService автоматически собирает все зависимости. func InitializeService() *Service { wire.Build(NewRealDB, NewService) return &Service{} }
В тестах нужно зафиксировать время, иначе результаты будут непредсказуемыми.
Подмена с monkey patching:
package main import ( "fmt" "testing" "time" "github.com/bouk/monkey" ) func TestGetCurrentTime_Monkey(t *testing.T) { // Подменяем функцию getCurrentTime, чтобы она всегда возвращала фиксированное значение. patch := monkey.Patch(getCurrentTime, func() int64 { return 9876543210 }) defer patch.Unpatch() // Важно разпатчить после теста, иначе другие тесты могут сломаться. result := getCurrentTime() if result != 9876543210 { t.Errorf("ожидалось 9876543210, получено %d", result) } fmt.Println("Monkey patching сработал:", result) }
Вызов monkey.Patch(getCurrentTime, func() int64 { return 9876543210 })
подменяет оригинальную функцию getCurrentTime() на анонимную, которая всегда возвращает фиксированное значение 9 876 543 210, что делает тест детерминированным, ведь теперь каждый вызов этой функции в тестовой среде будет давать один и тот же результат; при этом важно использовать defer patch.Unpatch(), чтобы после выполнения теста патч был снят и не влиял на работу других тестов.
Иногда надо заменить функции из стандартной библиотеки. Например, time.Now(), os.Exit(), http.Get() и другие. Допустим, нужно протестировать код, который зависит от time.Now(), но не хотим каждый раз получать разное время.
Ориг.код:
package main import ( "fmt" "testing" "time" ) // asyncOperation имитирует долгую асинхронную операцию. func asyncOperation(result chan<- int) { time.Sleep(100 * time.Millisecond) result <- 42 } func TestAsyncOperation(t *testing.T) { resultChan := make(chan int) go asyncOperation(resultChan) select { case res := <-resultChan: if res != 42 { t.Errorf("ожидалось 42, получено %d", res) } case <-time.After(200 * time.Millisecond): t.Error("таймаут ожидания результата") } }
Monkey patching для time.Now():
package main import ( "fmt" "testing" "time" "github.com/bouk/monkey" ) func TestLogCurrentTime_Monkey(t *testing.T) { // Подменяем time.Now(), чтобы он всегда возвращал фиксированное значение. fixedTime := time.Date(2024, 3, 18, 12, 0, 0, 0, time.UTC) patch := monkey.Patch(time.Now, func() time.Time { return fixedTime }) defer patch.Unpatch() result := logCurrentTime() expected := "Current time: 2024-03-18 12:00:00 +0000 UTC" if result != expected { t.Errorf("Ожидали: %s, получили: %s", expected, result) } fmt.Println("Monkey patching для time.Now() сработал:", result) }
monkey.Patch(
time.Now
, func() time.Time { return fixedTime })
подменяет стандартную функцию time.Now()
на анонимную, которая всегда возвращает заранее заданное значение fixedTime
, благодаря чему все вызовы time.Now()
в тесте будут детерминированы; после теста важно отключить патч с помощью defer patch.Unpatch()
, чтобы вернуть оригинальное поведение функции и избежать влияния на другие тесты.
Можно заменить не только функции, но и методы структур. Предположим, есть структура UserService с методом GetUserName():
package main import "fmt" type UserService struct{} func (u *UserService) GetUserName(userID int) string { // Допустим, тут сложная логика, обращения к БД и т. д. return fmt.Sprintf("User%d", userID) }
Подмена метода:
package main import ( "fmt" "testing" "github.com/bouk/monkey" ) func TestUserService_Monkey(t *testing.T) { userService := &UserService{} // Подменяем метод GetUserName, чтобы он всегда возвращал "MockUser" patch := monkey.PatchInstanceMethod(reflect.TypeOf(userService), "GetUserName", func(*UserService, int) string { return "MockUser" }) defer patch.Unpatch() result := userService.GetUserName(42) if result != "MockUser" { t.Errorf("Ожидали MockUser, получили %s", result) } fmt.Println("Monkey patching метода структуры сработал:", result) }
Теперь любой вызов GetUserName()
вернёт «MockUser». defer patch.Unpatch()
снимает подмену после теста.
Использовать monkey patching стоит только в тестах и с большой осторожностью. Если есть возможность — лучше использовать dependency injection и интерфейсы.
Тестирование API с httptest.Server
Пакет net/http/httptest позволяет поднять локальный сервер и симулировать ответы настоящего API.
Пример использования httptest.Server:
package main import ( "encoding/json" "io/ioutil" "net/http" "net/http/httptest" "testing" ) // User описывает структуру ответа сервера. type User struct { ID int `json:"id"` Name string `json:"name"` } // fetchUser делает HTTP-запрос и парсит JSON-ответ. func fetchUser(url string) (*User, error) { resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } var user User if err := json.Unmarshal(body, &user); err != nil { return nil, err } return &user, nil } func TestFetchUser(t *testing.T) { // Создаем тестовый HTTP-сервер с фиксированным JSON-ответом. ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(User{ID: 1, Name: "Alice"}) })) defer ts.Close() user, err := fetchUser(ts.URL) if err != nil { t.Fatalf("ошибка при получении пользователя: %v", err) } if user.Name != "Nikita" { t.Errorf("ожидалось имя Nikita, получено %s", user.Name) } }
Можно эмулировать практически любые сценарии: от нормальных ответов до экзотических ошибок, задержек и неожиданных заголовков.
Спасибо, что дочитали до этого момента. А как у вас обстоят дела с моками? Какие инструменты предпочитаете, какие интересные истории или проблемы встречались на вашем пути? Делитесь опытом в комментариях.
В заключение всем тестировщикам рекомендую обратить внимание на открытые уроки в Otus:
ссылка на оригинал статьи https://habr.com/ru/articles/892084/
Добавить комментарий