Внешние зависимости — платёжный шлюз, почтовый провайдер, соседний сервис — делают тесты медленными и хрупкими. Разберём, когда в Go нужна заглушка, когда она только мешает, и как тестировать HTTP-вызовы к партнёру через httptest.Server, не поднимая ничего, кроме своего кода.
Самое неприятное в заглушках — не то, что они ломают тесты, а то, что они их не ломают. Партнёр поменял формат ответа, сервис в проде считает отказ успехом, а тест по-прежнему зелёный: он спрашивает не партнёра, а наше представление о партнёре.
Когда заглушка уместна
В Go нет контейнера зависимостей и нет аннотаций: подмена работает через обычные интерфейсы. Интерфейс объявляет тот, кто потребляет, и объявляет его маленьким — только методы, которые ему нужны.
Короткая формула: заглушка уместна на внешней границе — там, где заканчивается ваш код и начинается чужой.
type charger interface {
Charge(ctx context.Context, req ChargeRequest) (ChargeResult, error)
}
type Checkout struct {
payments charger
orders orderSaver
}
Заглушку пишут руками, и это несколько строк:
type stubCharger struct {
result ChargeResult
err error
}
func (s stubCharger) Charge(context.Context, ChargeRequest) (ChargeResult, error) {
return s.result, s.err
}
func TestCheckout_PaymentDeclined(t *testing.T) {
c := Checkout{payments: stubCharger{result: ChargeResult{Status: Declined}}, orders: fakeOrders{}}
_, err := c.Place(context.Background(), orderRequest())
if !errors.Is(err, ErrPaymentDeclined) {
t.Fatalf("err = %v, want ErrPaymentDeclined", err)
}
}
Никакого Docker, никакой сети, тест работает мгновенно. Когда интерфейс большой или заглушек десятки, берут генератор: gomock (теперь go.uber.org/mock, старый golang/mock заархивирован) или mockery для testify/mock. Сгенерированный мок умеет ожидания и счётчики вызовов, но платит читаемостью: EXPECT().Charge(gomock.Any()).Return(...) читается хуже, чем структура с двумя полями. Правило простое: пока заглушка умещается в десять строк, пишите её руками.
Когда заглушка вредит
Короткая формула: подменять свой код — значит тестировать ожидания о поведении, а не само поведение.
Если заглушка стоит между двумя вашими типами (сценарий и репозиторий заказов), тест проходит даже при неправильном взаимодействии: поменяйте смысл метода, и заглушка «не заметит». Для своего репозитория лучше настоящая база в контейнере, а для чистой логики — fake: репозиторий на map, который действительно хранит и ищет.
type fakeOrders struct {
mu sync.Mutex
items map[OrderID]Order
}
func (f *fakeOrders) Save(_ context.Context, o Order) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.items == nil {
f.items = map[OrderID]Order{}
}
f.items[o.ID] = o
return nil
}
func (f *fakeOrders) ByID(_ context.Context, id OrderID) (Order, error) {
f.mu.Lock()
defer f.mu.Unlock()
o, ok := f.items[id]
if !ok {
return Order{}, ErrOrderNotFound
}
return o, nil
}
Fake ведёт себя как настоящий на уровне контракта — «сохранил, потом нашёл», «не нашёл — ErrOrderNotFound», — поэтому тесты с ним переживают рефакторинг сценария. Мьютекс не лишний: с t.Parallel() и горутинами в сценарии fake без него поймает детектор гонок.
Чего нельзя подменять вообще
Чужие типы со сложным поведением. *pgxpool.Pool, *kafka.Writer, *http.Client, клиент S3. Заглушка на них воспроизводит не библиотеку, а вашу фантазию о ней: вы записали «на третий вызов вернёт пустой список», а настоящая библиотека в этом месте возвращает ошибку, повторяет запрос сама или отдаёт ленивый курсор. Признак ловушки: в тесте приходится описывать, в каком порядке и что вернут четыре метода чужого типа.
Чем заменять: настоящей реализацией в контейнере (база, брокер), сервером-заглушкой на уровне протокола (httptest.Server из следующего раздела, а не мок клиента) или своей тонкой обёрткой. Последнее — самый недооценённый приём в Go: заводите свой интерфейс с двумя-тремя методами на языке задачи, за ним прячете чужую библиотеку и подменяете свой интерфейс. Обёртка проверяется одним интеграционным тестом против настоящей библиотеки.
Значения. Money, OrderID, time.Time, срезы и структуры данных создают настоящими — они для этого и существуют.
Функции пакета. time.Now(), rand.IntN(), os.Getenv() внутри метода — зависимость, не выраженная в коде. Библиотеки подмены на уровне загрузчика в Go существуют, и именно поэтому стоит сказать: это признак, что зависимость надо сделать явной — полем структуры или аргументом.
То, что проверяется только целиком. Транзакция, права доступа, сериализация ответа, порядок middleware: заглушка обходит ровно тот механизм, который вы собирались проверить. Это уровень интеграционного теста.
Проверка вызовов
Всё, что выше, — про ответы заглушки. Но половина проверок звучит иначе: «письмо отправлено», «повтора не было», «в шлюз ушла правильная сумма». Это не результат функции, а факт исходящего вызова. В Go заглушка просто записывает, что с ней делали:
type spyCharger struct {
mu sync.Mutex
calls []ChargeRequest
reply func(n int) (ChargeResult, error)
}
func (s *spyCharger) Charge(_ context.Context, req ChargeRequest) (ChargeResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls = append(s.calls, req)
return s.reply(len(s.calls))
}
if len(spy.calls) != 0 { // не вызывали вовсе
t.Fatalf("charge called %d times", len(spy.calls))
}
if got := len(spy.calls); got != 3 { // ровно три попытки
t.Fatalf("attempts = %d, want 3", got)
}
if spy.calls[0].Amount != Money(500) { // с такими аргументами
t.Errorf("amount = %v", spy.calls[0].Amount)
}
Проверка «не вызывали» заслуживает отдельного слова: именно такие ошибки самые дорогие — второе списание, повторное письмо, уведомление при откате. Число вызовов — единственный способ проверить повторы. Сгенерированные моки дают то же самое строками Times(3) в gomock и AssertNumberOfCalls в testify.
И граница, за которую лучше не выходить: проверка вызовов привязывает тест к реализации. «Вызвал ли репозиторий Save» — плохая проверка: поведение «заказ сохранён» не изменится, если сохранять станут иначе. «Ушёл ли запрос в шлюз» — хорошая: сам вызов и есть наблюдаемое поведение.
httptest.Server вместо реального партнёра
Когда ваш код вызывает внешний HTTP-сервис, поднять настоящий партнёрский сервер в тестах невозможно. Отдельная библиотека для этого в Go не нужна: httptest.NewServer поднимает настоящий HTTP-сервер на свободном порту с вашим обработчиком вместо партнёра.
func TestPaymentClient_Declined(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/charge" || r.Method != http.MethodPost {
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
_, _ = w.Write([]byte(`{"status":"DECLINED"}`))
}))
t.Cleanup(srv.Close)
client := NewPaymentClient(srv.URL, srv.Client())
res, err := client.Charge(context.Background(), ChargeRequest{Card: "card_123", Amount: 500})
if err != nil {
t.Fatal(err)
}
if res.Status != Declined {
t.Errorf("status = %v, want Declined", res.Status)
}
}
Половина работы здесь — строка NewPaymentClient(srv.URL, …): клиент должен брать адрес партнёра из конструктора или настроек, а не держать его константой. Иначе тест выглядит рабочим, а стучится к настоящему партнёру. srv.Client() отдаёт *http.Client, который уже умеет ходить в этот сервер; для TLS-варианта есть httptest.NewTLSServer, и его клиент доверяет самоподписанному сертификату сервера.
Три случая, которые без кода не понять.
Таймаут клиента. Партнёр отвечает медленнее, чем вы готовы ждать. Задержка задаётся обработчику, а тест проверяет, что клиент не завис, а честно отвалился своей ошибкой:
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(3 * time.Second):
case <-r.Context().Done():
}
}))
t.Cleanup(srv.Close)
client := NewPaymentClient(srv.URL, &http.Client{Timeout: 200 * time.Millisecond})
_, err := client.Charge(context.Background(), req)
if !errors.Is(err, ErrPaymentUnavailable) {
t.Fatalf("err = %v", err)
}
Тест заодно отвечает на вопрос, который иначе проверить нечем: настроен ли таймаут вообще. http.Client{} без Timeout в этом тесте прождёт три секунды и вернёт успех — и вы увидите, что в проде он ждал бы бесконечно. <-r.Context().Done() в обработчике важен: когда клиент отвалится по таймауту, сервер отпустит горутину, а не задержит srv.Close на три секунды.
Обрыв соединения. Не всякий отказ — код ответа. Сброшенное соединение обрабатывается другим кодом, чем 500, и ломает клиентов чаще:
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Fatal(err)
}
_ = conn.Close()
}))
Hijack забирает соединение у сервера, и закрытие без ответа даёт клиенту ровно то, что даёт упавший партнёр: EOF или сброс.
Последовательность ответов и число повторов. Самая полезная возможность: «первые два раза плохо, третий хорошо» и проверка, что клиент сделал именно три попытки:
var attempts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if attempts.Add(1) < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte(`{"status":"APPROVED"}`))
}))
t.Cleanup(srv.Close)
res, err := NewPaymentClient(srv.URL, srv.Client()).Charge(context.Background(), req)
if err != nil || res.Status != Approved {
t.Fatalf("res = %v, err = %v", res, err)
}
if got := attempts.Load(); got != 3 {
t.Fatalf("attempts = %d, want 3", got)
}
Последняя проверка — самая важная: тест с бесконечными повторами и тест с одной попыткой оба «проходят», если число не считать. Счётчик атомарный, потому что обработчики httptest.Server работают в своих горутинах.
За пределами такого теста остаётся сам контракт партнёра: заглушка отвечает так, как мы думаем, что отвечает партнёр. Расхождение ловят контрактными тестами или записью настоящих ответов из тестового контура партнёра — об этом раздел «Глубже» ниже.
Изоляция тестовых данных
Тесты должны быть независимы: порядок запуска не должен влиять на результат. Три подхода, подробно разобранные в статье про интеграционные тесты:
Откат транзакции. Репозиторий принимает DBTX, тест отдаёт ему транзакцию и откатывает её в t.Cleanup. Не годится для сквозных тестов через HTTP: обработчик берёт соединение из пула, и откат тестовой транзакции его данных не касается.
Очистка таблиц. TRUNCATE всех таблиц одним запросом перед тестом. Не годится при параллельном прогоне: пока один тест чистит, второй читает свои данные и не находит их.
Уникальные идентификаторы. uuid.New() на покупателя прямо в тесте, чтобы тесты не конкурировали за одни записи:
func TestOrdersOfCustomer(t *testing.T) {
t.Parallel()
customer := "cust-" + uuid.NewString()
mustSave(t, repo, order(customer, Pending))
mustSave(t, repo, order(customer, Paid))
got, err := repo.ByCustomer(context.Background(), customer)
if err != nil || len(got) != 2 {
t.Fatalf("got %d orders, err %v", len(got), err)
}
}
Уникальные ключи не ломаются ни при HTTP, ни при t.Parallel(). Цена одна: проверка «а сколько всего» перестаёт работать, считать надо всегда с условием по своему ключу.
Тест падает в полночь: время и случайность
Код с time.Now() внутри недетерминирован: каждый прогон даёт другой результат, и точную проверку не написать. В Go нет Clock из стандартной библиотеки, но решение ещё проще — функция как зависимость:
type OrderService struct {
now func() time.Time
orders orderSaver
}
func NewOrderService(orders orderSaver) *OrderService {
return &OrderService{now: time.Now, orders: orders}
}
func (s *OrderService) Place(ctx context.Context, req OrderRequest) (Order, error) {
o := NewOrder(req, s.now().UTC())
return o, s.orders.Save(ctx, o)
}
func TestPlace_StampsCreatedAt(t *testing.T) {
fixed := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
svc := &OrderService{now: func() time.Time { return fixed }, orders: &fakeOrders{}}
o, err := svc.Place(context.Background(), orderRequest())
if err != nil || !o.CreatedAt.Equal(fixed) {
t.Fatalf("createdAt = %v, err = %v", o.CreatedAt, err)
}
}
Два замечания. .UTC() в коде, а не в тесте: time.Now() возвращает местное время машины, и одна и та же запись получит разное время на ноутбуке и на сервере; в базе отметка лежит в timestamptz, то есть это момент на общей шкале. И сравнивать время надо через Equal, а не ==: у двух одинаковых моментов могут отличаться пояс и монотонная составляющая.
С Go 1.25 есть ещё один путь — testing/synctest: внутри synctest.Test(t, func(t *testing.T) {…}) горутины работают в «пузыре» с виртуальными часами, time.Sleep и таймеры не ждут по-настоящему, а тест ждёт, пока все горутины заблокируются. Это лечит тесты с повторами по таймеру и фоновыми горутинами, которые иначе спят секундами или мигают.
То же с генераторами случайных чисел: math/rand/v2 с rand.New(rand.NewPCG(seed, seed)) передают через конструктор, а зерно печатают при падении, чтобы повторить прогон.
Глубже: пять видов дублей: dummy, stub, fake, spy, mockрасширенное
Слово «заглушка» обозначает пять разных вещей, и спор «уместна или вредит» без разделения не решается.
Dummy передают, потому что параметр обязателен, и никогда не используют: nil на месте уведомлений в тесте расчёта цены (если метод к ним не обращается). Stub отвечает заранее заданным — stubCharger выше: он нужен, чтобы тест дошёл до проверяемого места. Fake — работающая упрощённая реализация: fakeOrders на map. Spy записывает вызовы — spyCharger. Mock в строгом смысле знает ожидания заранее и падает при отклонении — то, что генерирует gomock.
Разница определяет, что тест проверяет. Stub и fake проверяют результат: что вернул метод, что оказалось в хранилище. Spy и mock проверяют взаимодействие: что метод вызвал соседа. Первое устойчиво, второе хрупко. Правило: проверять взаимодействие только там, где оно и есть результат, — у исходящих команд без возвращаемого значения («письмо отправлено», «событие опубликовано»); всё остальное проверять по состоянию. В Go это особенно естественно: stub и fake — это две структуры, а мок с ожиданиями на каждый вызов — признак, что тест повторяет реализацию.
Глубже: контрактные тесты и записанные ответырасширенное
Тест на httptest.Server остаётся зелёным, когда партнёр меняет формат: заглушку никто не сверяет с настоящим сервисом. Есть три способа закрыть это.
Контракт со стороны потребителя (Pact, библиотека pact-go). Потребитель описывает в тесте, что отправляет и что ожидает получить; Pact поднимает заглушку из этого описания и на выходе даёт файл контракта. Его публикуют в брокер контрактов, а сборка поставщика проигрывает контракты всех потребителей против настоящего кода. Изменил поставщик поле, которое читает мобильное приложение, — его сборка красная с именем потребителя, до выката.
Проверка по спецификации. Если у партнёра есть OpenAPI, ответ заглушки и запрос клиента можно сверять с ней в тесте: openapi3filter из kin-openapi валидирует запрос и ответ по схеме. Это не заменяет контракт, но ловит расхождение, когда заглушка написана «по памяти».
Записанные ответы. go-vcr записывает настоящие ответы партнёрского тестового контура в файл при первом прогоне и воспроизводит их дальше. Заглушка перестаёт быть вашим представлением о партнёре и становится его настоящим ответом на конкретную дату; перезапись по расписанию находит изменения формата.
Для событий в очереди контракт важнее, чем для HTTP: там нет статуса 400, который скажет о расхождении схемы, — об этом в статье про Kafka.
Коротко
- Заглушки уместны только на внешней границе: интерфейс объявляет потребитель, маленький, а заглушка — рукописная структура; gomock и testify/mock — когда их десятки.
- Подменять свой репозиторий — тестировать ожидания: для логики берут fake на
mapс мьютексом, для запросов — настоящую базу в контейнере. - Чужие типы (
*pgxpool.Pool,*http.Client, клиент брокера), значения и функции пакета не подменяют: своя тонкая обёртка, контейнер или сервер-заглушка на уровне протокола. - Факт вызова проверяют spy со срезом вызовов: ноль вызовов, ровно три попытки, аргументы; взаимодействие проверяют только у исходящих команд.
httptest.NewServerзаменяет партнёра: адрес берётся из конструктора клиента, таймаут проверяют задержкой в обработчике, обрыв —HijackиClose, повторы — атомарным счётчиком попыток.- Изоляция данных: откат транзакции через
DBTX,TRUNCATEдля сквозных тестов, уникальные идентификаторы дляt.Parallel(). - Время внедряют функцией
now func() time.Time, сравнивают черезEqual, хранят в UTC; случайность —rand.NewPCGс зерном;testing/synctestубирает настоящие ожидания. - Контракт сверяет заглушку с партнёром:
pact-goчерез брокер,openapi3filterпо спецификации,go-vcrзаписанными ответами.
Что почитать дальше
- Интеграционные тесты на Go — testcontainers-go, миграции и изоляция данных подробно.
- Тестирование в Go —
testing, табличные тесты иhttptestс нуля. - Пирамида тестирования — какой уровень тестов за что отвечает.
- Устойчивость к отказам — таймауты, повторы и предохранитель, которые эти тесты проверяют.