---
title: "Тестирование endpoints"
description: "Тестирование go-kit сервисов: unit-тесты endpoint прямыми вызовами, fake-next для middleware, fake-сервис для декораторов, кодеки через httptest и интеграция транспорта."
course: "go-kit: микросервисный тулкит"
url: "https://golangmaster.ru/courses/go-kit/lessons/16-testirovanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Тестирование endpoints

*Курс «go-kit: микросервисный тулкит», урок 16 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-kit/lessons/16-testirovanie/*

Два этажа обёрток готовы — настало время проверить, что они действительно работают. Тестирование go-kit устроено проще, чем кажется: endpoint — это функция, middleware — функция над функцией, декоратор — реализация интерфейса, и всё это тестируется прямыми вызовами без серверов, портов и сна. Ты напишешь endpoint тесты как table-driven таблицу к функции, подставишь fake-next обёрткам и fake-сервис декораторам из уроков 14-15, проверишь кодеки через httptest.NewRecorder без сети, а затем поднимешь интеграционный тест транспорта на httptest.NewServer — и увидишь, что половина вопросов «а как это протестировать» уже решена самой архитектурой. Мок-транспорт в курсе — это рука-стаб на двадцать строк: тестирование go kit приучает мокать зависимости, а не библиотеку, и держать пирамиду: сервис, endpoint, обёртки, транспорт — от быстрых тестов к медленным.

### Пирамида тестов go-kit: что и чем проверяем

У go-сервиса четыре тестируемых этажа, и скорость теста обратно пропорциональна его ценности в рантайме: чем выше этаж, тем реже и дороже его проверяют. В основании — сервис: table-driven тесты StringService из [урока 03](/courses/go-kit/lessons/03-service/), микросекунды на прогон, десятки кейсов на метод. Второй этаж — endpoint'ы: те же table-driven, но вызывается makeUppercaseEndpoint, и проверяется шов адаптера: type assertion, маппинг ошибок в Err-поле, Failer. Третий — обёртки обоих этажей из уроков 14 и 15: endpoint-middleware проверяются с fake-next, декораторы — с fake-сервисом; изоляция полная, никакого транспорта. Четвёртый — транспорт и кодеки: kithttp.NewServer гоняется на httptest.NewRecorder без сети, интеграционный сценарий — на httptest.NewServer с реальным HTTP-циклом. Тестирование go kit дорожает по этажам: сервис — миллисекунды, endpoint — миллисекунды, middleware — единицы миллисекунд, транспорт — десятки. Держи пропорцию пирамиды: много быстрых тестов внизу, горстка интеграционных наверху; end-to-end прогон с curl оставь деплою. Ключ к этажу — простая мысль: в go-kit почти всё — функции, а функции тестируются вызовом.

### Тест endpoint: это просто функция

Начни с главного приёма курса. Endpoint — сигнатура func(ctx, request) (response, error) — зовётся в тесте напрямую: ep := makeUppercaseEndpoint(svc); resp, err := ep(context.Background(), UppercaseRequest{S: "go"}). Никакого декодера, никакого HTTP: таблица кейсов с входной строкой, ожидаемым V и ожидаемой ошибкой — почти копия table-driven теста сервиса из урока 03, только проверяется ещё и адаптер. Что здесь проверяется, а что нет. Проверяется: assertion request-структуры, вызов метода сервиса, упаковка ответа, перенос ErrEmpty в Err-поле через Failer — весь шов из [урока 02](/courses/go-kit/lessons/02-endpoint/). Не проверяется: JSON, статусы, заголовки — это транспорт, его тест ниже. Вторая причина endpoint тестов — обёртки: makeUppercaseEndpoint(svc) принимает декорированный сервис, поэтому один и тот же тест можно прогнать на чистом сервисе и на матрёшке с кэшем из урока 15 — и это уже тест интеграции этажей, всё ещё без сети. Ошибки в тестах проверяются через errors.Is на sentinel: строковое сравнение err.Error() ломается при первой же обёртке fmt.Errorf с %w из урока 14.

### Table-driven тест endpoint

```go
func TestUppercaseEndpoint(t *testing.T) {
        ep := makeUppercaseEndpoint(stringService{})

        cases := []struct {
                name    string
                in      string
                want    string
                wantErr error
        }{
                {"обычная", "go", "GO", nil},
                {"пустая", "", "", ErrEmpty},
        }
        for _, c := range cases {
                t.Run(c.name, func(t *testing.T) {
                        resp, err := ep(context.Background(), UppercaseRequest{S: c.in})
                        if c.wantErr != nil {
                                if !errors.Is(err, c.wantErr) {
                                        t.Fatalf("err = %v, хочу %v", err, c.wantErr)
                                }
                                return
                        }
                        if err != nil {
                                t.Fatalf("неожиданная ошибка: %v", err)
                        }
                        if resp.(UppercaseResponse).V != c.want {
                                t.Fatalf("V = %v, хочу %v", resp.(UppercaseResponse).V, c.want)
                        }
                })
        }
}
```

Три проверки — на ошибку, на отсутствие ошибки и на значение: пропуск любой из веток превращает тест в решето. errors.Is на ErrEmpty — канон: sentinel остаётся различимым сквозь Failer и любые будущие обёртки ошибок. Запрос строится литералом структуры — тест не знает про JSON и умеет только сигнатуру endpoint'а.

### Тест middleware: fake-next на двадцать строк

Endpoint-middleware тестируется подстановкой next: обёртка — функция над функцией, значит в тест next ставится стаб, поведение которого ты контролируешь полностью. Два шаблона. Первый — сигнальный next: стаб-функция ставит флаг «меня вызвали» или паникует по требованию — им проверяется, что лимитер не пропустил вызов, что timeout отменил контекст, что recovery превратил панику в ошибку. Второй — записывающий next: стаб фиксирует контекст и аргументы, им проверяются обёртки-трансформеры вроде errwrap из урока 14. Отдельная прелесть — логгер-стаб: реализация log.Logger из урока 06 — один метод Log(keyvals ...interface{}) error; двадцать строк собирают логгер, который копит пары в срез под мьютексом, и middleware логирования можно проверять дословно. Мокается только next и зависимости, потому что именно они интерфейсы; сами endpoint middleware — конкретные функции, и тестируются как функции. Так и работает тестирование go kit: мокать то, что твоя обёртка вызывает, а не то, что она есть.

### Тест recovery: next паникует

```go
func TestRecoveryMiddleware(t *testing.T) {
        logger := newTestLogger() // стаб из хелперов ниже
        ep := recoveryMiddleware(logger)(func(ctx context.Context, req interface{}) (interface{}, error) {
                panic("бум")
        })

        resp, err := ep(context.Background(), UppercaseRequest{S: "go"})
        if err == nil {
                t.Fatal("хотели ошибку, получили nil")
        }
        if resp != nil {
                t.Fatalf("ответ должен быть nil, получен %v", resp)
        }
        if !strings.Contains(logger.text(), "panic: бум") {
                t.Fatalf("в логе нет паники: %s", logger.text())
        }
}

// next без паники — проверка прозрачности:
func TestRecoveryHappyPath(t *testing.T) {
        ep := recoveryMiddleware(newTestLogger())(okEndpoint) // возвращает ("OK", nil)
        resp, err := ep(context.Background(), UppercaseRequest{S: "go"})
        if err != nil || resp.(UppercaseResponse).V != "OK" {
                t.Fatalf("здоровый путь испорчен: %v %v", resp, err)
        }
}
```

Первый тест — негативный сценарий: паника должна стать err, ответ — nil, лог — содержать значение паники. Второй — обязательный парный: обёртка не должна ломать здоровый путь, и два теста всегда пишутся рядом. okEndpoint и newTestLogger — хелперы пакета тестов; заметь, что стаб-next — обычный литерал функции, типы совпадают с endpoint.Endpoint без объявлений.

### Логгер-стаб: log.Logger за 15 строк

```go
type testLogger struct {
        mu  sync.Mutex
        kvs [][]interface{}
}

func (l *testLogger) Log(keyvals ...interface{}) error {
        l.mu.Lock()
        defer l.mu.Unlock()
        l.kvs = append(l.kvs, keyvals)
        return nil
}

func (l *testLogger) text() string {
        l.mu.Lock()
        defer l.mu.Unlock()
        parts := make([]string, 0, len(l.kvs)*2)
        for _, kv := range l.kvs {
                parts = append(parts, fmt.Sprint(kv...))
        }
        return strings.Join(parts, " ")
}
```

Интерфейс kitlog — один метод: интерфейсная сегрегация в чистом виде, поэтому подстановка такая дешёвая. Мьютекс обязателен: middleware логирует из горутин параллельных запросов, и тест, читающий лог во время записи, будет гнать data race — не забудь флаг -race в CI. Тот же стаб годится для endpoint- и service-логгеров: у них одинаковый контракт log.Logger.

### Как поймать пролетевший вызов

**Вопрос:** Как убедиться, что NewErroringLimiter при пустом бакете не вызвал endpoint вовсе?

- Проверить, что err == nil — лимитер молчит
- ✅ В next поставить стаб с panic("endpoint вызван") и убедиться, что паники нет
- Обернуть endpoint вторым логгером и поискать запись
- Замерить время: если быстрее миллисекунды — значит, не вызван

Сигнальный next — самый надёжный способ проверить «не был вызван»: если обёртка пробросит вызов, стаб запаникует, тест упадёт с понятным сообщением. Время и логи — косвенные и хрупкие признаки: тайминги в CI плавают, а логи зависят от соседних обёрток. ErrLimited в err при этом проверяется парой errors.Is — она подтверждает, что отказ лимитный, а не случайный.

### Тест декоратора: fake-сервис вместо реализации

Service-middleware из урока 15 тестируется зеркально: вместо fake-next — fake-сервис, реализующий StringService. Стаб-реализация считает вызовы метода (атомарный счётчик или канал-лог), возвращает заранее заготовленные ответы и умеет ломаться по сценарию — вернуть ErrEmpty, зависнуть на канале. Классический кейс кэша: fake-сервис, поверх которого cachingMiddleware; первый вызов инкрементирует счётчик base, второй — нет, при этом оба вернули одно значение, а sentinel-ошибка не закэшировалась. Это тест целого контракта из урока 15 — три утверждения из одной таблицы. Заметка об интеграции этажей: матрёшку для теста собирай той же applyMiddleware, что и prod-код — если тест соберёт декораторы руками, а main через срез, расхождение однажды проскочит мимо тестов. И ещё: fake-сервис — это не «мок-библиотека»: для интерфейса из двух методов ручной стаб дешевле и нагляднее кодогенерации; gomock и компания уместны, когда интерфейсов десятки и обновлять стабы руками дороже, чем держать тулчейн.

### Кэш на fake-сервисе: три утверждения

```go
type fakeService struct {
        calls int32
}

func (f *fakeService) Uppercase(ctx context.Context, s string) (string, error) {
        atomic.AddInt32(&f.calls, 1)
        return strings.ToUpper(s), nil
}
func (f *fakeService) Count(ctx context.Context, s string) int { return len(s) }

func TestCachingMiddleware(t *testing.T) {
        base := &fakeService{}
        svc := NewCachingMiddleware(map[string]string{})(base)

        v1, err1 := svc.Uppercase(context.Background(), "go")
        v2, err2 := svc.Uppercase(context.Background(), "go")

        if v1 != "GO" || v2 != "GO" || err1 != nil || err2 != nil {
                t.Fatalf("значения испорчены: %q %q %v %v", v1, v2, err1, err2)
        }
        if n := atomic.LoadInt32(&base.calls); n != 1 {
                t.Fatalf("base вызван %d раз, хочу 1", n)
        }
}
```

Атомарный счётчик вместо флага: будущие параллельные тесты кэша не сломают стаб. Средняя проверка — самая важная: именно «base вызван один раз» отличает кэш от простого проброса. Тест живёт в микросекундах: ни сети, ни портов, ни сна — обычные вызовы функций по интерфейсу.

### Тест кодека: httptest.NewRecorder без сети

Транспортный этаж проверяется двумя уровнями, и первый — без сети вообще. kithttp.NewServer возвращает http.Handler, а httptest.NewRecorder — фейковый ResponseWriter, который собирает статус и тело в память. Собери сервер, сформируй httptest.NewRequest, вызови server.ServeHTTP(w, r) — и весь HTTP-цикл прошёл, не открыв ни порта. Что здесь проверяется: декодер (сломанный JSON должен дать честный 400 из урока 05), энкодер (Content-Type application/json), error encoder (ErrEmpty → 400 с телом {"error":"empty string"}, кривая ошибка → 500), Failer-контракт. Мутации канона: этот тест — единственное место, где транспортные типы легальны в тесте; всё остальное про байты живёт в кодеках. Скорость — микросекунды на кейс, поэтому транспортная таблица может быть такой же жирной, как сервисная. Второй уровень — интеграция: httptest.NewServer поднимает настоящий listener на loopback; его цена — десятки миллисекунд, его ценность — проверка полного цикла: json.Marshal, заголовки, конверт ошибки, поведение клиента. Один-два таких теста на сервис достаточно.

### Recorder: статус и тело в памяти

```go
func TestUppercaseHTTP(t *testing.T) {
        ep := makeUppercaseEndpoint(stringService{})
        server := kithttp.NewServer(
                ep,
                decodeUppercaseRequest, // из урока 04
                encodeResponse,         // с Failer и encodeError из урока 05
        )

        body := strings.NewReader("{"s":"go"}")
        req := httptest.NewRequest(http.MethodPost, "/uppercase", body)
        req.Header.Set("Content-Type", "application/json")
        w := httptest.NewRecorder()

        server.ServeHTTP(w, req)

        if w.Code != http.StatusOK {
                t.Fatalf("статус %d, хочу 200", w.Code)
        }
        if got := w.Body.String(); !strings.Contains(got, "GO") {
                t.Fatalf("тело %q не содержит GO", got)
        }
}
```

Три действия — собрать handler, сконструировать запрос, вызвать ServeHTTP — и никаких слушателей. Рекордер позволяет проверить всё, что реально пишет энкодер: статус, тело, заголовки через w.Header().Get. Table-driven продолжается и здесь: вторая строка таблицы — пустой s, ожидание 400 и substring empty string; третья — кривой JSON, ожидание 400 из ветки сломанного декодера.

### Что упадёт в Recorder

```go
req := httptest.NewRequest(http.MethodPost, "/uppercase",
        strings.NewReader("{"s":""}"))
w := httptest.NewRecorder()
server.ServeHTTP(w, req)
// encodeError из урока 05: errors.Is(err, ErrEmpty) -> 400

fmt.Println(w.Code, w.Body.String())
```

**Что выведет программа?**

- 200 {"v":"","err":""}
- ✅ 400 {"error":"empty string"}
- 500 text/plain: internal error
- 400 пустое тело без JSON

Endpoint вернул UppercaseResponse{Err: err.Error()} — это Failer, поэтому encodeResponse ушёл в encodeError из урока 05, тот распознал ErrEmpty через errors.Is и записал 400 с JSON-конвертом ошибки. Вариант 3 — судьба нераспознанных ошибок: голый err без типа-носителя уезжает в 500 text/plain, и именно поэтому каждый sentinel сервиса обязан иметь ветку в encodeError.

### Интеграционный тест: httptest.NewServer

Второй уровень транспортных тестов поднимает настоящий HTTP-сервер: httptest.NewServer(server) слушает loopback и отдаёт базовый URL; тест идёт обычным http.Client — как реальный потребитель. Ценность уровня: он проверяет стыки, которые Recorder не видит — сериализацию json.Marshal в энкодере, заголовки Content-Type обеих сторон, обработку редиректов и таймаутов клиентом, а главное — форму конверта ошибки глазами клиента: тест декодирует ответ в структуру и сверяет поле error, а не substring тела. Дедлайн теста — context.WithTimeout на запрос: зависший сервер не повесит CI, а упадёт с понятным сообщением за две секунды. Заметь честную деталь: этот тест ходит по сети — localhost, но сеть; он медленнее и чуть более хрупкий, поэтому его в одном экземпляре на критичный метод, а не на каждый кейс таблицы. Мок-транспорт, о котором спрашивают чаще всего, — это и есть httptest.NewServer: go-kit не требует мокать kithttp — сервер готов, а мокаешь ты свой сервис под ним.

### Интеграция: клиент против httptest.NewServer

```go
func TestUppercaseIntegration(t *testing.T) {
        ep := makeUppercaseEndpoint(stringService{})
        handler := kithttp.NewServer(ep, decodeUppercaseRequest, encodeResponse)
        ts := httptest.NewServer(handler)
        defer ts.Close()

        ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
        defer cancel()

        req, _ := http.NewRequestWithContext(ctx, http.MethodPost,
                ts.URL+"/uppercase", strings.NewReader("{"s":"go"}"))
        req.Header.Set("Content-Type", "application/json")

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
                t.Fatalf("запрос не прошёл: %v", err)
        }
        defer resp.Body.Close()

        if resp.StatusCode != http.StatusOK {
                t.Fatalf("статус %d", resp.StatusCode)
        }
        var got UppercaseResponse
        if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
                t.Fatalf("ответ не декодируется: %v", err)
        }
        if got.V != "GO" {
                t.Fatalf("V = %q", got.V)
        }
}
```

Декодирование в request/response-структуру — проверка контракта глазами клиента: то же поле V, тот же json-тег из урока 02. defer ts.Close() обязателен — иначе тест слушатель живёт до конца процесса и порт пачкается. Клиент с контекстом и таймаутом — привычка из урока 19: зависший тест хуже упавшего, потому что подвешивает весь прогон.

### Матрица инструментов

**Вопрос:** Тебе нужно проверить, что лимитный отказ доезжает до клиента как 429 с Retry-After. Какой уровень теста выберешь?

- Table-driven на сервисе: sentinel и правда
- Endpoint-тест: errors.Is(err, ErrLimited) достаточно
- ✅ Recorder-тест: запрос в server.ServeHTTP, проверка w.Code и заголовка
- Ничего не подойдёт: нужен живой прод

Ретрай-After пишется в заголовок транспортного кодека из урока 09 — значит, нужен http.ResponseWriter, и Recorder даёт его без сети: тип-носитель с StatusCode() и Header() обрабатывается error-encoder'ом, статус и заголовок читаются с w. Endpoint-тест подтвердит только половину контракта (sentinel), сервисный — вовсе ничего про транспорт.

### Матрица: этаж → тест → инструмент

| Этаж | Что проверяем | Инструмент | Скорость |
| --- | --- | --- | --- |
| Сервис (урок 03) | логика, sentinel-ошибки | table-driven, прямой вызов | микросекунды |
| Endpoint (урок 02) | адаптер, Failer, Err-поле | table-driven, вызов ep(ctx, req) | микросекунды |
| Endpoint-middleware (14) | перехват, порядок, ошибки | fake-next, логгер-стаб | миллисекунды |
| Service-middleware (15) | кэш, ретраи, счётчик вызовов | fake-сервис с атомарным счётчиком | миллисекунды |
| Кодеки (04-05) | статусы, тело, Content-Type | httptest.NewRecorder без сети | миллисекунды |
| Транспорт | полный HTTP-цикл, клиент | httptest.NewServer + http.Client | десятки мс |

### Чего не тестировать: библиотека и каркас

Обратная сторона пирамиды — куда тесты не нужны. Не тестируй go-kit: kithttp.NewServer гарантированно вызывает endpoint, это покрыто библиотечными тестами; твой Recorder-тест уже проверяет интеграцию с твоими кодеками — дублировать внутренности пакета бессмысленно. Не тестируй обёртки других людей в их же терминах: gobreaker проверял свои состояния, твоя зона ответственности — контракт ErrOpenState → 503 в твоём encodeError. Не пиши тесты на конфигурацию в main: сборку матрёшек проверяют типы компилятора и один интеграционный тест. И не гоняйся за процентом покрытия в тестах каркаса — гони гонку и детерминизм: go test -race -count=1 в CI обязателен для всего, что касается middleware из уроков 14-15, потому что замыкания исполняются параллельными горутинами, а мьютексы и атомарные счётчики проверяются только рантаймом с гонкой. Правило выбора цели теста простое: тестируй то, что написал ты, через контракт, который объявил ты, — библиотека остаётся чёрным ящиком, твой код — белым. Финальная строка CI-конфига: go-kit тесты гоняются с -race -count=1 — привычка, которая окупается с первого инцидента.

### Чья зона ответственности

**Вопрос:** Команда спорит: писать ли тест на gobreaker, что после MaxRequests в half-open цепь пропускает новые попытки?

- Да: breaker стоит в нашей цепочке — наша зона
- ✅ Нет: это внутренняя логика библиотеки, у неё свои тесты
- Да, но через сбой реального соседа и sleep в тесте
- Только вручную на стенде — автоматизировать нельзя

Состояния gobreaker — контракт библиотеки, покрытый её тестами; дублирование уйдет в категорию тестов, которые всегда зелёные и ничего не ловят. Твоя зона — склейка: ErrOpenState превращается в 503, OnStateChange инкрементирует gauge, fallback отвечает заглушкой — всё это тестируется твоими средствами с fake-зависимостью и без сна.

### Дедлайны и сон: тесты без flaky

Тесты, проверяющие время, — главный источник flakiness, и у go-kit-стека есть аккуратный выход. Не пиши time.Sleep в тестах: Sleep(100ms) на быстрой машине стреляет в 99% прогонов, а на CI в час пик — в 95%, и красные тесты перестают что-либо значить. Вместо этого сжимай время: timeout-обёртка из урока 14 тестируется с дедлайном в пять миллисекунд против fake-next, который блокируется на канале без записи; TestRecovery паникует мгновенно; retry-декоратор из урока 15 конфигурируется паузой в наносекунды — зависимость паузы приходит конструктором, как и всякая другая. Если тест должен дождаться события — дождись его блокирующим чтением канала, а не опросом со сном: стаб может отдавать chan struct{}, тест читает с context.WithTimeout. Дедлайн теста — всегда: t.Deadline или контекст на запрос; упавший тест лучше зависшего. И помни про -race: обёртки, кэши и логгер-стабы — многогорутинный код, гонка в тесте — баг в проде, который успел проголосовать.

### Логгер-стаб и порядок записей

```go
logger := newTestLogger()
svc := NewLoggingMiddleware(logger)(stringService{})
svc.Uppercase(context.Background(), "go")

// Из predict урока 03: внутренний пишет первым.
// Теперь — endpoint-этаж:
ep := loggingMiddleware(logger)(makeUppercaseEndpoint(svc))
ep(context.Background(), UppercaseRequest{S: "go"})
```

**Что выведет программа?**

- Лог одна запись: только endpoint-слой, сервис-лог слился
- ✅ Две записи: сначала endpoint-method=uppercase, затем service-запись с input=go
- Две записи в обратном порядке: сервис быстрее
- Ни одной: логгер-стаб не поддерживает пары

Оба этажа пишут в один стаб: endpoint-лог снаружи открывает запись позже, но его defer сработает последним — вернее, порядок записей здесь такой: service-логгер стоит внутри endpoint-замыкания, его defer сработает при возврате из base, ДО defer внешней обёртки. Правило «кто внутри — тот пишет первым» из урока 03 работает для стаба одинаково на обоих этажах — ровно поэтому его и удобно проверять тестом.

### Собери Recorder-тест

Восстанови транспортный тест без сети: три пропуска.

```go
func TestUppercaseHTTP(t *testing.T) {
        server := kithttp.NewServer(
                makeUppercaseEndpoint(stringService{}),
                decodeUppercaseRequest,
                encodeResponse,
        )

        req := httptest.___(http.MethodPost, "/uppercase",
                strings.NewReader("{"s":"go"}"))
        w := httptest.___()
        server.___(w, req)

        if w.Code != http.StatusOK {
                t.Fatalf("статус %d, хочу 200", w.Code)
        }
}
```

Подсказка: Первый пропуск — конструктор запроса пакета httptest; второй — фейковый ResponseWriter, собирающий ответ в память; третий — метод http.Handler, запускающий весь цикл.

Правильные ответы в пропусках: `NewRequest`, `NewRecorder`, `ServeHTTP`

### Путь теста от базы пирамиды к вершине

Расставь шаги прогонки: от самого быстрого теста к самому дорогому.

Правильный порядок строк:

1. `Table-driven тест сервиса: stringService{} без зависимостей`
2. `Table-driven тест endpoint: makeUppercaseEndpoint(svc) с литералом запроса`
3. `Тест endpoint-middleware: recoveryMiddleware над fake-next с паникой`
4. `Тест service-middleware: cachingMiddleware над fake-сервисом со счётчиком`
5. `Recorder-тест кодека: server.ServeHTTP(w, req) без сети`
6. `Интеграционный тест: httptest.NewServer и клиент с дедлайном`
7. `Smoke-прогон собранного сервиса curl'ом после деплоя`

### Термины урока

- **Endpoint-тест** — Прямой вызов ep(ctx, request) в table-driven таблице: проверяется шов адаптера, Failer и Err-поле; без декодера и сети.
- **Fake-next** — Стаб endpoint.Endpoint в тесте middleware: сигнальный (panic, флаг вызова) или записывающий (фиксирует ctx и аргументы); мокается next, а не обёртка.
- **Fake-сервис** — Ручная реализация StringService с атомарным счётчиком вызовов и сценариями ошибок: база для тестов декораторов кэша и ретраев.
- **Логгер-стаб** — Реализация log.Logger за один метод: копит keyvals под мьютексом; даёт проверять текст логов от middleware обоих этажей.
- **httptest.NewRecorder** — Фейковый ResponseWriter в памяти: server.ServeHTTP(w, req) прогоняет весь транспорт без сети; проверяются Code, Body и заголовки.
- **httptest.NewServer** — Настоящий listener на loopback для интеграционных тестов: полный HTTP-цикл, клиент с context.WithTimeout, defer ts.Close() обязателен.

> **Ловушка: Sleep в тестах.** time.Sleep — чек на flaky: на CI тайминги плавают, и тесты краснеют случайно, после чего команда привыкает перезапускать прогон — а это уже не тесты. Время в тестах сжимается до миллисекунд, события дожидаются чтением канала с дедлайном, пауза ретрая приходит зависимостью конструктора. Прогон всего пакета — с -race и -count=1: обёртки из уроков 14-15 исполняются горутинами параллельно, и только рантайм с гонкой ловит мьютекс-ошибки.

### Что дальше

Тестовая пирамида stringsvc стоит на всех четырёх этажах: сервис и endpoint — table-driven, обёртки — на стабах, транспорт — на httptest. Всё, что написано в уроках 02-15, теперь проверяемо изоляционно, и это последний аргумент в пользу слоистой архитектуры из [урока 01-filosofiya](/courses/go-kit/lessons/01-filosofiya/): слой тестируется по контракту соседа, а не по контракту мира. Дальше карта курса ведёт в стройку: урок 17-struktura — структура go kit проекта (каталоги, DI, шаблон большого сервиса), затем конфигурация и graceful shutdown; обе темы опираются на матрёшку и endpoint-цепочку из уроков 14-15, которые ты теперь умеешь и писать, и тестировать. Путь до итогового проекта урока 25 — на [странице курса](/courses/go-kit/); HTTP-декодер для тестов освежи в [уроке 04-http-transport](/courses/go-kit/lessons/04-http-transport/).

> **Практика локально.** Урок требует модуля go-kit и установленного Go: в песочнице курса сторонние пакеты не исполняются, поэтому интерактивы построены на чтении кода. Локально: скопируй логгер-стаб и fake-сервис в stringsvc_test.go, прогони тесты урока с -race -count=1; добавь таблицу Recorder-кейсов (200, 400 на ErrEmpty, 400 на кривой JSON); удали один defer cancel() в интеграционном тесте и посмотри, как -race ловит утечку; включи в CI go test -race -count=1 и запиши время прогона пирамиды — оно должно остаться в секундах.

## Коротко: что запомнить

- Пирамида тестов go-kit: сервис и endpoint — table-driven микросекунды, middleware — стабы, кодеки — Recorder, транспорт — httptest.NewServer; дорогое — наверху и редко.
- Endpoint-тест — прямой вызов ep(ctx, request): проверяется шов адаптера, Failer и перенос ErrEmpty; ошибки — только через errors.Is на sentinel.
- Middleware тестируется fake-next: сигнальный стаб с panic ловит пролетевший вызов, записывающий — проверяет трансформеры; логгер-стаб из одного метода Log даёт проверку текста логов.
- Декораторы тестируются fake-сервисом со счётчиком вызовов; матрёшку в тесте собирает та же applyMiddleware, что и prod-код.
- Recorder-тест прогоняет kithttp.NewServer без сети: статусы, тела и заголовки проверяются на httptest.NewRecorder; sentinel-ветки encodeError — обязательная таблица.
- Интеграция — httptest.NewServer с клиентом и дедлайном: полный HTTP-цикл и контракт глазами клиента, один-два теста на сервис.
- Не тестируй библиотеку и не спи в тестах: go-kit остаётся чёрным ящиком, время сжимается, события дожидаются каналом, CI гоняет -race -count=1.

## Домашнее задание

Построй тестовую пирамиду stringsvc полностью. Первое: endpoint-тест makeUppercaseEndpoint из урока с тремя кейсами — обычная строка, пустая, строка из пробелов; проверь sentinel через errors.Is. Второе: логгер-стаб из урока и два теста recoveryMiddleware — паника превращается в err с записью в лог, здоровый путь прозрачен; прогон с -race обязателен. Третье: fake-сервис со счётчиком и три утверждения кэша из урока 15 — значения равны, ошибки не кэшируются, base вызван один раз; добавь кейс на два разных ключа. Четвёртое: Recorder-таблица на три кейса — 200 на GO, 400 на ErrEmpty, 400 на кривой JSON — и проверка Content-Type на положительном кейсе. Пятое: интеграционный тест на httptest.NewServer с дедлайном контекста и defer Close, декодирование ответа в UppercaseResponse. Шестое: замерь время прогона go test -race -count=1 и запиши, какой этаж съел больше всего; если транспортный тест дольше ста миллисекунд — разберись, что мешает. Седьмое: письменно ответь, почему limiter-тест со Sleep в пятьдесят миллисекунд — плохой тест, и как его переписать.

## Вопросы и ответы по уроку

### Нужны ли библиотеки вроде gomock или testify для тестов go-kit?

Нет, стандартная библиотека закрывает почти всё: endpoint — функция, middleware — функция над функцией, сервис — интерфейс из двух методов; ручные стабы по двадцать строк нагляднее кодогенерации. gomock уместен, когда интерфейсов десятки и стабы разрастаются; testify добавляет assert-хелперы поверх t.Fatal. Канон курса — таблицы на struct-литералах, стабы на замыканиях, -race в CI: минимум зависимостей, максимум читаемости.

### Как тестировать gRPC-транспорт из урока 13?

Тем же паттерном, что и HTTP: каркас grpc-go даёт тестовый канал bufconn — gRPC-сервер слушает не порт, а буфер, и клиент подключается через grpc.DialContext с Dialer на тот же буфер; сеть и порты не нужны. Endpoint-тесты при этом те же: gRPC-кодеки — функции, grpctrans.NewServer — обёртка endpoint'а, и половина проверок делается до транспорта. Интеграцию же гоняют по bufconn с дедлайном контекста.

### Тест endpoint или тест сервиса — не дубль ли это?

Нет: сервисный тест проверяет логику (uppercase и sentinel), endpoint-тест — шов: type assertion request-структуры, упаковку ответа, перенос ошибки в Err-поле через Failer. Ломаются эти этажи по-разному: рефакторинг сервиса не должен ронять тесты адаптера и наоборот. Плюс endpoint-тест принимает декорированный сервис — он ловит поломки интеграции этажей из уроков 14-15, которые сервисный тест не видит в принципе.
