Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 16 из 25 ~18 мин +50 XP

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

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

Содержание урока · 11 разделов

В классическом стеке тестирование 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-приложения, и их должно быть большинство: они дешевле всего и падают точнее всех.

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

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, ...). Что увидит тест, сравнивая ошибку?

Полный стек на 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) — таблица кейсов превращается в матрицу проверки кодов без единой сети.

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

Прочитайте код и выберите, что он выведет:

// Хендлер:
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-контура: три кейса полного стека (нет токена, плохой токен, хороший токен) — и у вас есть исполняемая документация безопасности, которая падает раньше, чем упадёт прод.

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

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))
                }
        })
}

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

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

Стримы: 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 + CodeOfauth, метаданные, контексткейсы «нет/плохой/хороший токен»
стримы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

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

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

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) } }

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

Два уровня тестов хендлеров

нажмите, чтобы перевернуть
Определение

Прямой вызов метода — логика без протокола, микросекунды. Полный стек на httptest — сериализация, коды, перехватчики, лимиты. Уровни дополняют друг друга, не заменяют.

1 / 6

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

Тестовый контур собран: логика — прямыми вызовами, контракт — полным стеком на 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. Запишите в заметки таблицу «слой — что ловит — сколько тестов» и проверьте баланс: прямых больше, стековых по контракту, интеграционных — минимум.

Личные заметкиПусто
Хранятся только в вашем браузере (Connect · Тестирование Connect-сервисов)

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

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

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

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

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

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

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

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

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Fyne25 уроков~13 чпрактика25 уроков уже доступны

Fyne: десктопные и мобильные GUI на Go

Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.

  • Создавать окна и интерфейсы из виджетов
  • Управлять компоновкой: Box, Grid, Form
  • Рисовать примитивами canvas и строить свои виджеты
  • Связывать данные через binding