сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Веб и тесты на Go

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

Подмена зависимостей

Подделка хранилища для теста у меня заняла ровно три строки: тип с двумя полями и один метод, который возвращает то, что положили. Никакого генератора. Спрашивают, как тестировать код, который ходит в базу или в чужой сервис, и проверяют понимание, что в Go для этого обычно хватает узкого интерфейса.

Стержень: интерфейс объявляет потребитель, поэтому подделка получается маленькой, а генератор нужен не всегда.

// Формулировки: «как подменяете хранилище?», «мок или фейк?», «как тестировать HTTP?»

Узкий интерфейс у потребителя

Зависимость принимают интерфейсом, который объявлен в пакете-потребителе и содержит ровно те методы, что вызываются. У меня это был интерфейс с одним методом Name(id int) (string, error) - и подделка к нему уместилась в структуру с полями name и err.

Дальше проверяются обе ветки за два вызова: с именем сервис вернул «привет, Аня», с ошибкой - «гость». Никакой базы, никакой сети, весь тест мгновенный.

// Генератор моков оправдан, когда интерфейс широкий и нужно проверять порядок и число вызовов. Для узкого контракта он добавляет сборочный шаг ради того, что пишется руками за минуту.

фейк
простая рабочая подделка зависимости
интерфейс у потребителя
контракт объявляет тот, кто вызывает

Однометодный контракт: замыкание вместо структуры

Если в интерфейсе один метод, структуру можно не заводить вообще. Объявляешь тип-функцию с нужной сигнатурой, вешаешь на неё этот метод - и дальше подставляешь обычное замыкание прямо в тесте.

Я так и проверил: подставил замыкание, которое возвращает «юзер-42», и сервис отдал «привет, юзер-42». Ровно этот приём стандартная библиотека использует в http.HandlerFunc, поэтому в чужом коде он читается без пояснений.

// Побочная выгода: каждый тест задаёт своё поведение прямо на месте, без полей структуры и без переключателей внутри подделки.

тип-функция
замыкание вместо структуры для одного метода
http.HandlerFunc
тот же приём в стандартной библиотеке

httptest: рекордер против настоящего сервера

Замерил оба варианта на обработчике, который спит 30 мс. Через httptest.NewRecorder обработчик вызывается напрямую как функция - 30 мс. Через httptest.NewServer поднимается настоящий сервер на свободном порту, у меня это был 127.0.0.1:44109, и запрос занял 32 мс.

То есть выигрыш рекордера не в скорости - две миллисекунды на петле разницы не делают. Он в том, что нет порта, нет второй горутины, нет уборки, а статус, заголовки и тело можно посмотреть прямо в структуре: 418, заголовок X-Trace, тело целиком.

// NewServer нужен, когда тестируешь клиента, а не обработчик. Я поставил клиенту таймаут 10 мс против ответа в 30 - получил ошибку, как и должно быть. Редиректы, повторы и таймаут рекордером не проверить, там нужен настоящий транспорт.

ResponseRecorder
запись ответа обработчика без сети и порта
httptest.NewServer
настоящий сервер: для тестов клиента

Как отвечать: «Как подменяете зависимости в тестах?»

Через интерфейс, объявленный в пакете-потребителе. Толстый контракт рядом с реализацией я не описываю, а перечисляю у потребителя ровно те методы, которые вызываю, обычно один-два. Тогда подделка в тесте выходит на три строки: структура с парой полей и метод, возвращающий заранее заданный результат. Обе ветки, успех и ошибка, проверяются двумя вызовами без базы и без сети. Генераторы моков беру редко, они оправданы на широком интерфейсе, где надо проверять порядок и число вызовов. Для однометодного контракта удобнее приём с типом-функцией: объявляешь тип с нужной сигнатурой, вешаешь на него метод и подставляешь обычное замыкание - ровно так сделан http.HandlerFunc в стандартной библиотеке. Для HTTP пользуюсь httptest. Рекордером, когда тестирую обработчик: он вызывается напрямую, а статус, заголовки и тело лежат в структуре. Настоящим сервером, когда тестирую клиента, потому что таймауты и редиректы иначе не проверить - я так и ловил ошибку на клиенте с таймаутом 10 миллисекунд против ответа в 30. А стык с базой моками не закрываю вовсе: мок не воспроизводит диалект SQL, поведение транзакций и ограничения целостности, поэтому там держу горстку интеграционных тестов на контейнере, а логику проверяю юнитами на подделках.

Сильный ответ: подход выведен из идиомы «интерфейс объявляет потребитель», объяснено, когда генератор всё-таки нужен, разведены два инструмента httptest по задачам и обозначена граница применимости моков. Последнее - признак человека, который ловил расхождения между моком и настоящей базой.

На чём валят

  • Объявляют толстый интерфейс рядом с реализацией, и подделки распухают до сотни строк.
  • Тянут генератор моков туда, где хватает трёх строк руками.
  • Тестируют обработчик через настоящий сервер, хотя достаточно рекордера.
  • Закрывают моками стык с базой и узнают про диалект SQL уже на проде.
  • Проверяют в тесте детали реализации вместо наблюдаемого поведения.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 4, остальные разбираются в тренажёре.

  1. #go_mocks1 / 4
    Как в Go принято подменять зависимость (например хранилище) в тесте?
    A)Патчить нужные функции прямо в рантайме специальной monkey-patch библиотекой
    B)Глобальной переменной-флагом, который прод-код проверяет в тестовом режиме работы
    C)Через рефлексию подменять поля структуры на лету прямо перед вызовом метода
    D)Принимать зависимость через интерфейс и передавать в тесте свою реализацию
    показать ответ и разбор
    +D)Принимать зависимость через интерфейс и передавать в тесте свою реализацию

    // разбор: Идиома Go — dependency injection через интерфейс: код зависит от мелкого интерфейса (Store с нужными методами), а в тесте передают свою простую реализацию-заглушку (или сгенерированный мок). Никакого monkey-patching и рефлексии: подмена статически типобезопасна и явна. Отсюда снова принцип «маленькие интерфейсы у потребителя» — они и делают код тестируемым.

  2. #go_mocks2 / 4
    Чем удобен пакет net/http/httptest при тестировании HTTP?
    A)NewServer даёт реальный сервер на случайном порту, а NewRecorder ловит ответ
    B)Он автоматически генерирует нагрузочные тесты и подробный отчёт по RPS для эндпоинтов
    C)Целиком подменяет весь пакет net/http на заглушку, полностью отключая всякую сеть
    D)Проверяет соответствие эндпоинтов их OpenAPI-контракту по внешнему описанию
    показать ответ и разбор
    +A)NewServer даёт реальный сервер на случайном порту, а NewRecorder ловит ответ

    // разбор: httptest даёт два инструмента: httptest.NewServer(handler) поднимает НАСТОЯЩИЙ HTTP-сервер на локальном случайном порту (тестируешь клиента против него), а httptest.NewRecorder() — фейковый ResponseWriter, куда хендлер пишет ответ, и ты проверяешь код/тело/заголовки без сети вообще. Так тестируют и серверную, и клиентскую сторону быстро и изолированно.

  3. #go_mocks3 / 4
    Тест поднимает зависимость в контейнере. Что даёт подход с testcontainers вместо мока?
    A)Проверяется реальное поведение зависимости, включая её диалект и ограничения
    B)Тест выполняется быстрее, потому что не нужно писать реализацию подделки
    C)Контейнер поднимается один раз на весь проект и переиспользуется всеми пакетами
    D)Тест перестаёт зависеть от окружения, потому что образ собирается на лету
    показать ответ и разбор
    +A)Проверяется реальное поведение зависимости, включая её диалект и ограничения

    // разбор: Мок проверяет, что код вызвал то, что вы задумали, — но не то, что база это примет: диалект SQL, поведение транзакций, ограничения целостности мок не воспроизводит. Контейнер даёт настоящую зависимость ценой секунд на старт и требования к докеру в CI. Отсюда обычное разделение: логика — юнит-тестами на подделках, стык с базой — горсткой интеграционных на контейнере.

  4. #go_mocks4 / 4
    Почему в Go часто обходятся ручной подделкой зависимости вместо генератора моков?
    A)Генераторы моков не поддерживают интерфейсы с несколькими методами
    B)Стандартная библиотека запрещает подключать сторонние генераторы
    C)Сгенерированные моки не годятся для параллельных тестов
    D)Интерфейсы у потребителя узкие, и реализация в пару строк проще генератора
    показать ответ и разбор
    +D)Интерфейсы у потребителя узкие, и реализация в пару строк проще генератора

    // разбор: Интерфейс объявляется у потребителя и содержит один-два метода — подделка занимает пять строк и читается без документации. Генератор оправдан, когда интерфейс широкий и нужна проверка порядка и числа вызовов. Ещё один частый приём — тип-функция: объявить интерфейс с единственным методом и подставлять обычное замыкание, как это сделано с http.HandlerFunc.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.