---
title: "Тестирование Connect-сервисов"
description: "Тестирование connect-сервисов: хендлеры без сети, полный стек на httptest, коды ошибок через connect.CodeOf, стримы и перехватчики, интеграционные тесты и CI."
course: "Connect: современный RPC от Buf"
url: "https://golangmaster.ru/courses/connect/lessons/16-testirovanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Тестирование Connect-сервисов

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

Тестирование connect-сервисов строится на том, что хендлер — обычный Go-тип, а транспорт — обычный http.Handler. Отсюда два уровня тестов без единой внешней зависимости: прямой вызов метода хендлера проверяет бизнес-логику без сети, а полный стек через httptest поднимает настоящий Connect-хендлер и гоняет против него настоящий сгенерированный клиент — со всей сериализацией, кодами ошибок и перехватчиками. Этот приём — то самое тестирование grpc go-сервисов, которое обычно требует запуска сервера на порту; в Connect он умещается в одну строчку без сокетов. Именно поэтому тестирование connect сводится к обычному go test, а полный стек на httptest — это те самые «connect тесты», которыми проверяют контракт целиком. Разбираем оба уровня, тесты перехватчиков из седьмого урока, аутентификацию из тринадцатого, стримы из восьмого и gzip-бомбы из четырнадцатого — и собираем всё в интеграционный контур для CI.

В классическом стеке тестирование grpc go-сервисов — это отдельная дисциплина: либо поднимается буферный listener и настоящий сервер, либо тестируются только внутренние функции, а протокол остаётся непроверенным. Connect превращает тесты в обычную Go-историю по одной причине: и хендлер, и клиент — тонкие обёртки над знакомыми абстракциями. Хендлер — http.Handler, смонтированный на mux; клиент — структура, которой вы передаёте http.Client и базовый URL. Между ними нет ничего, что требовало бы сети: httptest подменяет и то, и другое in-memory. Отсюда пирамида урока. Нижний слой — прямые вызовы: тест создаёт ваш notesServer{} и зовёт GetNote как обычный метод — быстро, детерминированно, без сериализации. Средний слой — полный стек: httptest.NewServer поднимает настоящий хендлер из [третьего урока](/courses/connect/lessons/03-pervyj-servis/), сгенерированный клиент из [пятого](/courses/connect/lessons/05-klienty/) ходит по нему как по настоящему API, и тестирование connect покрывает всё — кодеки, HTTP-маппинг ошибок из [шестого урока](/courses/connect/lessons/06-oshibki/), перехватчики, лимиты и сжатие из [четырнадцатого](/courses/connect/lessons/14-compression/). Верхний слой — интеграционные тесты в CI, где к этому добавляются настоящие зависимости. Слои не исключают, а дополняют друг друга: прямой вызов отлаживает логику, полный стек ловит протокольные ошибки, интеграция ловит ошибки окружения. По дороге закрепим канон: тесты пишутся table-driven, как в шестнадцатом уроке [курса go-advanced](/courses/go-advanced/lessons/16-testirovanie/), а флаг -race обязателен — стримы и горутины здесь всюду.

### Хендлер без сети: прямой вызов метода

Начнём с самого дешёвого уровня. Ваш notesServer реализует сгенерированный интерфейс — значит, тест может создать его напрямую и вызвать метод как обычную функцию Go: собрать request-структуру, получить ответ и ошибку. Такой тест собирается за микросекунды, не требует http.Client, не зависит от protobuf-сериализации и идеально подходит для бизнес-логики: правил доступа, валидации, вычислений. Но у него есть жёсткая граница — внутри прямого вызова нет Connect. Ошибка, которую хендлер вернул через connect.NewError, в тесте — та же обычная Go-ошибка, что и любая другая: коды, статусы HTTP и сериализация на этом уровне не существуют. Это не баг, а свойство уровня: вы тестируете функцию, а не RPC. Отсюда правила гигиены. Первое: бизнес-ошибки возвращайте connect.NewError с осмысленным кодом уже в хендлере — тогда и прямой тест, и полный стек увидят одну и ту же семантику (как раскладывать ошибки по кодам — шестой урок). Второе: в прямых тестах проверяйте именно логику — ветки, границы, инварианты хранилища, — и не пытайтесь через них проверять заголовки, коды в HTTP и перехватчики: для этого есть следующий уровень. Третье: если хендлер тянет зависимости — хранилище, часы, генератор идентификаторов, — заведите их в тестовой версии фейками на простых структурах; интерфейсы из третьего урока этому помогают. Прямые тесты — это unit-уровень вашего RPC-приложения, и их должно быть большинство: они дешевле всего и падают точнее всех.

### Прямой вызов: логика без протокола

```go
func TestGetNote_NotFound(t *testing.T) {
        server := &notesServer{store: newFakeStore()} // фейк без БД

        got, err := server.GetNote(context.Background(),
                &notesv1.GetNoteRequest{Id: "missing"})

        if err == nil {
                t.Fatal("ожидали ошибку, получили nil")
        }
        // Ошибка пришла из connect.NewError в хендлере —
        // но здесь это просто Go-ошибка с текстом.
        if !strings.Contains(err.Error(), "не найдена") {
                t.Fatalf("неожиданная ошибка: %v", err)
        }
        if got != nil {
                t.Fatalf("ответ при ошибке должен быть nil, got %v", got)
        }
}
```

Хендлер тестируется как обычная функция: без mux, без клиента, без сети. Код ошибки здесь не проверяется — на этом уровне его просто нет; код проверит полный стек ниже.

**Вопрос:** В прямом тесте хендлер вернул connect.NewError(connect.CodeNotFound, ...). Что увидит тест, сравнивая ошибку?

- connect.CodeNotFound — код доступен и без транспорта
- ✅ Обычную Go-ошибку с текстом: код ошибки существует только на уровне протокола, здесь проверяют логику, а не код
- Панику: NewError нельзя вызывать вне HTTP-запроса
- Ошибку с кодом CodeUnknown, потому что нет HTTP-статуса

connect.NewError оборачивает ошибку, и её текст доступен, но код и HTTP-маппинг — забота транспорта и протокола. Прямой вызов проверяет бизнес-логику; коды ошибок проверяются полным стеком через connect.CodeOf, который увидим ниже.

### Полный стек на httptest: клиент против своего хендлера

Главный приём httptest connect-разработки: поднять ваш настоящий хендлер через httptest.NewServer и вызвать его настоящим сгенерированным клиентом. Вы собираете mux ровно как в третьем уроке — path, handler из NewNoteServiceHandler, опции WithInterceptors, WithReadMaxBytes, всё честно, — отдаёте mux в httptest.NewServer и получаете listening-адрес. Клиенту NewNoteServiceClient нужен http.Client и базовый URL — и httptest даёт оба готовыми: server.Client() — уже настроенный клиент тестового сервера, server.URL — адрес. Всё это в памяти одного процесса: ни портов вручную, ни ожидания старта, ни сокетов в CI. Что такой тест проверяет на самом деле — весь протокольный путь: protobuf-сериализацию запроса и ответа, HTTP-маппинг кодов из шестого урока, перехватчики из седьмого, лимиты чтения и сжатие из четырнадцатого урока. Именно поэтому код ошибки здесь наконец проверяется честно: helper connect.CodeOf возвращает код connect-ошибки, и тест утверждает не текст, а семантику — CodeNotFound, CodeInvalidArgument, CodePermissionDenied. Стандартная форма — таблица кейсов с t.Run: на каждый код свой случай {запрос, ожидаемый код}, и один прогон покрывает всю матрицу ошибок. Замечание про URL: client подключается к server.URL, а не к отдельному адресу — и если в тестах нужен строгий базовый URL (например, для перехватчика, который смотрит на процедуру), он задаётся при сборке клиента, как в проде. Так появляются тесты connect-контракта: тот же код, что исполняется в проде, вызывается тем же клиентом, что едет в проде, — отличается только транспорт под ним.

### Полный стек: таблица кейсов на коды ошибок

```go
func TestGetNoteCodes(t *testing.T) {
        mux := http.NewServeMux()
        path, handler := notesv1connect.NewNoteServiceHandler(
                &notesServer{store: newFakeStore()},
        )
        mux.Handle(path, handler)
        server := httptest.NewServer(mux)
        defer server.Close()

        client := notesv1connect.NewNoteServiceClient(server.Client(), server.URL)

        cases := []struct {
                name string
                id   string
                code connect.Code
        }{
                {"существующая заметка", "n_1", connect.CodeOK},
                {"нет такой", "missing", connect.CodeNotFound},
                {"чужая заметка", "n_secret", connect.CodeNotFound},
                {"пустой идентификатор", "", connect.CodeInvalidArgument},
        }
        for _, tc := range cases {
                t.Run(tc.name, func(t *testing.T) {
                        _, err := client.GetNote(context.Background(),
                                connect.NewRequest(&notesv1.GetNoteRequest{Id: tc.id}))
                        if connect.CodeOf(err) != tc.code {
                                t.Fatalf("код = %v, хотим %v", connect.CodeOf(err), tc.code)
                        }
                })
        }
}
```

server.Client() и server.URL — готовые транспорт и адрес. connect.CodeOf(err) возвращает код ошибки (для успешного вызова err == nil даёт CodeOK) — таблица кейсов превращается в матрицу проверки кодов без единой сети.

### Какой код увидит клиент?

```go
// Хендлер:
func (s *notesServer) GetNote(ctx context.Context, req *connect.Request[notesv1.GetNoteRequest]) (*connect.Response[notesv1.GetNoteResponse], error) {
        if req.Msg.Id == "" {
                return nil, fmt.Errorf("пустой идентификатор") // не NewError!
        }
        // ...
}

// Полный стек на httptest: клиент вызывает GetNote с пустым Id.
// Вопрос: что вернёт connect.CodeOf(err) на стороне теста?
```

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

- CodeInvalidArgument: fmt.Errorf тоже создаёт код
- ✅ CodeUnknown: голая ошибка без кода маппится в unknown — в хендлере надо было звать connect.NewError
- CodeInternal: fmt.Errorf всегда становится internal
- Ошибки не будет: текст ошибки вернётся в ответе

Без connect.NewError у ошибки нет кода, и клиент видит CodeUnknown — это ловушка из шестого урока, теперь на тестовом уровне. Именно такие кейсы и ловит полный стек: прямой вызов хендлера этой разницы не покажет.

**Вопрос:** Чем полный стек на httptest лучше прямого вызова хендлера, и что он всё равно не проверяет?

- ✅ Проверяет сериализацию, коды, перехватчики; не проверяет реальную сеть, TLS и внешний ingress
- Проверяет только скорость: он быстрее прямого вызова
- Проверяет сетевые сбои, обрывы TCP и балансировщик нагрузки
- Не проверяет ничего сверх прямого вызова, но проще пишется

httptest гоняет весь протокольный путь — кодеки, HTTP-маппинг кодов, перехватчики, лимиты. За его границей остаются настоящий сокет, TLS-рукопожатие, прокси и деплой — это уже интеграционный уровень и проблемы двадцать второго урока.

### Перехватчики и метаданные: тестируем auth из тринадцатого

Перехватчики — самый недооценённый предмет тестирования, и зря: именно в них живут сквозные ошибки — авторизация, логирование, лимиты. Перехватчик — обычное значение типа connect.Interceptor, значит его можно тестировать двумя способами. Первый — изолированно: WrapUnary получает вызов next и запрос; тест подставляет фейковый next, который записывает, что до него доехало, — и проверяет, что перехватчик добавил нужные заголовки, проставил значения в контекст или отклонил запрос. Второй — в составе полного стека: хендлер собирается с WithInterceptors(authInterceptor из тринадцатого урока), и тестовые вызовы либо несут токен в req.Header(), либо нет. Второй способ честнее, потому что проверяет то, как перехватчик реально смонтирован, — и это тест на несколько строк: вызов без заголовка Authorization должен вернуть CodeUnauthenticated, вызов с протухшим токеном — тоже, вызов с валидным — пройти до хендлера. Отдельный инструмент для мелких проверок — тестовый перехватчик, который вешается на клиент в полном стеке и подглядывает за запросами и ответами: им удобно фиксировать, что сервер проставил заголовки ответа или что процедура вызова выглядит как /notes.v1.NoteService/GetNote. Контекстные значения из тринадцатого урока — userFromContext — проверяются там же: перехватчик кладёт пользователя в контекст, хендлер его читает, тест проходит только если связка работает в настоящем потоке исполнения. Практический минимум для auth-контура: три кейса полного стека (нет токена, плохой токен, хороший токен) — и у вас есть исполняемая документация безопасности, которая падает раньше, чем упадёт прод.

### Три кейса аутентификации

```go
func TestAuth(t *testing.T) {
        mux := http.NewServeMux()
        path, handler := notesv1connect.NewNoteServiceHandler(
                &notesServer{store: newFakeStore()},
                connect.WithInterceptors(newAuthInterceptor("secret-key")), // урок 13
        )
        mux.Handle(path, handler)
        server := httptest.NewServer(mux)
        defer server.Close()

        client := notesv1connect.NewNoteServiceClient(server.Client(), server.URL)

        t.Run("без токена — unauthenticated", func(t *testing.T) {
                _, err := client.GetNote(context.Background(),
                        connect.NewRequest(&notesv1.GetNoteRequest{Id: "n_1"}))
                if connect.CodeOf(err) != connect.CodeUnauthenticated {
                        t.Fatalf("код = %v, хотим CodeUnauthenticated", connect.CodeOf(err))
                }
        })

        t.Run("с токеном — OK", func(t *testing.T) {
                req := connect.NewRequest(&notesv1.GetNoteRequest{Id: "n_1"})
                req.Header().Set("Authorization", "Bearer "+makeTestToken("secret-key"))
                _, err := client.GetNote(context.Background(), req)
                if connect.CodeOf(err) != connect.CodeOK {
                        t.Fatalf("код = %v, хотим CodeOK", connect.CodeOf(err))
                }
        })
}
```

Полный стек проверяет перехватчик так, как он смонтирован в проде: заголовок ставится на запрос клиента, код возвращается через весь транспорт. Три-четыре кейса закрывают контур авторизации целиком.

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

- ✅ Полным стеком на httptest: хендлер с WithInterceptors, вызов клиента без Authorization, ожидание CodeUnauthenticated
- Прямым вызовом хендлера: без сети перехватчики не нужны
- Никак: перехватчики проверяются только в проде
- Запустить сервер на порту 8080 и постучаться curl-ом руками

Перехватчик исполняется на транспортном пути, поэтому честно проверяется полным стеком: WithInterceptors на хендлере, клиент без заголовка, connect.CodeOf ждёт CodeUnauthenticated. Ручной curl хорош для отладки, но не для регрессии.

### Стримы: Send и Receive по очереди

Стримы из восьмого урока тестируются на том же httptest — генерированные стрим-клиенты работают против тестового сервера без изменений, и здесь появляются две новые вещи: порядок сообщений и завершение потоков. Для server-streaming тест сводится к циклу Receive: клиент открывает поток, читает ответы до io.EOF и сравнивает полученную ленту с ожидаемой — проверяется и содержимое, и порядок, и что сервер закрыл поток корректно. Для client-streaming — обратная картина: тест шлёт сообщения через Send и закрывает отправку, сервер отвечает одним итогом; проверяется, что ничего не потерялось между сообщениями. Для bidi, где обе стороны говорят одновременно, тестам полезен helper CloseAndReceive у клиентского потока: он закрывает отправку и дочитывает все ответы — в тесте это снимает гонку «кто первый закрылся». И главная ловушка стрим-тестов — горутины: серверный хендлер стрима исполняется в отдельной горутине, поэтому флаг -race при тестах обязателен, а общий стейт между хендлером и тестом защищайте каналами или мьютексом, как в любом конкурентном Go. Второй класс проверок на стримах — отказы посреди потока: сервер возвращает ошибку после трёх сообщений, и тест должен увидеть три успешных Receive, затем ошибку с ожидаемым кодом — именно так ведут себя лимиты и таймауты на длинных потоках. Третий класс — большие сообщения: стрим с сообщением больше WithReadMaxBytes из четырнадцатого урока должен отвалиться ресурсным кодом, и полный стек это подтвердит без единого реального сокета. Стрим-тесты медленнее unary, но на httptest всё ещё миллисекунды — их можно гонять в каждом PR, а не только ночью.

### Server-streaming: лента до EOF

```go
func TestListNotes(t *testing.T) {
        mux := http.NewServeMux()
        path, handler := notesv1connect.NewNoteServiceHandler(
                &notesServer{store: newFakeStore()},
        )
        mux.Handle(path, handler)
        server := httptest.NewServer(mux)
        defer server.Close()

        client := notesv1connect.NewNoteServiceClient(server.Client(), server.URL)
        stream := client.ListNotes(context.Background(),
                connect.NewRequest(&notesv1.ListNotesRequest{Tag: "go"}))

        var got []string
        for stream.Receive() {
                got = append(got, stream.Msg().Title)
        }
        if connect.CodeOf(stream.Err()) != connect.CodeOK {
                t.Fatalf("код потока = %v", stream.Err())
        }
        if len(got) != 3 {
                t.Fatalf("получили %d заметок, хотим 3", len(got))
        }
}
```

Receive возвращает false по окончании ленты; потоковая ошибка — в stream.Err(), и её код проверяется тем же connect.CodeOf. Для bidi на клиентском потоке есть CloseAndReceive — закрывает отправку и дочитывает ответы.

### Карта тестирования Connect-сервиса

| Слой | Инструмент | Что проверяет | Где живёт |
| --- | --- | --- | --- |
| хендлер напрямую | go test + вызов метода | бизнес-логику, ветки, границы | unit-тесты, большинство |
| полный стек | httptest.NewServer + сгенерированный клиент | сериализацию, коды, HTTP-маппинг | таблицы кейсов на каждый метод |
| перехватчики | WithInterceptors + CodeOf | auth, метаданные, контекст | кейсы «нет/плохой/хороший токен» |
| стримы | Receive/CloseAndReceive, -race | порядок, завершение, отказы посреди потока | по каждому стрим-методу |
| защита | сырые запросы поверх httptest | лимиты, gzip-бомбы, большие тела | кейсы из четырнадцатого урока |
| интеграция | build-теги + реальные зависимости | схождение с БД, миграции, окружение | CI с сервисами, не каждый PR |

### Интеграционные тесты и CI: от httptest к прод-подобному контуру

Полный стек на httptest не проверяет ровно одно — окружение: настоящую базу данных, миграции, соседние сервисы, конфигурацию. За это отвечает интеграционный уровень, и первый его инструмент — разделение через build-теги: медленные тесты с реальными зависимостями помечаются //go:build integration и не бегут в быстрой петле go test ./...; в CI их включает отдельный шаг. Внутри интеграционного теста картина та же, что и на httptest, — сервер, клиент, CodeOf, — только зависимости настоящие: тестовая база поднимается контейнером (инструменты вроде testcontainers это автоматизируют), миграции прогоняются до тестов, данные между кейсами изолируются. Второй момент — протокольное покрытие: сгенерированный клиент по умолчанию говорит протоколом Connect; убедиться, что хендлеры совместимы и с gRPC-клиентами, позволяют отдельные интеграционные проверки gRPC-инструментами — например, grpcurl-ом против поднятого сервиса, что заодно проверяет рефлексию из пятнадцатого урока. Третий момент — гонки и утечки: go test -race обязателен для всего курса тестов, а для стрим-хендлеров добавьте проверку, что горутина завершается после закрытия потока (pprof-счётчик горутин в тесте — грубый, но действенный приём). Четвёртый — конвейер: CI из одиннадцатого урока уже гоняет buf lint, buf breaking и buf generate с git diff --exit-code; тестирование connect вписывается туда же строками go test -race ./... для быстрого контура и go test -tags integration ./... для интеграционного. И последнее — экономика: прямой вызов стоит микросекунды, полный стек — миллисекунды, интеграция — секунды; распределяйте кейсы по слоям так, чтобы каждый тест падал там, где возникла причина, а не уровнем выше.

### Чеклист тестирования Connect-сервиса

- Бизнес-логика — прямыми вызовами хендлера с фейковыми зависимостями
- Коды ошибок каждого метода — таблицей кейсов на полном стеке с connect.CodeOf
- Auth-контур — три кейса: нет токена, плохой токен, хороший токен
- Каждый стрим-метод — тест ленты: порядок, EOF, отказ посреди потока
- Лимиты и сжатие — кейсы из четырнадцатого урока поверх httptest
- go test -race в CI, интеграционные тесты за build-тегом integration

### Соберите каркас полного стека

Восстановите тест полного стека: сборка хендлера, тестовый сервер, клиент и проверка кода ошибки.

```go
func TestGetNote_FullStack(t *testing.T) {
        mux := http.NewServeMux()
        path, handler := notesv1connect.___(
                &notesServer{store: newFakeStore()},
        )
        mux.Handle(path, handler)
        server := ___.NewServer(mux)
        defer server.___()

        client := notesv1connect.NewNoteServiceClient(server.Client(), server.URL)
        _, err := client.GetNote(context.Background(),
                connect.NewRequest(&notesv1.GetNoteRequest{Id: "missing"}))
        if connect.___(err) != connect.CodeNotFound {
                t.Fatalf("код = %v, хотим CodeNotFound", err)
        }
}
```

Подсказка: Хендлер собирает NewNoteServiceHandler; сервер — httptest.NewServer; высвобождение — server.Close(); код ошибки достаёт connect.CodeOf.

Правильные ответы в пропусках: `NewNoteServiceHandler`, `httptest`, `Close`, `CodeOf`

### Жизненный цикл полного стек-теста

Расставьте шаги одного теста полного стека от начала до конца.

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

1. `Тест собирает mux и хендлер с опциями как в проде`
2. `httptest.NewServer поднимает хендлер в памяти процесса`
3. `Клиент получает server.Client() и server.URL`
4. `Тест строит запрос и выставляет нужные заголовки`
5. `Клиент вызывает метод через настоящий протокол`
6. `connect.CodeOf(err) сравнивается с ожидаемым кодом`
7. `server.Close() высвобождает соединения тестового сервера`

> **Только прямые вызовы — не тестирование API.** Сьют из одних прямых вызовов проверяет логику, но не проверяет то, что придёт в прод: сериализацию, коды в HTTP, перехватчики, лимиты. Обратная ошибка — всё через полный стек: тесты медленнее и падают дальше от причины. Держите два уровня в каждом методе: логика — прямо, контракт — стеком; интеграция — для окружения.

> **Таблицы кейсов и t.Run.** Один тест-функция с срезом кейсов {name, вход, ожидаемый код} и t.Run внутри покрывает целую матрицу ошибок без копипасты. Table-driven тесты и t.Run разобраны в шестнадцатом уроке [курса go-advanced](/courses/go-advanced/lessons/16-testirovanie/) — здесь тот же стиль, только ожидание сравнивается с connect.CodeOf(err). Имена кейсов делайте утверждениями: «чужая заметка — NotFound» читается в выводе теста как документация.

> **Как проходить этот курс.** Код урока — настоящий Go, который копируется в ваш тестовый файл к проекту из третьего урока. Браузерная песочница сайта сетевой код не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP, а тесты запускаются локально командой go test -race ./...

### Карточки: тестирование Connect

- **Два уровня тестов хендлеров** — Прямой вызов метода — логика без протокола, микросекунды. Полный стек на httptest — сериализация, коды, перехватчики, лимиты. Уровни дополняют друг друга, не заменяют.
- **httptest для Connect** — httptest.NewServer(mux) поднимает настоящий хендлер в памяти; клиенту дают server.Client() и server.URL. Ни портов, ни сокетов, ни ожидания старта — и это рекомендованный путь.
- **connect.CodeOf** — Helper, возвращающий код connect-ошибки; nil даёт CodeOK. Тест утверждает семантику (CodeNotFound), а не текст ошибки — поэтому не хрупок к формулировкам.
- **Голая ошибка в хендлере** — fmt.Errorf без connect.NewError доезжает до клиента кодом CodeUnknown. Ловится только полным стеком — прямой вызов разницы не покажет.
- **Тесты перехватчиков** — Полный стек с WithInterceptors: без токена — CodeUnauthenticated, с валидным — CodeOK. Перехватчик проверяется так, как смонтирован в проде.
- **Стрим-тесты** — Receive до io.EOF, ошибка потока в stream.Err(), для bidi — CloseAndReceive. Обязателен -race: хендлер стрима живёт в своей горутине.

### Что дальше по курсу

Тестовый контур собран: логика — прямыми вызовами, контракт — полным стеком на httptest, окружение — интеграцией за build-тегом. Дальше по курсу: семнадцатый урок — метрики и наблюдаемость, где тестированный сервис получит счётчики и латентности; двадцать второй — деплой, где интеграционный контур встретится с настоящим ingress. Для повторения: коды ошибок — в [шестом уроке](/courses/connect/lessons/06-oshibki/), перехватчики — в [седьмом](/courses/connect/lessons/07-interceptors/), защита и лимиты — в [тринадцатом](/courses/connect/lessons/13-bezopasnost/) и [четырнадцатом](/courses/connect/lessons/14-compression/), рефлексия и доки — в [пятнадцатом](/courses/connect/lessons/15-dokumentaciya/). Программа всех двадцати пяти уроков — на [странице курса](/courses/connect/).

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

- Тестирование connect-сервисов — два уровня без сети: прямой вызов хендлера для логики и полный стек на httptest для протокола.
- Прямой вызов — обычная Go-функция: коды ошибок здесь не существуют, тестируются ветки, границы и инварианты на фейковых зависимостях.
- Полный стек: httptest.NewServer поднимает хендлер как в проде, сгенерированный клиент идёт через server.Client() и server.URL — сериализация, HTTP-маппинг кодов, перехватчики, лимиты проверяются честно.
- connect.CodeOf(err) превращает тесты кодов в таблицы кейсов; голая ошибка без connect.NewError доезжает кодом CodeUnknown — ловится только полным стеком.
- Auth-контур из тринадцатого урока закрывается тремя кейсами: нет токена, плохой токен, валидный токен — CodeUnauthenticated против CodeOK.
- Стримы тестируются на том же httptest: Receive до EOF, ошибка в stream.Err(), для bidi — CloseAndReceive; обязателен go test -race.
- Интеграционный уровень — за build-тегом integration: реальные зависимости, grpcurl-проверка совместимости и рефлексии, отдельный шаг в CI рядом с buf-конвейером.

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

Постройте тестовую пирамиду для своего notes-сервиса. Шаг первый: для GetNote и CreateNote напишите прямые тесты бизнес-логики на фейковом хранилище — ветки отсутствия объекта, чужой заметки, валидации пустого текста. Шаг второй: соберите полный стек на httptest с таблицей кейсов и connect.CodeOf — по одному кейсу на каждый код, который возвращает метод, включая ловушку с fmt.Errorf вместо NewError. Шаг третий: добавьте три auth-кейса с перехватчиком из тринадцатого урока и стрим-тест для ListNotes: лента из трёх сообщений, порядок, EOF, затем кейс с отказом посреди потока и ожидаемым кодом. Шаг четвертый: гоняйте всё через go test -race ./..., затем заведите файл с //go:build integration, поднимите в нём настоящую базу и перенесите туда два кейса, которые требуют реального SQL. Запишите в заметки таблицу «слой — что ловит — сколько тестов» и проверьте баланс: прямых больше, стековых по контракту, интеграционных — минимум.

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

### Почему не поднимать настоящий сервер на порту для тестов?

Потому что httptest даёт тот же протокольный путь без сокетов: быстрее, параллельнее, без конфликтов портов в CI. Настоящий порт, TLS и прокси — это интеграционный уровень за build-тегом; на быстрой петле они не нужны даже gRPC-проектам, а в Connect тем более.

### Как тестировать хендлеры gRPC-клиентами, если клиент в тестах говорит протоколом Connect?

Хендлер один и обслуживает все три протокола из четвёртого урока, так что совместимость с gRPC-клиентами проверяется отдельной интеграционной проверкой — например, grpcurl-ом против поднятого сервиса за build-тегом. Заодно это проверит рефлексию из пятнадцатого урока.

### Что мокать: хранилище, клиент или перехватчики?

Мокайте зависимости хендлера — хранилище, часы, внешние сервисы, через простые фейки на структурах. Клиент не мокается никогда: в полном стеке он настоящий. Перехватчики тоже не мокаются, а проверяются: изолированно с фейковым next и в составе стека с кодами ответа.
