---
title: "Тестирование gRPC-сервисов"
description: "Тестирование gRPC-сервисов: bufconn-сервер в памяти, тесты хендлеров без сети, стрим-тесты до io.EOF, матрица совместимости v1/v2, t.Cleanup и t.Parallel."
course: "gRPC-Go: высокопроизводительный RPC"
url: "https://golangmaster.ru/courses/grpc-go/lessons/21-testirovanie/"
published: "2026-09-19"
updated: "2026-09-26"
minutes: 20
---

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

*Курс «gRPC-Go: высокопроизводительный RPC», урок 21 из 25. ~20 минут. Обновлено 26.09.2026. Источник: https://golangmaster.ru/courses/grpc-go/lessons/21-testirovanie/*

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

### тестирование 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") прямо в тесте?

- ✅ Соединение in-memory: нет порта, TCP-стека и доступа извне, но весь стек gRPC — HTTP/2, сериализация, статусы — работает как в бою
- Вызовы идут напрямую в хендлеры, минуя gRPC-стек: bufconn подменяет транспорт вызовами функций
- bufconn шифрует трафик встроенным TLS, поэтому insecure-креды в тесте не нужны
- net.Listen на :0 запрещён в тестах: порт 0 зарезервирован под health-check

bufconn — это net.Listener, соединяющий Dial и Accept трубами в памяти, но по трубам едет настоящий HTTP/2 и настоящий proto: сериализация, фрейминг, статусы, метаданные и flow control — всё настоящее. Вариант с подменой транспорта функциями описывает моки интерфейса клиента, а не bufconn. TLS bufconn не даёт ни в каком виде — креды вы выбираете сами. Соседний же вариант с :0 вполне легален (сервер на случайном порту), просто медленнее, конфликтует с файрволами CI и оставляет тест доступным любому процессу машины — bufconn решает все три проблемы бесплатно.

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

Нижний этаж пирамиды — тест хендлера. Метод GetOrder — это обычная функция с двумя параметрами: контекст и запрос; чтобы её вызвать, не нужны ни сервер, ни bufconn, ни даже импорт google.golang.org/grpc. Достаточно сконструировать хендлер и позвать метод. Единственное, что мешает, — зависимость. В двадцатом уроке хендлер звал глобальную функцию loadOrder(ctx, req.GetId()) — для теста это блокер: глобальную функцию не подменишь. Исправление — одна строка и целая дисциплина: зависимость становится полем структуры. Боевой main подставляет туда настоящий репозиторий, тест — заглушку в пять строк. Это ровно та же дисциплина «инъекция зависимости», которую вы применяли к httptest в [шестнадцатом уроке](/courses/go-advanced/lessons/16-testirovanie/), — здесь она выглядит даже проще, потому что 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

```go
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
}
```

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

- ✅ Тест зависнет: Serve блокирует горутину теста, до клиентского кода дело не дойдёт; через 10 минут go test уронит прогон паникой test timed out after
- GetOrder вернёт Unavailable: сервер не подняли — тест мгновенно падает с ошибкой дозвона
- Тест падает при компиляции: srv.Serve без go — синтаксическая ошибка
- Тест пройдёт: Serve успевает поднять сервер до первого RPC

Serve — это бесконечный цикл Accept: он не возвращается, пока сервер не остановят. Без go он вызывается в горутине самого теста, управление к клиентскому коду не переходит никогда, и тест просто висит. Мгновенной ошибки не будет и по другой причине: grpc.NewClient ничего не открывает — «No I/O is performed» — и ErrUnavailable приехал бы только из будущего RPC, до которого дело не доходит. Спасает go test по умолчанию только аварийный стоп: флаг -timeout по умолчанию равен 10 минутам, после чего прогон падает паникой вида «panic: test timed out after 10m0s» с дампом всех горутин — в дампе будет видно, что тестовая горутина сидит в srv.Serve. Мораль: raise-сервер — в горутину, а ошибка Serve после Stop — штатное завершение, её в тестах молча проглатывают.

### Стрим-тест: 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 после конца потока

```go
// хендлер: три события и закрытие потока
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() // четвёртый вызов
```

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

- ✅ err == io.EOF: поток закрыт со статусом OK — это штатный конец, четвёртого сообщения не будет
- err с кодом OutOfRange: сообщение за пределами потока
- err с кодом Unavailable: сервер закрыл соединение некорректно
- ev != nil и err == nil: Recv всегда возвращает сообщение

nil, возвращённый хендлером, — это статус OK: транспорт аккуратно закрывает поток полузакрытием записи своей трубы, и на клиенте Recv после последнего сообщения возвращает io.EOF. Седьмой урок формулировал это так: io.EOF — не ошибка, а вежливое «всё». Unavailable не приедет — соединение живо, сервер его не рвал; OutOfRange не приедет — это код прикладного уровня, а не транспортный; и никогда Recv не вернёт nil-сообщение с nil-ошибкой — после io.EOF повторный вызов снова вернёт io.EOF. Если же хендлер завершится status.Errorf(codes.NotFound, ...), тот же 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-тест пропускает по определению?

- ✅ TLS-рукопожатие и поведение TCP: соединение — две трубы в памяти, креды — insecure
- Сериализацию proto: bufconn гоняет сообщения в памяти без кодирования
- Статусы и метаданные: над трубами они не ездят
- HTTP/2-фрейминг и flow control: bufconn пишет сообщения напрямую в буфер

bufconn подменяет только транспорт: net.Conn, лежащий под HTTP/2, — это пара труб. Всё, что выше, — настоящее: сообщения кодируются в байты, фреймятся HTTP/2, поедут через flow-control окна, статусы и метаданные живут в headers и trailers. Поэтому первые три «отсутствия» — выдумки, а вот TLS-рукопожатие, сертификаты, TCP-таймауты ядра, keepalive-сокеты и файрволы CI действительно остаются за кадром — они проверяются на e2e-этаже с настоящим портом и настоящими кредами. Отсюда правило распределения: всё, что чинится перегенерацией proto и кодом хендлера, — тесты на bufconn; всё, что чинится настройкой сети и сертификатов, — e2e.

### 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() }). В каком порядке сработает уборка?

- ✅ Сначала conn.Close(), потом srv.Stop: Cleanup зовётся в порядке last added, first called
- Сначала srv.Stop, потом conn.Close(): порядок регистрации повторяется FIFO
- Оба вызова одновременно в момент t.Parallel(): Cleanup срабатывает при параллелизации
- Только srv.Stop: conn.Close() в Cleanup вызывать нельзя — соединение ещё живо

Документация testing закрывает вопрос: «Cleanup functions will be called in last added, first called order» — стек, LIFO. Клиентский conn.Close() добавлен последним — сработает первым, и сервер остановится уже без клиента; обратный порядок тоже бы не сломал тест (Stop закрывает связи и за клиента), но осмысленный LIFO-порядок — клиент, затем сервер — повторяет логику боевого завершения. Cleanup не связан с t.Parallel и срабатывает после теста и всех его под-тестов — в том числе после провала через t.Fatal.

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

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

```go
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 // штатный конец потока
                }
        }
}
```

Подсказка: Одно значение — пакет-слушатель с трубами, одно — регистрация уборки, одно — цель NewClient со схемой passthrough, одно — сигнальная ошибка Recv.

Правильные ответы в пропусках: `bufconn.Listen`, `t.Cleanup`, `passthrough:///bufnet`, `io.EOF`

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

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

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

1. `lis := bufconn.Listen(1024 * 1024) — слушатель в памяти, порта нет`
2. `srv := grpc.NewServer() — сервер без сетевых настроек`
3. `shopv1.RegisterOrderServiceServer(srv, &v1Handler{}) — регистрация хендлера с боевой зависимостью`
4. `go func() { _ = srv.Serve(lis) }() — Serve уходит в горутину, тест живёт`
5. `t.Cleanup(srv.Stop) — уборка зарегистрирована до первого вызова`
6. `conn := grpc.NewClient("passthrough:///bufnet", ...) — клиент с WithContextDialer на lis.DialContext`
7. `client := shopv1.NewOrderServiceClient(conn) — сгенерированный стаб готов`
8. `resp, err := client.GetOrder(ctx, req) — вызов через полный стек gRPC`
9. `проверки: статус, поля ответа, порядок событий Recv до io.EOF`

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

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

> **Размер буфера: почему 1024 * 1024.** Труба bufconn — «ring buffer of fixed capacity»: кольцевой буфер фиксированной ёмкости, и ёмкость задаёт ровно один аргумент Listen(sz). Пока буфер полон, Write блокируется — это честная обратная связь по давлению, поэтому маленький bufSize не ломает корректность: сообщение больше буфера просто уезжает порциями, как вода через узкое горлышко. Что меняется — скорость: HTTP/2-фреймы по 16 КиБ пролезают в буфер на 1 КиБ в несколько приёмов, и прогон тормозит на переключениях. Пара труб на соединение — по одной на каждое направление, полудуплекс тут невозможен. Каноничный 1024 * 1024 (1 МиБ) из примеров grpc-go — разумный компромисс: дешевле памяти, чем гигантские буферы, и не душит фреймы. Правило практиков: не трогайте bufSize, пока прогон не стал заметно медленным, и не ставьте меньше 64 КиБ — меньше одного flow-control окна HTTP/2.

### Чек-лист тестирования 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-курса](/courses/grpc-go/); правила совместимости, которые сегодня охраняет матрица-тест, вы выводили руками в [уроке про версионирование](/courses/grpc-go/lessons/20-versionirovanie/).

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

- **bufconn** — Пакет test/bufconn из grpc-go: net.Listener, у которого Dial и Accept соединяют две трубы в памяти — сервер без порта и сети, полный стек gRPC сохраняется.
- **bufconn.Listen(sz)** — Создаёт слушатель с фиксированной ёмкостью буфера; каноничный sz = 1024 * 1024. Документация: «Listen returns a Listener that can only be contacted by its own Dialers and creates buffered connections between the two».
- **passthrough:///bufnet** — Цель grpc.NewClient в bufconn-тесте: схема passthrough отдаёт адрес дайлеру как есть (минуя DNS — резолвер по умолчанию dns); bufnet — произвольное имя-заглушка.
- **t.Cleanup** — «Cleanup registers a function to be called when the test (or subtest) and all its subtests complete»; вызовы в порядке last added, first called — LIFO. Сработает и после t.Fatal.
- **io.EOF в стрим-тесте** — Штатный конец серверного потока: после nil-возврата хендлера Recv вернёт io.EOF — errors.Is(err, io.EOF) и выход из цикла; повторный Recv снова вернёт io.EOF.
- **Матрица совместимости как тест** — Табличный тест пар клиент × сервер (v1/v2): известные пары — codes.OK, неизвестные — codes.Unimplemented с дословным unknown service <пакет>.<Service> — fixture из урока про версионирование.

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

- 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 проходил чисто.

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

### Нужен ли 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 в цикле по кейсам.
