Опирается на правила:
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/mockHTTP-клиента запрещён (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)запускает сервер на случайном порту.- Внутри
HandlerFunc—assert.*на входящий запрос (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-клиента вместо реального HTTP | GOTEST-X8 | httptest.NewServer |
| Глобальная заглушка, общая для нескольких тестов | GOTEST-24 | заглушка объявляется внутри каждого теста |
| Заглушка не проверяет входящий запрос | GOTEST-25 | assert.* на метод, заголовки, тело внутри HandlerFunc |
os.Setenv для переопределения base-url партнёра | GOTEST-23 | параметр конструктора клиента |
defer paymentStub.Close() на уровне функции теста | GOTEST-23 | t.Cleanup(paymentStub.Close) |
Заглушка не возвращает тело при 200 | GOTEST-25 | возвращаем реалистичный JSON, который клиент будет десериализовывать |
| Один тест проверяет и успех, и отказ партнёра | GOTEST-3 | два отдельных теста с двумя заглушками |
Куда дальше
- Базовые правила — TestMain и Testcontainers — как устроена инфраструктура теста в Go.
- Один тест — структура и имена — AAA,
Test<Action>_<Condition>_<Expected>,requirevsassert. - Kafka, Redis, async — по умолчанию НЕТ — почему не поднимаем и как проверяем Outbox.
- Resilience Style Guide — Go — retry, circuit-breaker, timeout на HTTP-клиентах к партнёрам.