- Интерфейс как контракт
- Самый простой мок: жёстко зашитый ответ
- Более гибкий мок: заранее заданные данные
- Мок как способ эмулировать сбои
- Более сложный пример: HTTP-клиент
- Итоги
В любом более-менее серьёзном коде появляются зависимости. Это может быть база данных, 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)
}
}
Дополнительные материалы
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.