Моки в 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, остальные разбираются в тренажёре.
- Как в Go принято подменять зависимость (например хранилище) в тесте?A)Патчить нужные функции прямо в рантайме специальной monkey-patch библиотекойB)Глобальной переменной-флагом, который прод-код проверяет в тестовом режиме работыC)Через рефлексию подменять поля структуры на лету прямо перед вызовом методаD)Принимать зависимость через интерфейс и передавать в тесте свою реализацию
показать ответ и разбор
+D)Принимать зависимость через интерфейс и передавать в тесте свою реализацию// разбор: Идиома Go — dependency injection через интерфейс: код зависит от мелкого интерфейса (Store с нужными методами), а в тесте передают свою простую реализацию-заглушку (или сгенерированный мок). Никакого monkey-patching и рефлексии: подмена статически типобезопасна и явна. Отсюда снова принцип «маленькие интерфейсы у потребителя» — они и делают код тестируемым.
- Чем удобен пакет 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, куда хендлер пишет ответ, и ты проверяешь код/тело/заголовки без сети вообще. Так тестируют и серверную, и клиентскую сторону быстро и изолированно.
- Тест поднимает зависимость в контейнере. Что даёт подход с testcontainers вместо мока?A)Проверяется реальное поведение зависимости, включая её диалект и ограниченияB)Тест выполняется быстрее, потому что не нужно писать реализацию подделкиC)Контейнер поднимается один раз на весь проект и переиспользуется всеми пакетамиD)Тест перестаёт зависеть от окружения, потому что образ собирается на лету
показать ответ и разбор
+A)Проверяется реальное поведение зависимости, включая её диалект и ограничения// разбор: Мок проверяет, что код вызвал то, что вы задумали, — но не то, что база это примет: диалект SQL, поведение транзакций, ограничения целостности мок не воспроизводит. Контейнер даёт настоящую зависимость ценой секунд на старт и требования к докеру в CI. Отсюда обычное разделение: логика — юнит-тестами на подделках, стык с базой — горсткой интеграционных на контейнере.
- Почему в Go часто обходятся ручной подделкой зависимости вместо генератора моков?A)Генераторы моков не поддерживают интерфейсы с несколькими методамиB)Стандартная библиотека запрещает подключать сторонние генераторыC)Сгенерированные моки не годятся для параллельных тестовD)Интерфейсы у потребителя узкие, и реализация в пару строк проще генератора
показать ответ и разбор
+D)Интерфейсы у потребителя узкие, и реализация в пару строк проще генератора// разбор: Интерфейс объявляется у потребителя и содержит один-два метода — подделка занимает пять строк и читается без документации. Генератор оправдан, когда интерфейс широкий и нужна проверка порядка и числа вызовов. Ещё один частый приём — тип-функция: объявить интерфейс с единственным методом и подставлять обычное замыкание, как это сделано с http.HandlerFunc.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.