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

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

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

Урок 21 из 25 ~20 мин +50 XP

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

Двадцатый урок закончил обещанием: матрица совместимости v1/v2 перестанет быть мысленным экспериментом и станет набором тестов, который падает в CI при первом же нарушении. Сегодня — тестирование gRPC-сервисов: как поднять сервер на in-memory-соединении без единого порта с пакетом test/bufconn, как прогнать тест хендлера как обычной функции без сети, как проверить серверный стрим циклом Recv до io.EOF и как превратить вчерашнюю таблицу «клиент × сервер» в честные тест-кейсы — вместе с fixture-проверкой того самого «unknown service shop.v1.OrderService», который мы ловили в прошлом уроке. Фундамент уже есть: table driven tests, t.Run, t.Error против t.Fatal, t.Cleanup и t.Parallel вы разобрали в [уроке про тестирование Go](/courses/go-advanced/lessons/16-testirovanie/) — здесь эти инструменты поедут по gRPC-рельсам. bufconn при этом не игрушка: между клиентом и сервером гоняется полный стек gRPC — HTTP/2-фрейминг, сериализация proto, статусы, метаданные, дедлайны и flow-control окна. Отличие от боевого сервера ровно одно: вместо TCP-сокета — пара труб в памяти процесса. Поэтому тест, зелёный на bufconn, ловит всё, кроме того, что живёт ниже gRPC: TCP-таймауты ядра, TLS-рукопожатие и настройку сертификатов — им достанется отдельная строка в таблице уровней проверки. Запрос «тестирование grpc» обычно гуглят в момент, когда интеграционный тест на настоящем порту падает из-за занятого :50051 или из-за соседа по CI, — сегодня такие тесты научатся запускаться в памяти, параллельно и молча.

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

тестирование grpc: три уровня проверки#

У gRPC-сервиса три слоя, и тестируются они по-разному. Первый — тест хендлера: метод как обычная функция Go, зависимость подставлена заглушкой, сети нет вовсе; здесь живут доменные правила и коды ошибок. Второй — интеграционный тест на bufconn: настоящий gRPC-стек поднимается целиком, но «сеть» — это пара труб в памяти; здесь проверяются сериализация proto, маршрутизация по полному имени метода вроде /shop.v1.OrderService/GetOrder, статусы, метаданные, дедлайны и стримы. Третий — e2e на настоящем TCP с TLS и балансировщиком; это уже территория стейджинга, а не go test. Разумная пропорция — пирамида: как можно больше быстрых unit-тестов хендлеров, интеграционных на bufconn ровно столько, сколько поведения проходит через провод, и считанные e2e. Дисциплина testing из шестнадцатого урока курса go-advanced переносится один в один: table driven tests, t.Run с именованными кейсами, t.Error против t.Fatal, t.Helper в хелперах. Ничего из этого не придётся переучивать — gRPC лишь добавляет один строительный блок: слушатель без порта. Запрос «тестирование grpc» в поиске почти всегда означает «как не поднимать сеть в тестах» — и ответ ровно этот.

bufconn: сетевой тест без сети#

bufconn — пакет test/bufconn из репозитория grpc-go (путь в исходниках — github.com/grpc/grpc-go/test/bufconn, в коде импортируется как google.golang.org/grpc/test/bufconn). Документация пакета честная и короткая: «Package bufconn provides a net.Conn implemented by a buffer and related dialing and listening functionality» — реализация net.Conn на буфере плюс дайлинг и «слушание». Главное слово здесь — слушание в кавычках: «Listen returns a Listener that can only be contacted by its own Dialers and creates buffered connections between the two» — слушатель, к которому может подключиться только собственный дайлер этого же слушателя. Никакого порта, никакого TCP-стека, никакого ядра: Dial создаёт «an in-memory full-duplex network connection» — полнодуплексную пару труб в памяти, серверная половина уходит в Accept, клиентская возвращается дайлеру. Загляните в исходники: под капотом — два pipe с кольцевыми буферами фиксированной ёмкости; Read блокируется, пока труба пуста, Write — пока полна. При этом это настоящий net.Conn: адрес у него фиктивный, но методы SetReadDeadline и SetWriteDeadline реализованы через time.AfterFunc — дедлайны из четырнадцатого урока работают без изменений. Адрес слушателя задаётся типом bufconn: метод Network() возвращает «bufconn», String() тоже «bufconn» — увидите это слово в логах вместо 127.0.0.1:50051. Идея in-memory-теста от этого только выигрывает: нет порта — нет конфликтов «занято соседним тестом», нет TCP — нет медленных хэндшейков, нет сокета — нет доступа извне.

Каркас: сервер на bufconn в тесте

GO
// server_test.go — каркас bufconn-теста, который используют все тесты пакета
package shop

import (
        "context"
        "net"
        "testing"

        "google.golang.org/grpc"
        "google.golang.org/grpc/credentials/insecure"
        "google.golang.org/grpc/test/bufconn" // пакет test/bufconn из репозитория grpc-go

        shopv1 "github.com/example/shop/gen/shopv1"
        shopv2 "github.com/example/shop/gen/shopv2"
)

const bufSize = 1024 * 1024 // один мебибайт — каноничный размер из примеров

// newBufconnConn поднимает сервер в памяти и возвращает клиентское соединение.
func newBufconnConn(t *testing.T, withV1, withV2 bool) *grpc.ClientConn {
        t.Helper() // провалы и зависания внутри хелпера честно относят к нему

        lis := bufconn.Listen(bufSize) // слушатель без порта: Accept ждёт Dial
        srv := grpc.NewServer()
        if withV1 {
                // боевая зависимость из двадцатого урока: репозиторий как поле
                shopv1.RegisterOrderServiceServer(srv, &v1Handler{orders: loadOrder})
        }
        if withV2 {
                shopv2.RegisterOrderServiceServer(srv, &v2Handler{}) // путь /shop.v2.OrderService/...
        }
        go func() {
                _ = srv.Serve(lis) // ошибка после Stop — штатный финал, не тревога
        }()
        t.Cleanup(srv.Stop) // уборка: тест закончится — сервер умрёт

        conn, err := grpc.NewClient(
                "passthrough:///bufnet", // схема passthrough: адрес уходит в дайлер как есть
                grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
                        return lis.DialContext(ctx) // вместо TCP — труба в памяти
                }),
                grpc.WithTransportCredentials(insecure.NewCredentials()), // TLS проверяют отдельно
        )
        if err != nil {
                t.Fatalf("NewClient: %v", err)
        }
        t.Cleanup(func() { conn.Close() }) // порядок уборки: сначала клиент, потом сервер
        return conn
}

// TestGetOrderOverBufconn — интеграционный тест через полный стек gRPC.
func TestGetOrderOverBufconn(t *testing.T) {
        conn := newBufconnConn(t, true, false) // сервер знает только v1
        client := shopv1.NewOrderServiceClient(conn)

        resp, err := client.GetOrder(context.Background(), &shopv1.GetOrderRequest{Id: "42"})
        if err != nil {
                t.Fatalf("GetOrder: %v", err)
        }
        if resp.GetOrder().GetId() != "42" {
                t.Errorf("id = %q; want %q", resp.GetOrder().GetId(), "42")
        }
}

Четыре решения в каркасе заслуживают отдельного взгляда. Первое: srv.Serve(lis) — только через go: Serve — бесконечный цикл Accept, синхронный вызов повесил бы тест навсегда (сейчас докажем предиктом). Второе: цель «passthrough:///bufnet» обязана носить схему passthrough явно — grpc.NewClient по умолчанию резолвит цели через dns («The default resolver is "dns"»), а passthrough отдаёт адрес дайлеру без изменений, и тот попадает в lis.DialContext. Сам NewClient ничего не открывает: «No I/O is performed. Use of the ClientConn for RPCs will automatically cause it to connect» — подключение случится на первом RPC. Третье: опция WithContextDialer — «WithContextDialer returns a DialOption that sets a dialer to create connections»; сигнатура дайлера — func(context.Context, string) (net.Conn, error), поэтому lis.DialContext оборачивается замыканием, отбрасывающим адрес. Четвёртое: insecure.NewCredentials — в bufconn-тесте TLS выключен не потому, что не нужен, а потому, что проверяется он на другом уровне пирамиды.

Проверка: почему bufconn, а не настоящий порт

Чем bufconn-тест лучше варианта с net.Listen("tcp", "127.0.0.1:0") прямо в тесте?

Тест хендлера напрямую: сеть не нужна#

Нижний этаж пирамиды — тест хендлера. Метод GetOrder — это обычная функция с двумя параметрами: контекст и запрос; чтобы её вызвать, не нужны ни сервер, ни bufconn, ни даже импорт google.golang.org/grpc. Достаточно сконструировать хендлер и позвать метод. Единственное, что мешает, — зависимость. В двадцатом уроке хендлер звал глобальную функцию loadOrder(ctx, req.GetId()) — для теста это блокер: глобальную функцию не подменишь. Исправление — одна строка и целая дисциплина: зависимость становится полем структуры. Боевой main подставляет туда настоящий репозиторий, тест — заглушку в пять строк. Это ровно та же дисциплина «инъекция зависимости», которую вы применяли к httptest в шестнадцатом уроке, — здесь она выглядит даже проще, потому что gRPC-хендлер не читает http.Request, ему нужен только контекст и protobuf-запрос. Что проверять в тесте хендлера? Контракт ошибок: не «err != nil», а конкретный код — codes.NotFound для отсутствия, codes.InvalidArgument для кривого входа — и конкретные поля ответа. Статусные коды — это обещание клиентам, поэтому проверять их надо на нижнем этаже, дёшево и в табличном виде.

Табличный тест хендлера GetOrder

GO
// handler_test.go — тест хендлера напрямую: сеть не нужна вообще
// Зависимость — поле-функция: main подставит репозиторий, тест — заглушку.
type v1Handler struct {
        shopv1.UnimplementedOrderServiceServer
        orders func(ctx context.Context, id string) (order, error)
}

func (h *v1Handler) GetOrder(ctx context.Context, req *shopv1.GetOrderRequest) (*shopv1.GetOrderResponse, error) {
        o, err := h.orders(ctx, req.GetId())
        if err != nil {
                return nil, status.Errorf(codes.NotFound, "заказ %q: %v", req.GetId(), err)
        }
        return &shopv1.GetOrderResponse{Order: toV1(o)}, nil // toV1 из двадцатого урока
}

// TestGetOrderHandler — табличный тест: два кейса, одна проверка.
func TestGetOrderHandler(t *testing.T) {
        stub := func(ctx context.Context, id string) (order, error) {
                if id == "42" {
                        return order{ID: "42", TotalCents: 1990}, nil
                }
                return order{}, errors.New("нет такого заказа")
        }
        h := &v1Handler{orders: stub}

        cases := []struct {
                name     string
                id       string
                wantCode codes.Code
        }{
                {name: "заказ существует", id: "42", wantCode: codes.OK},
                {name: "заказа нет", id: "nope", wantCode: codes.NotFound},
        }
        for _, tc := range cases {
                t.Run(tc.name, func(t *testing.T) {
                        resp, err := h.GetOrder(context.Background(), &shopv1.GetOrderRequest{Id: tc.id})
                        if status.Code(err) != tc.wantCode {
                                t.Fatalf("статус = %v; want %v", status.Code(err), tc.wantCode)
                        }
                        if tc.wantCode == codes.OK && resp.GetOrder().GetId() != tc.id {
                                t.Errorf("id = %q; want %q", resp.GetOrder().GetId(), tc.id)
                        }
                })
        }
}

status.Code(err) — канонический способ достать код из ошибки: для nil он вернёт codes.OK, поэтому одна проверка обслуживает оба кейса. Заметьте, что заглушка возвращает именно error, а хендлер решает, каким кодом её перевести, — значит, тест фиксирует политику перевода, а не причуды заглушки. Заодно хендлер из прошлого урока честно дозрел до боевого вида: loadOrder переезжает в поле с сигнатурой (order, error) — ошибки репозитория и так нужно переводить в статусы, поле лишь делает перевод видимым и тестируемым. Кейсы с кривым входом (пустой id, отрицательная сумма) добавляются строкой данных — table driven tests из шестнадцатого урока в работе. На этот уровень теста bufconn не нужен и вреден: миллисекунды на кейс превращаются в секунды, а стек gRPC тут не проверяет ничего нового.

Забыли go перед Serve

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

func TestGetOrderBroken(t *testing.T) {
        lis := bufconn.Listen(1024 * 1024)
        srv := grpc.NewServer()
        shopv1.RegisterOrderServiceServer(srv, &v1Handler{})
        srv.Serve(lis) // упс: без go

        conn, _ := grpc.NewClient("passthrough:///bufnet",
                grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
                        return lis.DialContext(ctx)
                }),
                grpc.WithTransportCredentials(insecure.NewCredentials()),
        )
        client := shopv1.NewOrderServiceClient(conn)
        resp, err := client.GetOrder(context.Background(), &shopv1.GetOrderRequest{Id: "42"})
        if err != nil {
            t.Fatalf("GetOrder: %v", err)
        }
        _ = resp
}

Стрим-тест: Recv до io.EOF#

Server streaming из седьмого урока — WatchOrders(WatchRequest) returns (stream OrderEvent) — тестируется на bufconn так же, как и работает в бою: клиент читает Recv в цикле, пока не получит io.EOF. Дизайн стрим-теста сводится к трём вопросам. Сколько событий? Хендлер решает сам — кардинальность серверного стрима свободная, и тест обязан зафиксировать ожидание: три события для заказа, который прошёл путь «создан — оплачен — отгружен», ноль для тихого заказа. В каком порядке? Порядок — часть контракта: создан раньше оплачен; сравнивайте весь срез kind-ов, а не только длину — slices.Equal из стандартной библиотеки закрывает и длину, и порядок одним вызовом. Чем заканчивается? Штатный финал — nil из хендлера, и на клиенте он приезжает как io.EOF: седьмой урок вбивал это правило, стрим-тест закрепляет — io.EOF не ошибка, а сигнал выхода; errors.Is(err, io.EOF) — единственный корректный способ сравнения, потому что io.EOF может доехать обёрнутой. Отдельный плюс bufconn для стримов: дедлайны. У bufconn-коннекта реализованы SetReadDeadline и SetWriteDeadline — таймеры на time.AfterFunc, — поэтому context.WithTimeout вокруг WatchOrders из урока про дедлайны работает над трубами в памяти без изменений. Стрим-тест на bufconn — самая ценная инвестиция курса: переклинить порядок событий в бою вы заметите через жалобы, в тесте — за секунду.

Тест WatchOrders: события по порядку

GO
// stream_test.go — стрим-тест: порядок и полнота событий
func TestWatchOrdersStream(t *testing.T) {
        conn := newBufconnConn(t, true, false) // каркас из блока выше
        client := shopv1.NewOrderServiceClient(conn)

        // нулевой фильтр — подписка на все события: proto3-дефолт из второго урока
        stream, err := client.WatchOrders(context.Background(), &shopv1.WatchRequest{})
        if err != nil {
                t.Fatalf("WatchOrders: %v", err)
        }

        var got []string
        for {
                ev, err := stream.Recv()
                if errors.Is(err, io.EOF) {
                        break // io.EOF — штатный конец потока, не ошибка
                }
                if err != nil {
                        t.Fatalf("Recv: %v", err) // любой другой err — настоящий провал
                }
                got = append(got, ev.GetKind()) // kind: "created", "paid", "shipped"
        }

        want := []string{"created", "paid", "shipped"}
        if !slices.Equal(got, want) {
                t.Errorf("порядок событий = %v; want %v", got, want)
        }
}

К импортам каркаса добавьте errors, io и slices. Заметьте, как лаконично Recv-цикл из боевого клиента превращается в тест: та же структура for с двумя проверками ошибки — и это не совпадение, а следствие того, что bufconn гоняет настоящий клиентский стаб: чуть выше тот же цикл вы писали для реального соединения. Ловушка стрим-тестов одна: поймать io.EOF через err == io.EOF можно, а через err == errors.New("EOF") — никогда; конвертация кодов и EOF внутри стаба бывает обёрнутой, поэтому errors.Is обязателен. Если хендлер вместо nil вернёт статусную ошибку, Recv вернёт её в том же err — отдельный кейс в таблице легко проверит и это.

Четвёртый Recv после конца потока

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

// хендлер: три события и закрытие потока
func (h *v1Handler) WatchOrders(_ *shopv1.WatchRequest,
        stream grpc.ServerStreamingServer[shopv1.OrderEvent]) error {
        for _, kind := range []string{"created", "paid", "shipped"} {
                if err := stream.Send(&shopv1.OrderEvent{Kind: kind}); err != nil {
                        return err
                }
        }
        return nil // закрыли поток со статусом OK
}

// тест: после трёх успешных Recv
ev, err := stream.Recv() // четвёртый вызов

Матрица совместимости v1/v2 как тест#

Двадцатый урок оставил вам таблицу совместимости: v1-клиент на сервере v1 — работает, v2-клиент на сервере v2 — работает, v1-клиент на сервере без v1 — Unimplemented с дословным «unknown service shop.v1.OrderService», v2-клиент без v2 — аналогично. Мысленный эксперимент превращается в тест без единой новой идеи: сервер bufconn собирается с флагами withV1/withV2 (каркас выше уже умеет), клиент зовёт нужную версию, а табличный тест перебирает пары. Ценность такой проверки в том, что она держит не код, а обещание: известные пары обязаны отвечать OK, неизвестные — обязаны падать известным кодом Unimplemented, а не Internal и не Timeout. Иначе говоря, матрица — это fixture режима деградации: когда однажды кто-то зарегистрирует v2, забыв v1, или перепутает пакет в go_package, тест упадёт с дословным сообщением из прошлого урока. Сообщение стоит проверять буквально — status.Convert(err).Message() — потому что на него опираются клиенты, ловящие текст «unknown service». И вот приятный бонус связки: полный путь /shop.v1.OrderService/GetOrder внутри bufconn собирается тем же кодом маршрутизации, что и в бою, — два OrderService в разных пакетах мирно сосуществуют на одном «сервере» без единого порта.

Тест матрицы: четыре пары клиент × сервер

GO
// compat_test.go — матрица совместимости v1/v2 как тест (клиент × сервер)
func TestCompatibilityMatrix(t *testing.T) {
        cases := []struct {
                name     string // имя пары: «клиент на сервере»
                withV1   bool   // сервер регистрирует v1?
                withV2   bool   // сервер регистрирует v2?
                callV1   bool   // клиент зовёт v1?
                wantCode codes.Code
        }{
                {name: "v1-клиент на сервере v1", withV1: true, callV1: true, wantCode: codes.OK},
                {name: "v2-клиент на сервере v2", withV2: true, callV1: false, wantCode: codes.OK},
                {name: "v1-клиент на сервере только v2", withV2: true, callV1: true, wantCode: codes.Unimplemented},
                {name: "v2-клиент на сервере только v1", withV1: true, callV1: false, wantCode: codes.Unimplemented},
        }
        for _, tc := range cases {
                tc := tc // до Go 1.22 переменная цикла одна на все под-тесты
                t.Run(tc.name, func(t *testing.T) {
                        t.Parallel() // у каждой пары свой bufconn-сервер — гонок нет
                        conn := newBufconnConn(t, tc.withV1, tc.withV2)
                        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
                        defer cancel()

                        if tc.callV1 {
                                _, err := shopv1.NewOrderServiceClient(conn).
                                        GetOrder(ctx, &shopv1.GetOrderRequest{Id: "42"})
                                if status.Code(err) != tc.wantCode {
                                        t.Fatalf("статус v1 = %v; want %v", status.Code(err), tc.wantCode)
                                }
                        } else {
                                _, err := shopv2.NewOrderServiceClient(conn).
                                        GetOrder(ctx, &shopv2.GetOrderRequest{Id: "42"})
                                if status.Code(err) != tc.wantCode {
                                        t.Fatalf("статус v2 = %v; want %v", status.Code(err), tc.wantCode)
                                }
                        }
                        if tc.wantCode == codes.Unimplemented { // fixture из двадцатого урока
                                svc := "shop.v1.OrderService"
                                if !tc.callV1 {
                                        svc = "shop.v2.OrderService"
                                }
                                if got, want := status.Convert(err).Message(), "unknown service "+svc; got != want {
                                        t.Fatalf("сообщение = %q; want %q", got, want)
                                }
                        }
                })
        }
}

Табличный тест здесь — не прихоть, а требование: пары совместимости — это данные, и новая пара (например, v1beta-канал) добавляется строкой. Строка tc := tc — страховка для Go до 1.22, где переменная цикла одна на все итерации: параллельные под-тесты стартуют позже и могли бы увидеть последний кейс; с Go 1.22 строка избыточна, но безвредна. t.Parallel безопасен именно потому, что каждый под-тест собирает собственный bufconn-сервер — общего состояния ноль. Дедлайн в пять секунд — самостраховка: если трубный тест однажды зависнет, прогон узнает об этом через пять секунд, а не через десять минут.

Проверка: что bufconn не проверит

Какой уровень поведения bufconn-тест пропускает по определению?

t.Cleanup: уборка, которая не забудется#

Два ресурса в каждом bufconn-тесте — сервер и клиентское соединение — нужно остановить, и defer внутри под-теста для этого плохо подходит: параллельные под-тесты, t.Parallel и ранние t.Fatal путают порядок. Стандартное решение — t.Cleanup, знакомое вам по шестнадцатому уроку. Документация testing формулирует контракт точно: «Cleanup registers a function to be called when the test (or subtest) and all its subtests complete. Cleanup functions will be called in last added, first called order» — уборка сработает после завершения теста и всех его под-тестов, а функции вызываются в порядке «последним добавил — первым позвали», LIFO. Именно поэтому в каркасе порядок регистрации осмысленный: t.Cleanup(srv.Stop) добавлен раньше t.Cleanup(func() { conn.Close() }), значит, при уборке сначала закроется клиент, потом умрёт сервер. Что делает Stop? Документация grpc-go: «Stop stops the gRPC server. It immediately closes all open connections and listeners» — немедленно закрывает слушателя и связи и отменяет активные RPC. Родственник GracefulStop ведёт себя мягче: «It stops the server from accepting new connections and RPCs and blocks until all the pending RPCs are finished» — блокируется до конца начатых вызовов. В тестах обычно нужен именно Stop: быстрый, безусловный, не подвешивающий прогон; GracefulStop — история продакшена. И да — Cleanup сработает даже если тест упал через t.Fatal: Goexit не отменяет уборку, поэтому «зависший горутиной сервер» — не то, о чём вы будете думать после красного теста.

t.Parallel: параллельность и её цена#

bufconn-тесты параллелятся бесплатно: сервер не занимает порт, не пишет в общие файлы, не трогает сеть — конфликты нечему возникать. Механика знакомая: t.Parallel внутри t.Run помечает под-тест параллельным; документация testing: «Parallel signals that this test is to be run in parallel with (and only with) other parallel tests, and pauses until all non-parallel tests have finished» — параллельные тесты ждут завершения последовательных и гоняются только друг с другом; родительский тест в это время висит, пока не закроются все дети. Две осторожности. Первая — захват переменной цикла: до Go 1.22 tc := tc в начале тела цикла обязательна, иначе все под-тесты разом увидят последний кейс (с Go 1.22 каждая итерация получает собственную переменную, и строка становится памяткой истории). Вторая — общее состояние: t.Parallel честно распараллелит только то, что не делит; если два под-теста пишут в одну мапу хендлера, тесты будут флакать редко и безжалостно. Правило этого курса: один под-тест — один bufconn-сервер — ноль разделяемых ресурсов, и тогда t.Parallel на матрице совместимости ускоряет прогон вчетверо без единого риска. Скорость, кстати, не мелочь: трубный тест отрабатывает за миллисекунды, и параллельность превращает пакет из ста интеграционных тестов в секунду работы вместо минуты.

Проверка: порядок Cleanup

В хелпере зарегистрированы t.Cleanup(srv.Stop), затем t.Cleanup(func() { conn.Close() }). В каком порядке сработает уборка?

Соберите каркас bufconn-теста

Вставьте пропущенные вызовы и значения: слушатель, уборка сервера, цель с passthrough-схемой и штатный конец стрима.

func TestSkeleton(t *testing.T) { lis := (1024 * 1024) // слушатель без порта srv := grpc.NewServer() shopv1.RegisterOrderServiceServer(srv, &v1Handler{}) go func() { _ = srv.Serve(lis) }() (srv.Stop) // уборка после теста и всех под-тестов conn, err := grpc.NewClient("", grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }), grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { t.Fatal(err) } client := shopv1.NewOrderServiceClient(conn) stream, err := client.WatchOrders(context.Background(), &shopv1.WatchRequest{}) if err != nil { t.Fatal(err) } for { if _, err := stream.Recv(); errors.Is(err, ) { break // штатный конец потока } } }

Шаги bufconn-интеграционного теста

Соберите жизненный цикл bufconn-теста из девяти шагов — от слушателя до проверки результата.

Строки

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

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

Три уровня проверки gRPC-сервиса#

УровеньЧто гоняетЧто ловит
Тест хендлера напрямуюМетод как функция: заглушка зависимости, context.Background()Доменные правила, коды ошибок, граничные значения; миллисекунды на кейс
bufconn-интеграционныйПолный стек gRPC поверх труб в памяти: HTTP/2, proto, статусы, стримыМаршрутизацию по пакету, матрицу совместимости, дедлайны, метаданные
e2e на настоящем портуTCP + TLS + балансировщик, боевые сертификатыТо, что bufconn не видит: рукопожатие TLS, keepalive, порты и файрволы
Ручной grpcurl (восемнадцатый урок)Живые запросы к стейджингу без кодаОтражение, схемы и статусы глазами клиента без Go-кода

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

  • Зависимости хендлеров — поля структуры, а не глобальные функции: иначе тест хендлера напрямую не написать
  • Один хелпер newBufconnConn с t.Helper(): каркас без копипасты, провалы относят к хелперу
  • srv.Serve(lis) — только через go; ошибка Serve после Stop — штатный финал, её глотают
  • t.Cleanup(srv.Stop) и t.Cleanup(conn.Close) — LIFO: клиент умирает раньше сервера
  • В стрим-тесте io.EOF — не ошибка: errors.Is(err, io.EOF) и выход из цикла
  • Неизвестный сервис — fixture: codes.Unimplemented и дословное unknown service shop.v1.OrderService
  • Матрица совместимости — табличный тест с t.Parallel: пара клиент × сервер — строка данных
  • bufSize = 1024 * 1024; маленький буфер тормозит прогон, но не ломает корректность

Практика: три эксперимента локально#

Честная оговорка про формат, одна на урок: в самоучителе есть песочница с интерпретатором Go, но сеть в браузере недоступна — gRPC-сервер и bufconn в браузере не запустить, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет; каждое упражнение делается локально за несколько минут. Эксперимент первый: соберите каркас этого урока и уберите go перед srv.Serve(lis) — go test провиснет, а через десять минут упадёт паникой test timed out after; верните go и убедитесь, что прогон зелёный за миллисекунды. Эксперимент второй: в тесте матрицы зарегистрируйте только v2 и вызовите клиентом v1 — получите дословное unknown service shop.v1.OrderService как fixture, затем добавьте withV1 и наблюдайте OK. Эксперимент третий: уменьшите bufSize до 1024 и прогоните стрим-тест WatchOrders — тест обязан остаться зелёным (корректность не зависит от буфера), а прогон — слегка притормозить; верните 1024 * 1024. Заодно проверьте t.Parallel: закомментируйте его в матрице и сравните время go test -v — разница на четырёх парах небольшая, но на сотне тестов станет убедительной.

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

Пирамида собрана: хендлеры тестируются как функции, интеграционные тесты живут в памяти, матрица совместимости из двадцатого урока — это тест-кейсы, а не таблица в конспекте. Оставшийся вопрос курса — скорость боевого провода: если тест на bufconn зелёный, а продакшен тормозит, дело не в корректности, а в настройках транспорта — keepalive-пингах, лимитах размера сообщений, компрессии и окнах HTTP/2. Двадцать второй урок — производительность и тюнинг — разберёт именно эти ручки: как keepalive отличает живое соединение от мёртвого, чем грозит message size по умолчанию (четыре мебибайта) и когда компрессия окупается. Придёт время — прогоните тот же TestGetOrderOverBufconn с замером времени, и окажется, что и в трубах есть что тюнить: ведь HTTP/2 и сериализация там настоящие. Полный маршрут курса — в оглавлении gRPC-курса; правила совместимости, которые сегодня охраняет матрица-тест, вы выводили руками в уроке про версионирование.

Карточки урока

Термин

bufconn

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

Пакет test/bufconn из grpc-go: net.Listener, у которого Dial и Accept соединяют две трубы в памяти — сервер без порта и сети, полный стек gRPC сохраняется.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • bufconn поднимает gRPC-сервер на трубах в памяти: полный стек — HTTP/2, proto, статусы, дедлайны — без порта, TCP и доступа извне.
  • Каркас теста: bufconn.Listen(1024 * 1024), grpc.NewServer, регистрация хендлера, go srv.Serve(lis), t.Cleanup(srv.Stop), grpc.NewClient("passthrough:///bufnet") с WithContextDialer на lis.DialContext.
  • Тест хендлера напрямую — функция с заглушкой зависимости: проверяйте коды ошибок, а не голый err != nil.
  • Стрим-тест — Recv-цикл до io.EOF: фиксируйте количество, порядок (slices.Equal) и штатный конец errors.Is(err, io.EOF).
  • Матрица совместимости v1/v2 — табличный тест с t.Parallel: известные пары OK, неизвестные — Unimplemented и дословное unknown service.
  • t.Cleanup зовётся LIFO после теста и всех под-тестов; Stop останавливает сервер немедленно, GracefulStop — дожидается начатых RPC.

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

Возьмите сервис из двадцатого урока и покройте его пирамидой: табличный тест хендлера GetOrder с заглушкой зависимости (минимум три кейса с кодами OK и NotFound), хелпер newBufconnConn с t.Cleanup-уборкой, интеграционный тест GetOrderOverBufconn и матрицу совместимости из четырёх пар с t.Parallel. Затем добавьте стрим-тест WatchOrders с проверкой порядка событий и отдельным кейсом «хендлер вернул статусную ошибку». Добейтесь, чтобы go test -race проходил чисто.

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

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

Нужен ли bufconn, если можно тестировать хендлеры напрямую?

Нужен: прямой вызов хендлера не проходит через сериализацию, маршрутизацию по полному имени метода, статусы и HTTP/2. bufconn гоняет полный стек gRPC без сети, поэтому ловит то, что unit-тест хендлера принципиально не видит: неверное имя пакета, забытое поле, сломанный статус.

Почему grpc.NewClient в bufconn-тесте требует «passthrough:///bufnet»?

NewClient резолвит цели через схему в target, и по умолчанию резолвер — dns. Схема passthrough передаёт адрес дайлеру без изменений, и он попадает в WithContextDialer, где замыкание зовёт lis.DialContext(ctx). Имя bufnet — произвольная заглушка, в память оно не резолвится.

Что если srv.Serve(lis) вернуть ошибку в тесте?

После t.Cleanup(srv.Stop) Serve выходит с ошибкой — это штатный финал остановки, поэтому в тестовой горутине его просто глотают: _ = srv.Serve(lis). В бою ошибку Serve логируют, в тестах она означает «уборка сработала».

Можно ли параллелить bufconn-тесты?

Да, и это главная выгода in-memory-подхода: сервер не занимает порт и не имеет общего состояния, если каждый под-тест собирает собственный bufconn.Listen и собственный grpc.NewServer. Обязательно t.Helper() в хелпере и tc := tc для Go до 1.22 в цикле по кейсам.

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

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

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

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

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

Connect25 уроков~12 чпродвинутый25 уроков уже доступны

Connect: современный RPC от Buf

Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.

  • Строить сервисы на connectrpc.com/connect
  • Понимать три протокола и выбирать правильный
  • Настраивать buf: lint, breaking changes, generate
  • Писать interceptors и обрабатывать ошибки