Содержание урока · 11 разделов
- Хендлер без сети: прямой вызов метода
- Полный стек на httptest: клиент против своего хендлера
- Перехватчики и метаданные: тестируем auth из тринадцатого
- Стримы: Send и Receive по очереди
- Карта тестирования Connect-сервиса
- Интеграционные тесты и CI: от httptest к прод-подобному контуру
- Чеклист тестирования Connect-сервиса
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
В классическом стеке тестирование grpc go-сервисов — это отдельная дисциплина: либо поднимается буферный listener и настоящий сервер, либо тестируются только внутренние функции, а протокол остаётся непроверенным. Connect превращает тесты в обычную Go-историю по одной причине: и хендлер, и клиент — тонкие обёртки над знакомыми абстракциями. Хендлер — http.Handler, смонтированный на mux; клиент — структура, которой вы передаёте http.Client и базовый URL. Между ними нет ничего, что требовало бы сети: httptest подменяет и то, и другое in-memory. Отсюда пирамида урока. Нижний слой — прямые вызовы: тест создаёт ваш notesServer{} и зовёт GetNote как обычный метод — быстро, детерминированно, без сериализации. Средний слой — полный стек: httptest.NewServer поднимает настоящий хендлер из третьего урока, сгенерированный клиент из пятого ходит по нему как по настоящему API, и тестирование connect покрывает всё — кодеки, HTTP-маппинг ошибок из шестого урока, перехватчики, лимиты и сжатие из четырнадцатого. Верхний слой — интеграционные тесты в CI, где к этому добавляются настоящие зависимости. Слои не исключают, а дополняют друг друга: прямой вызов отлаживает логику, полный стек ловит протокольные ошибки, интеграция ловит ошибки окружения. По дороге закрепим канон: тесты пишутся table-driven, как в шестнадцатом уроке курса go-advanced, а флаг -race обязателен — стримы и горутины здесь всюду.
Хендлер без сети: прямой вызов метода#
Начнём с самого дешёвого уровня. Ваш notesServer реализует сгенерированный интерфейс — значит, тест может создать его напрямую и вызвать метод как обычную функцию Go: собрать request-структуру, получить ответ и ошибку. Такой тест собирается за микросекунды, не требует http.Client, не зависит от protobuf-сериализации и идеально подходит для бизнес-логики: правил доступа, валидации, вычислений. Но у него есть жёсткая граница — внутри прямого вызова нет Connect. Ошибка, которую хендлер вернул через connect.NewError, в тесте — та же обычная Go-ошибка, что и любая другая: коды, статусы HTTP и сериализация на этом уровне не существуют. Это не баг, а свойство уровня: вы тестируете функцию, а не RPC. Отсюда правила гигиены. Первое: бизнес-ошибки возвращайте connect.NewError с осмысленным кодом уже в хендлере — тогда и прямой тест, и полный стек увидят одну и ту же семантику (как раскладывать ошибки по кодам — шестой урок). Второе: в прямых тестах проверяйте именно логику — ветки, границы, инварианты хранилища, — и не пытайтесь через них проверять заголовки, коды в HTTP и перехватчики: для этого есть следующий уровень. Третье: если хендлер тянет зависимости — хранилище, часы, генератор идентификаторов, — заведите их в тестовой версии фейками на простых структурах; интерфейсы из третьего урока этому помогают. Прямые тесты — это unit-уровень вашего RPC-приложения, и их должно быть большинство: они дешевле всего и падают точнее всех.
Прямой вызов: логика без протокола
GOfunc TestGetNote_NotFound(t *testing.T) {
server := ¬esServer{store: newFakeStore()} // фейк без БД
got, err := server.GetNote(context.Background(),
¬esv1.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, ...). Что увидит тест, сравнивая ошибку?
Полный стек на 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-контракта: тот же код, что исполняется в проде, вызывается тем же клиентом, что едет в проде, — отличается только транспорт под ним.
Полный стек: таблица кейсов на коды ошибок
GOfunc TestGetNoteCodes(t *testing.T) {
mux := http.NewServeMux()
path, handler := notesv1connect.NewNoteServiceHandler(
¬esServer{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(¬esv1.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) — таблица кейсов превращается в матрицу проверки кодов без единой сети.
Какой код увидит клиент?
Прочитайте код и выберите, что он выведет:
// Хендлер:
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) на стороне теста?Чем полный стек на httptest лучше прямого вызова хендлера, и что он всё равно не проверяет?
Перехватчики и метаданные: тестируем auth из тринадцатого#
Перехватчики — самый недооценённый предмет тестирования, и зря: именно в них живут сквозные ошибки — авторизация, логирование, лимиты. Перехватчик — обычное значение типа connect.Interceptor, значит его можно тестировать двумя способами. Первый — изолированно: WrapUnary получает вызов next и запрос; тест подставляет фейковый next, который записывает, что до него доехало, — и проверяет, что перехватчик добавил нужные заголовки, проставил значения в контекст или отклонил запрос. Второй — в составе полного стека: хендлер собирается с WithInterceptors(authInterceptor из тринадцатого урока), и тестовые вызовы либо несут токен в req.Header(), либо нет. Второй способ честнее, потому что проверяет то, как перехватчик реально смонтирован, — и это тест на несколько строк: вызов без заголовка Authorization должен вернуть CodeUnauthenticated, вызов с протухшим токеном — тоже, вызов с валидным — пройти до хендлера. Отдельный инструмент для мелких проверок — тестовый перехватчик, который вешается на клиент в полном стеке и подглядывает за запросами и ответами: им удобно фиксировать, что сервер проставил заголовки ответа или что процедура вызова выглядит как /notes.v1.NoteService/GetNote. Контекстные значения из тринадцатого урока — userFromContext — проверяются там же: перехватчик кладёт пользователя в контекст, хендлер его читает, тест проходит только если связка работает в настоящем потоке исполнения. Практический минимум для auth-контура: три кейса полного стека (нет токена, плохой токен, хороший токен) — и у вас есть исполняемая документация безопасности, которая падает раньше, чем упадёт прод.
Три кейса аутентификации
GOfunc TestAuth(t *testing.T) {
mux := http.NewServeMux()
path, handler := notesv1connect.NewNoteServiceHandler(
¬esServer{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(¬esv1.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(¬esv1.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))
}
})
}Полный стек проверяет перехватчик так, как он смонтирован в проде: заголовок ставится на запрос клиента, код возвращается через весь транспорт. Три-четыре кейса закрывают контур авторизации целиком.
Как проверить, что перехватчик из тринадцатого урока отклоняет запросы без токена?
Стримы: Send и Receive по очереди#
Стримы из восьмого урока тестируются на том же httptest — генерированные стрим-клиенты работают против тестового сервера без изменений, и здесь появляются две новые вещи: порядок сообщений и завершение потоков. Для server-streaming тест сводится к циклу Receive: клиент открывает поток, читает ответы до io.EOF и сравнивает полученную ленту с ожидаемой — проверяется и содержимое, и порядок, и что сервер закрыл поток корректно. Для client-streaming — обратная картина: тест шлёт сообщения через Send и закрывает отправку, сервер отвечает одним итогом; проверяется, что ничего не потерялось между сообщениями. Для bidi, где обе стороны говорят одновременно, тестам полезен helper CloseAndReceive у клиентского потока: он закрывает отправку и дочитывает все ответы — в тесте это снимает гонку «кто первый закрылся». И главная ловушка стрим-тестов — горутины: серверный хендлер стрима исполняется в отдельной горутине, поэтому флаг -race при тестах обязателен, а общий стейт между хендлером и тестом защищайте каналами или мьютексом, как в любом конкурентном Go. Второй класс проверок на стримах — отказы посреди потока: сервер возвращает ошибку после трёх сообщений, и тест должен увидеть три успешных Receive, затем ошибку с ожидаемым кодом — именно так ведут себя лимиты и таймауты на длинных потоках. Третий класс — большие сообщения: стрим с сообщением больше WithReadMaxBytes из четырнадцатого урока должен отвалиться ресурсным кодом, и полный стек это подтвердит без единого реального сокета. Стрим-тесты медленнее unary, но на httptest всё ещё миллисекунды — их можно гонять в каждом PR, а не только ночью.
Server-streaming: лента до EOF
GOfunc TestListNotes(t *testing.T) {
mux := http.NewServeMux()
path, handler := notesv1connect.NewNoteServiceHandler(
¬esServer{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(¬esv1.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
Соберите каркас полного стека
Восстановите тест полного стека: сборка хендлера, тестовый сервер, клиент и проверка кода ошибки.
Жизненный цикл полного стек-теста
Расставьте шаги одного теста полного стека от начала до конца.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Сьют из одних прямых вызовов проверяет логику, но не проверяет то, что придёт в прод: сериализацию, коды в HTTP, перехватчики, лимиты. Обратная ошибка — всё через полный стек: тесты медленнее и падают дальше от причины. Держите два уровня в каждом методе: логика — прямо, контракт — стеком; интеграция — для окружения.
Один тест-функция с срезом кейсов {name, вход, ожидаемый код} и t.Run внутри покрывает целую матрицу ошибок без копипасты. Table-driven тесты и t.Run разобраны в шестнадцатом уроке курса go-advanced — здесь тот же стиль, только ожидание сравнивается с connect.CodeOf(err). Имена кейсов делайте утверждениями: «чужая заметка — NotFound» читается в выводе теста как документация.
Код урока — настоящий Go, который копируется в ваш тестовый файл к проекту из третьего урока. Браузерная песочница сайта сетевой код не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP, а тесты запускаются локально командой go test -race ./...
Карточки: тестирование Connect
Что дальше по курсу#
Тестовый контур собран: логика — прямыми вызовами, контракт — полным стеком на httptest, окружение — интеграцией за build-тегом. Дальше по курсу: семнадцатый урок — метрики и наблюдаемость, где тестированный сервис получит счётчики и латентности; двадцать второй — деплой, где интеграционный контур встретится с настоящим ingress. Для повторения: коды ошибок — в шестом уроке, перехватчики — в седьмом, защита и лимиты — в тринадцатом и четырнадцатом, рефлексия и доки — в пятнадцатом. Программа всех двадцати пяти уроков — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Тестирование 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 и в составе стека с кодами ответа.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding