Мокирование зависимостей в Go
Привет, Хабр!
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:
26 марта: «Организация процесса тестирования в Scrum».
Подробнее10 апреля: «Как найти баг и задокументировать его?».
Подробнее