Опирается на правила: GOTEST-23, GOTEST-24, GOTEST-25, GOTEST-X8 из Go Test Strategy → раздел 7. Внешние HTTP — мок через httptest.

Важно знать

  • Заглушка — httptest.NewServer, не мок интерфейса HTTP-клиента; реальный HTTP-стек участвует в тесте.
  • Stub пишется прямо в тесте — видно, что именно тест ожидает от внешнего сервиса.
  • Stub проверяет входящий запрос — метод, заголовки, тело; это тест спецификации клиента, не только ответа.
  • Base-url клиента переопределяется через конструктор или опцию — не через глобальную переменную.
  • t.Cleanup(stub.Close) — обязательно; не defer на уровне функции, Cleanup связан с lifetime теста.
  • testify/mock HTTP-клиента запрещён (GOTEST-X8) — теряем проверку сериализации, заголовков, retry, timeout.
  • Заглушку не выносим в глобальные фикстуры — каждый тест декларирует своё ожидание явно.
  • Изоляция t.Parallel() — при транзакционной изоляции stub-порты не пересекаются: каждый httptest.NewServer получает свой порт автоматически.

В интеграционном тесте Go-сервиса внешний REST-партнёр (платёжный шлюз, каталог, логистика) не поднимается в виде контейнера. Вместо этого в тесте создаётся реальный HTTP-сервер на случайном порту через net/http/httptest. Клиент получает адрес этого сервера через конструктор — и весь путь сериализации, заголовков, retry проходит в реальных условиях.

Анатомия httptest-заглушки

Один тест — одна заглушка, объявленная в начале Arrange-блока.

func TestCreateOrder_CallsPaymentGateway(t *testing.T) {
    t.Parallel()

    // Arrange
    paymentStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        assert.Equal(t, http.MethodPost, r.Method)
        assert.Equal(t, "application/json", r.Header.Get("Content-Type"))

        body, _ := io.ReadAll(r.Body)
        var req map[string]any
        _ = json.Unmarshal(body, &req)
        assert.Equal(t, float64(100), req["amount"])

        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte(`{"transactionId":"txn-1"}`))
    }))
    t.Cleanup(paymentStub.Close)

    srv, prep := newTestServerWithPayment(t, paymentStub.URL)
    prep.Clear(t).CreateCustomer(t, "c1")

    // Act
    resp, err := http.Post(srv.URL+"/orders", "application/json",
        strings.NewReader(`{"customerId":"c1","amount":100}`))
    require.NoError(t, err)
    defer resp.Body.Close()

    // Assert
    require.Equal(t, http.StatusCreated, resp.StatusCode)
}

Три элемента обязательны:

  • httptest.NewServer(handler) запускает сервер на случайном порту.
  • Внутри HandlerFuncassert.* на входящий запрос (GOTEST-25).
  • t.Cleanup(paymentStub.Close) завершает сервер после теста.

Передача адреса заглушки в тестовый сервер

Клиент к внешнему партнёру конструируется с явным base-url. Тест передаёт адрес заглушки в newTestServer-вариант с дополнительным параметром (GOTEST-23).

func newTestServerWithPayment(t *testing.T, paymentBaseURL string) (*httptest.Server, *OrderDatabasePreparer) {
    t.Helper()
    pool, _ := pgxpool.New(context.Background(), testDSN)
    t.Cleanup(pool.Close)

    q := db.New(pool)
    repo := order.NewRepository(q)
    clock := &fixedClock{at: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}
    ids := &seqIDGenerator{}

    paymentClient := payment.NewClient(paymentBaseURL)
    svc := order.NewService(repo, clock, ids, paymentClient)

    r := chi.NewRouter()
    orderhttp.Mount(r, svc)
    srv := httptest.NewServer(r)
    t.Cleanup(srv.Close)

    return srv, &OrderDatabasePreparer{pool: pool}
}

payment.NewClient(baseURL) — конструктор принимает строку. Не глобальная переменная, не os.Setenv — только параметр.

Проверка входящего запроса

Заглушка — спецификация клиента. Если клиент отправляет неверный заголовок или не сериализует поле, тест падает внутри HandlerFunc (GOTEST-25).

paymentStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    assert.Equal(t, http.MethodPost, r.Method)
    assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
    assert.Equal(t, "Bearer service-token", r.Header.Get("Authorization"))

    body, _ := io.ReadAll(r.Body)
    var req map[string]any
    _ = json.Unmarshal(body, &req)
    assert.Equal(t, "c1", req["customerId"])
    assert.Equal(t, float64(100), req["amount"])

    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte(`{"transactionId":"txn-1","status":"approved"}`))
}))
t.Cleanup(paymentStub.Close)

Если нужно проверить, что клиент корректно обрабатывает ошибочный ответ от партнёра — пишем отдельный тест с другой заглушкой.

Сценарий отказа партнёра

Тест на деградацию: заглушка возвращает 503, сервис должен вернуть 422 с доменным кодом.

func TestCreateOrder_WhenPaymentGatewayDown_Returns422(t *testing.T) {
    t.Parallel()

    // Arrange
    paymentStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusServiceUnavailable)
    }))
    t.Cleanup(paymentStub.Close)

    srv, prep := newTestServerWithPayment(t, paymentStub.URL)
    prep.Clear(t).CreateCustomer(t, "c1")

    // Act
    resp, err := http.Post(srv.URL+"/orders", "application/json",
        strings.NewReader(`{"customerId":"c1","amount":100}`))
    require.NoError(t, err)
    defer resp.Body.Close()

    // Assert
    require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)

    var body map[string]any
    require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
    assert.Equal(t, "PAYMENT_FAILED", body["code"])
}

Два сценария — два теста, каждый со своей заглушкой.

Проверка тела ответа от партнёра

Если сервис читает поля из ответа партнёра и сохраняет их в БД — проверяем через DatabasePreparer после Act.

func TestCreateOrder_PersistsTransactionId(t *testing.T) {
    t.Parallel()

    // Arrange
    paymentStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte(`{"transactionId":"txn-sber-42"}`))
    }))
    t.Cleanup(paymentStub.Close)

    srv, prep := newTestServerWithPayment(t, paymentStub.URL)
    prep.Clear(t).CreateCustomer(t, "c1")

    // Act
    resp, err := http.Post(srv.URL+"/orders", "application/json",
        strings.NewReader(`{"customerId":"c1","amount":100}`))
    require.NoError(t, err)
    defer resp.Body.Close()
    require.Equal(t, http.StatusCreated, resp.StatusCode)

    // Assert
    var created map[string]any
    require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
    orderId := created["orderId"].(string)

    order := prep.FindOrder(t, orderId)
    assert.Equal(t, "txn-sber-42", order.TransactionID)
}

prep.FindOrder — метод DatabasePreparer, читает строку из БД напрямую через pgx.

Что запрещено

АнтипаттернПравилоЧто взамен
testify/mock HTTP-клиента вместо реального HTTPGOTEST-X8httptest.NewServer
Глобальная заглушка, общая для нескольких тестовGOTEST-24заглушка объявляется внутри каждого теста
Заглушка не проверяет входящий запросGOTEST-25assert.* на метод, заголовки, тело внутри HandlerFunc
os.Setenv для переопределения base-url партнёраGOTEST-23параметр конструктора клиента
defer paymentStub.Close() на уровне функции тестаGOTEST-23t.Cleanup(paymentStub.Close)
Заглушка не возвращает тело при 200GOTEST-25возвращаем реалистичный JSON, который клиент будет десериализовывать
Один тест проверяет и успех, и отказ партнёраGOTEST-3два отдельных теста с двумя заглушками

Куда дальше

  • Базовые правила — TestMain и Testcontainers — как устроена инфраструктура теста в Go.
  • Один тест — структура и имена — AAA, Test<Action>_<Condition>_<Expected>, require vs assert.
  • Kafka, Redis, async — по умолчанию НЕТ — почему не поднимаем и как проверяем Outbox.
  • Resilience Style Guide — Go — retry, circuit-breaker, timeout на HTTP-клиентах к партнёрам.