← назад к разделу

Внешние зависимости — платёжный шлюз, почтовый провайдер, соседний сервис — делают тесты медленными и хрупкими. Разберём, когда в 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 записанными ответами.

Что почитать дальше