Содержание урока · 14 разделов
- тестирование grpc: три уровня проверки
- bufconn: сетевой тест без сети
- Тест хендлера напрямую: сеть не нужна
- Стрим-тест: Recv до io.EOF
- Матрица совместимости v1/v2 как тест
- t.Cleanup: уборка, которая не забудется
- t.Parallel: параллельность и её цена
- Три уровня проверки gRPC-сервиса
- Чек-лист тестирования gRPC
- Практика: три эксперимента локально
- Что дальше: скорость провода
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
тестирование 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-схемой и штатный конец стрима.
Шаги bufconn-интеграционного теста
Соберите жизненный цикл bufconn-теста из девяти шагов — от слушателя до проверки результата.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Три уровня проверки gRPC-сервиса#
| Уровень | Что гоняет | Что ловит |
|---|---|---|
| Тест хендлера напрямую | Метод как функция: заглушка зависимости, context.Background() | Доменные правила, коды ошибок, граничные значения; миллисекунды на кейс |
| bufconn-интеграционный | Полный стек gRPC поверх труб в памяти: HTTP/2, proto, статусы, стримы | Маршрутизацию по пакету, матрицу совместимости, дедлайны, метаданные |
| e2e на настоящем порту | TCP + TLS + балансировщик, боевые сертификаты | То, что bufconn не видит: рукопожатие TLS, keepalive, порты и файрволы |
| Ручной grpcurl (восемнадцатый урок) | Живые запросы к стейджингу без кода | Отражение, схемы и статусы глазами клиента без Go-кода |
Труба 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-курса; правила совместимости, которые сегодня охраняет матрица-тест, вы выводили руками в уроке про версионирование.
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 проходил чисто.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли 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 в цикле по кейсам.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Connect: современный RPC от Buf
Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.
- Строить сервисы на connectrpc.com/connect
- Понимать три протокола и выбирать правильный
- Настраивать buf: lint, breaking changes, generate
- Писать interceptors и обрабатывать ошибки