Мокирование зависимостей в 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‑запросом и аргументом 1, а затем возвращает заданное значение. Если вызов не произойдёт или параметры будут другими, тест упадёт.

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 апреля: «Как найти баг и задокументировать его?».
    Подробнее

© Habrahabr.ru