MBL
Go / avito-start-code-quality / Задание 2: минимальный интерфейс и мок через GoMock
Go средний

Задание 2: минимальный интерфейс и мок через GoMock

testingmocksgomockinterfaceshttptest

Второе задание — классическая подготовка кода к юнит-тестированию: 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, и компилятор укажет на несовпадение сигнатур ещё до запуска теста.

Самопроверка 0 / 4
Понимаю, почему интерфейс объявляется рядом с потребителем (Handler), а не рядом с реализацией (GreetingService)
Знаю разницу между -source и -destination+интерфейсом в mockgen и какой режим использован здесь
Могу объяснить разницу между gomock.Any() и конкретным значением в EXPECT()
Знаю, зачем нужны одновременно httptest.NewRequest и httptest.NewRecorder
Как усвоено?