Зарегистрируйтесь, чтобы продолжить обучение

Мокирование зависимостей через интерфейсы Go: Автоматическое тестирование

В любом более-менее серьёзном коде появляются зависимости. Это может быть база данных, HTTP-клиент, файловая система, кэш или внешнее API. В рабочем коде они нужны — но в тестах такие зависимости превращаются в проблему. Поднять настоящую базу? Медленно. Настроить внешний сервис? Ненадёжно, он может быть недоступен. А если тесты начинают реально стучаться в интернет или писать на диск — никакой изоляции уже нет.

Поэтому разработчики придумали подмену зависимостей: вместо реальной базы или API в тестах используется поддельная реализация. В Go это особенно удобно, потому что есть интерфейсы. Код работает через интерфейс, и ему всё равно, кто стоит «за кулисами» — настоящая реализация или подделка.

Эта подделка и есть мок. Он ведёт себя как настоящая зависимость снаружи, но внутри возвращает ровно те ответы, которые нужны тесту.

Интерфейс как контракт

В Go интерфейс — это обещание: «Я умею эти методы». Всё. Конкретно как — решает реализация.

Например, сервис пользователей.

// User — простая модель данных.
type User struct {
    ID   int
    Name string
}

// UserStorage — интерфейс, описывающий контракт.
// Он говорит: "я умею отдавать пользователя по id".
type UserStorage interface {
    GetUser(id int) (User, error)
}

// Service зависит не от базы, а от интерфейса.
// Ему всё равно, что там внутри: Postgres, Redis, JSON-файл или мок.
type Service struct {
    storage UserStorage
}

func NewService(storage UserStorage) *Service {
    return &Service{storage: storage}
}

// GetName получает имя пользователя по id.
func (s *Service) GetName(id int) (string, error) {
    user, err := s.storage.GetUser(id)
    if err != nil {
        return "", err
    }
    return user.Name, nil
}

Здесь Service вообще не знает, где лежат пользователи. Он работает только через интерфейс. Это и даёт возможность в тестах подсовывать ему «куклу» вместо настоящего хранилища.

Самый простой мок: жёстко зашитый ответ

Для старта можно сделать совсем простой мок:

// mockStorageAlways возвращает одного и того же пользователя всегда.
type mockStorageAlways struct{}

func (m *mockStorageAlways) GetUser(id int) (User, error) {
    return User{ID: id, Name: "TestUser"}, nil
}

Тест с таким моком:

func TestService_WithSimpleMock(t *testing.T) {
    service := NewService(&mockStorageAlways{})

    name, err := service.GetName(42)
    if err != nil {
        t.Fatal(err)
    }
    if name != "TestUser" {
        t.Errorf("получили %q, хотели %q", name, "TestUser")
    }
}

Здесь всё жёстко: любой id возвращает одного и того же юзера. Это примитивно, но иногда этого хватает, чтобы проверить «провода»: что Service вообще вызывает GetUser и достаёт имя.

Более гибкий мок: заранее заданные данные

Чаще нужен контроль над тем, какие id есть, а какие нет. Тогда мок хранит карту пользователей.

// mockStorage хранит заранее подготовленных пользователей.
type mockStorage struct {
    users map[int]User
}

func (m *mockStorage) GetUser(id int) (User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return User{}, fmt.Errorf("user %d not found", id)
}

Тест с таким моком:

func TestService_WithPreparedMock(t *testing.T) {
    mock := &mockStorage{
        users: map[int]User{
            1: {ID: 1, Name: "Alice"},
            2: {ID: 2, Name: "Bob"},
        },
    }

    service := NewService(mock)

    // проверяем успешный сценарий
    name, err := service.GetName(1)
    if err != nil {
        t.Fatal(err)
    }
    if name != "Alice" {
        t.Errorf("получили %q, хотели %q", name, "Alice")
    }

    // проверяем ошибку
    _, err = service.GetName(99)
    if err == nil {
        t.Fatal("ожидали ошибку, но её нет")
    }
}

Теперь поведение контролируем полностью: хотим — возвращаем юзера, хотим — ошибку.

Мок как способ эмулировать сбои

Иногда нужно проверить, как сервис ведёт себя при сбоях зависимостей. Например, база «упала».

// mockStorageError всегда возвращает ошибку.
type mockStorageError struct{}

func (m *mockStorageError) GetUser(id int) (User, error) {
    return User{}, fmt.Errorf("database is down")
}

Тест:

func TestService_WithErrorMock(t *testing.T) {
    service := NewService(&mockStorageError{})

    _, err := service.GetName(1)
    if err == nil {
        t.Fatal("ожидали ошибку, но её нет")
    }
}

Таким образом можно проверить, что сервис правильно обрабатывает критические ситуации и не падает сам.

Более сложный пример: HTTP-клиент

Возьмём случай, когда сервис делает HTTP-запрос. В боевом коде он использует http.Client, но в тестах мы не хотим ходить в интернет. Как быть?

Определим интерфейс:

// HTTPDoer описывает поведение: "я умею делать запросы".
type HTTPDoer interface {
    Do(req *http.Request) (*http.Response, error)
}

type APIClient struct {
    httpClient HTTPDoer
}

func NewAPIClient(httpClient HTTPDoer) *APIClient {
    return &APIClient{httpClient: httpClient}
}

func (c *APIClient) FetchData(url string) (string, error) {
    req, _ := http.NewRequest("GET", url, nil)
    resp, err := c.httpClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    return string(body), nil
}

Реальный код использует *http.Client, потому что у него есть метод Do. А в тесте можно подставить свой мок:

// mockHTTPClient возвращает заранее подготовленный ответ.
type mockHTTPClient struct {
    response string
}

func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
    r := io.NopCloser(strings.NewReader(m.response))
    return &http.Response{StatusCode: 200, Body: r}, nil
}

Тест:

func TestAPIClient_FetchData(t *testing.T) {
    mock := &mockHTTPClient{response: "mocked data"}
    client := NewAPIClient(mock)

    data, err := client.FetchData("http://example.com")
    if err != nil {
        t.Fatal(err)
    }
    if data != "mocked data" {
        t.Errorf("получили %q, хотели %q", data, "mocked data")
    }
}

Теперь тесты работают без интернета, быстро и предсказуемо.

Итоги

В Go интерфейсы позволяют легко подменять реальные зависимости на моки. Код работает через контракт (UserStorage, HTTPDoer), и ему всё равно, что стоит за ним. В реальности это база или сеть, в тесте — простая структура с картой или жёстко зашитым ответом.

Моки дают изоляцию, скорость и контроль: можно проверить как успешные сценарии, так и ошибки или сбои. Это делает тесты надёжными, повторяемыми и безопасными.


Самостоятельная работа

Приземлим интерфейсы на бытовую задачу — пересчитать цену в нужную валюту через внешний конвертер.

Уже написан интерфейс конвертера валют Converter с методом Convert(amount float64, from, to string) float64. И реализована функция‑клиент PriceIn(amount float64, from, to string, c Converter) float64, которая делегирует пересчёт c.Convert(...).

Что сделать:

  • Напишите мок: он хранит последние аргументы и всегда возвращает 42.0.
  • Протестируйте, что клиент вызывает конвертер с правильными аргументами и возвращает его результат.
package currency

// Converter отвечает за пересчёт сумм между валютами.
type Converter interface {
    Convert(amount float64, from, to string) float64
}

// PriceIn пересчитывает цену через переданный Converter.
func PriceIn(amount float64, from, to string, c Converter) float64 {
    return c.Convert(amount, from, to)
}

Что покрыть в тестах

  • Базовый кейс: любые входы → 42.0 (результат мука).
  • Аргументы передаются без искажений: сумма, из какой валюты и в какую.
  • Нули и отрицательные суммы (возврат и аргументы).

Подсказка

  • Мок — обычная структура с полями lastAmount, lastFrom, lastTo, calls и методом Convert.
Показать решение
package currency

import "testing"

type mockConverter struct {
    lastAmount       float64
    lastFrom, lastTo string
    calls            int
}

func (m *mockConverter) Convert(amount float64, from, to string) float64 {
    m.lastAmount, m.lastFrom, m.lastTo = amount, from, to
    m.calls++
    return 42.0
}

func TestPriceIn_DelegatesAndReturns(t *testing.T) {
    m := &mockConverter{}
    got := PriceIn(10.5, "EUR", "USD", m)
    if got != 42.0 {
        t.Fatalf("got %v, want %v", got, 42.0)
    }
    if m.calls != 1 {
        t.Fatalf("calls: got %d, want 1", m.calls)
    }
    if m.lastAmount != 10.5 || m.lastFrom != "EUR" || m.lastTo != "USD" {
        t.Fatalf("args mismatch: (%v,%s->%s)", m.lastAmount, m.lastFrom, m.lastTo)
    }
}

func TestPriceIn_ZeroAndNegative(t *testing.T) {
    m := &mockConverter{}
    _ = PriceIn(0, "RUB", "USD", m)
    if m.lastAmount != 0 || m.lastFrom != "RUB" || m.lastTo != "USD" {
        t.Fatalf("args mismatch for zero: (%v,%s->%s)", m.lastAmount, m.lastFrom, m.lastTo)
    }
    _ = PriceIn(-7, "USD", "EUR", m)
    if m.lastAmount != -7 || m.lastFrom != "USD" || m.lastTo != "EUR" {
        t.Fatalf("args mismatch for negative: (%v,%s->%s)", m.lastAmount, m.lastFrom, m.lastTo)
    }
}

Дополнительные материалы

  1. Интерфейсы в Effective Go
  2. Testify/mock — документация
  3. Code Review Comments — интерфейсы

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff