Задание 2: минимальный интерфейс и мок через GoMock
Второе задание — классическая подготовка кода к юнит-тестированию: HTTP-обработчик жёстко завязан на конкретный тип *GreetingService, из-за чего протестировать его без реального сервиса невозможно. Задача — разорвать эту связь через интерфейс и подставить вместо реального сервиса мок.
Почему интерфейс объявляется у потребителя, а не у реализации
Исходный код:
type Handler struct {
service *GreetingService
}
func NewHandler(service *GreetingService) *Handler {
return &Handler{service: service}
}Handler требует конкретный тип *GreetingService — единственный способ его протестировать в изоляции сейчас — поднять настоящий GreetingService. Если у него появятся зависимости (БД, внешний API), тест хендлера потянет их все за собой.
Решение — объявить интерфейс из ровно того, что реально нужно Handler, и принимать его вместо конкретного типа:
type Handler struct { service *GreetingService}func NewHandler(service *GreetingService) *Handler { return &Handler{service: service}}type Greeter interface { Greet(ctx context.Context, name string) (string, error)}type Handler struct { service Greeter}func NewHandler(service Greeter) *Handler { return &Handler{service: service}}Ключевая деталь — где живёт этот интерфейс. Greeter объявлен в том же файле, что и Handler (его потребитель), а не рядом с GreetingService (его реализацией). Это стандартный для Go приём: интерфейс описывает контракт, который нужен вызывающей стороне, а не полный набор возможностей реализации. *GreetingService в реальном коде мог бы уметь что-то ещё сверх Greet — Handler эти лишние методы не касаются, поэтому они и не попадают в Greeter. *GreetingService продолжает удовлетворять этому интерфейсу неявно, без единой строчки implements — так работает структурная типизация в Go.
go:generate и режим -source
В файле уже была аннотация:
//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -source=handler.go -destination=mocks/greeter_mock.go -package=mocks-source=handler.go — это source-режим mockgen: генератор читает конкретный файл, находит в нём объявления интерфейсов и генерирует моки для каждого. Альтернатива — reflect-режим (mockgen пакет ИмяИнтерфейса), который вместо чтения исходников импортирует пакет и смотрит на интерфейс через рефлексию во время выполнения генератора. Source-режим здесь предпочтителен: он не требует, чтобы пакет уже успешно собирался на момент генерации, и не зависит от того, как называется интерфейс во время выполнения — раз всё равно интерфейс объявляется в этом же файле, mockgen просто увидит его при чтении текста.
Команда генерации:
go generate ./02-mocks
go generate не запускается автоматически при go build/go test — это осознанный шаг разработчика, который нужно повторять руками при каждом изменении интерфейса. Результат — файл mocks/greeter_mock.go с типом MockGreeter, который реализует Greeter и добавляет пару вспомогательных методов:
func NewMockGreeter(ctrl *gomock.Controller) *MockGreeter
func (m *MockGreeter) EXPECT() *MockGreeterMockRecorderЭтот файл помечен // Code generated by MockGen. DO NOT EDIT. — его не редактируют руками, а перегенерируют при каждом изменении исходного интерфейса.
Тест: Controller, EXPECT и httptest
func TestHandler(t *testing.T) {
ctrl := gomock.NewController(t)
greeter := mocks.NewMockGreeter(ctrl)
greeter.EXPECT().Greet(gomock.Any(), "world").Return("Hello, world!", nil)
h := handler.NewHandler(greeter)
request := httptest.NewRequest(http.MethodGet, "/?name=world", nil)
recorder := httptest.NewRecorder()
h.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
if got := recorder.Body.String(); got != "Hello, world!" {
t.Errorf("body = %q, want %q", got, "Hello, world!")
}
}gomock.NewController(t) привязывает мок к текущему тесту — если мок вызовут не так, как задано в EXPECT() (не тот метод, не те аргументы, не то число вызовов), контроллер сам вызовет t.Fatal с понятным сообщением, отдельно проверять это в тесте не нужно. С Go 1.14+ отдельный ctrl.Finish() в конце теста больше не нужен — контроллер сам регистрирует t.Cleanup.
greeter.EXPECT().Greet(gomock.Any(), "world").Return(...) — это и есть настройка ожидания: «метод Greet будет вызван с любым первым аргументом (gomock.Any()) и вторым аргументом ровно "world"; когда это случится — вернуть ("Hello, world!", nil)». gomock.Any() на месте ctx — обычная практика: конкретное значение контекста редко важно для теста хендлера, а точное совпадение только сделало бы тест хрупким к любому изменению того, как Handler строит контекст.
Если бы вместо gomock.Any() стояло конкретное значение — например, context.Background() — вызов с любым другим контекстом (в том числе с контекстом реального HTTP-запроса, который редко бывает буквально context.Background()) привёл бы к падению теста с сообщением о несовпадении ожидания, даже если сама бизнес-логика отработала верно.
httptest.NewRequest и httptest.NewRecorder — парная конструкция для тестирования http.Handler без поднятия реального сетевого сервера. NewRequest строит *http.Request из метода, пути и тела, как будто он действительно пришёл по сети — Handler.ServeHTTP не отличает его от настоящего запроса. NewRecorder — вместо реального http.ResponseWriter, который пишет байты в сокет, — просто накапливает код ответа и тело в памяти, откуда тест потом их читает (recorder.Code, recorder.Body.String()). Вызов h.ServeHTTP(recorder, request) напрямую, без сетевого клиента — то, что делает такой тест быстрым: никакого реального TCP-соединения, весь путь запроса проходит в рамках одного вызова функции.
Запустить
go test -v ./02-mocks
Ожидается PASS. Если переименовать метод Greet в интерфейсе, но забыть перегенерировать мок (go generate ./02-mocks), тест не соберётся — старый MockGreeter из уже сгенерированного файла не будет реализовывать новый Greeter, и компилятор укажет на несовпадение сигнатур ещё до запуска теста.