5.4Тесты на границах сервиса
Проверим RPC, HTTP и queue через их реальные границы, учитывая совместимость соседних версий.
- Что такое
bufconn, какие слои gRPC он прогоняет честно и какие подменяет. - Почему зелёные тесты провайдера не значат, что клиент переживёт выкатку, и что с этим делает
buf breaking. - Что обязано быть в тестах своего клиента к чужому API и когда подменяют
http.RoundTripper, а когда поднимают сервер. - Как проверить идемпотентность консьюмера и почему одно застрявшее сообщение держит всю партицию.
- Как собирается тест сервиса целиком и сколько таких тестов стоит держать.
gRPC в тесте: bufconn вместо сокета
С HTTP всё просто: хендлер вызывается функцией через httptest.ResponseRecorder,
клиент ходит в httptest.NewServer, это разобрано в главе 5.2. В gRPC так не
выйдет. Хендлер принимает сгенерированные типы, и прямой вызов пропускает весь слой, ради
которого gRPC и берут: кодек, метаданные, интерцепторы, коды статуса, дедлайны.
Отсюда google.golang.org/grpc/test/bufconn - net.Listener,
который вместо сокета держит буфер в памяти: Accept отдаёт один конец
соединения, DialContext другой. Сервер настоящий, клиент настоящий,
нет только сети.
// startServer поднимает сервер на bufconn и возвращает готовый клиент.
func startServer(t *testing.T, srv *orderapi.Server, opts ...grpc.ServerOption) orderv1.OrderServiceClient {
t.Helper()
lis := bufconn.Listen(1024 * 1024) // буфер, а не сокет: ни порта, ни дескриптора
s := grpc.NewServer(opts...)
orderv1.RegisterOrderServiceServer(s, srv)
go func() {
if err := s.Serve(lis); err != nil {
t.Errorf("Serve: %v", err) // горутина сервера: только t.Errorf, не t.Fatal
}
}()
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("NewClient: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
s.Stop()
_ = lis.Close()
})
return orderv1.NewOrderServiceClient(conn)
}
Две детали в этих двадцати строках легко проскочить глазами. Первая:
grpc.NewClient вместо устаревшего grpc.DialContext. Старая функция
открывала соединение сразу, новая - лениво, при первом вызове; на bufconn разницы почти нет,
а привычка полезная. Вторая: схема passthrough:/// перед именем. Она говорит
резолверу ничего не резолвить и отдать имя диалеру как есть. У NewClient
резолвер по умолчанию - dns, и без passthrough клиент уходит
искать хост bufnet в DNS, а диалер не вызывается вообще:
$ go test ./internal/orderapi/ -v -run TestTargetWithoutPassthrough
target_test.go:39: вызов с target="bufnet": err=rpc error: code = DeadlineExceeded desc = context deadline exceeded
target_test.go:27: диалер получил addr="bufnet"
target_test.go:39: вызов с target="passthrough:///bufnet": err=<nil>
--- PASS: TestTargetWithoutPassthrough (5.01s)
Тест не падает с внятной ошибкой, а висит до дедлайна - пять секунд на первый кейс. Именно так промах и выглядит в жизни: «у меня bufconn-тест отваливается по таймауту».
Дальше тест выглядит как обычный табличный, только вместо возвращённой ошибки смотрят код:
status.FromError(err), потом st.Code() и st.Message().
Список подменённого не абстрактный, его видно прогоном. Хендлер спрашивает адрес клиента
через peer.FromContext: обычное дело, если по адресу считают лимиты или пишут
аудит. Один хендлер, два способа поднять сервер:
$ go test ./internal/orderapi/ -v -run TestPeerAddr
=== RUN TestPeerAddr_Bufconn
bufconn_test.go:149: bufconn: peer.Addr = "bufconn"
--- PASS: TestPeerAddr_Bufconn (0.00s)
=== RUN TestPeerAddr_TCP
bufconn_test.go:172: tcp: server.Addr = "127.0.0.1:65169", peer.Addr = "127.0.0.1:65170"
--- PASS: TestPeerAddr_TCP (0.00s)
На bufconn адрес - строка bufconn без порта и без хоста. Код, который делает
net.SplitHostPort(pr.Addr.String()), в тесте пройдёт по ветке ошибки, а в бою
по нормальной, и наоборот. Зато всё, что живёт выше транспорта, проверяется честно.
Лимит на размер сообщения, например:
$ go test ./internal/orderapi/ -v -run TestMaxRecvMsgSize
=== RUN TestMaxRecvMsgSize
msgsize_test.go:20: err = rpc error: code = ResourceExhausted desc = grpc: received message larger than max (4101 vs. 1024)
--- PASS: TestMaxRecvMsgSize (0.00s)
На bufconn пиши всё, что про семантику вызова: коды ошибок, метаданные, дедлайны, валидацию, стримы, интерцепторы, размеры сообщений. На настоящем слушателе - то, что про сеть и конфигурацию: TLS и взаимная аутентификация, keepalive, что сервис поднялся на том адресе, который прописан в конфиге. Таких тестов нужно немного, обычно один на сервис, и живут они рядом с тестом сервиса целиком.
$ go test ./internal/orderapi/ -run '^$' -bench BenchmarkUnary -benchtime=2000x
cpu: Apple M4 Pro
BenchmarkUnary_Bufconn-14 2000 21771 ns/op 10302 B/op 147 allocs/op
BenchmarkUnary_TCP-14 2000 46083 ns/op 9054 B/op 147 allocs/op
Вдвое с лишним на локальном шлейфе (Apple M4 Pro, Go 1.27.1, grpc-go v1.84.0).
Само по себе мелочь: двадцать четыре микросекунды никого не разорят.
Важнее, что bufconn не берёт порт. Сотня пакетов, которые go test ./...
гоняет параллельно, не подерётся за диапазон портов и не упадёт в CI
с address already in use.
Интерцепторы: коды ошибок, метаданные, дедлайн
Интерцептор (interceptor, «перехватчик») - мидлварь gRPC: функция, которая
оборачивает вызов хендлера. Через неё проходит всё, что снаружи выглядит как поведение
сервиса: авторизация, валидация, метрики, трассировка и превращение доменной ошибки
в код статуса. Проверять её отдельно от сервера бессмысленно: подменив
handler заглушкой, ты проверишь заглушку. На bufconn интерцептор работает
как в бою, поэтому тест пишут через клиента.
// ErrorInterceptor - единственное место, где доменная ошибка превращается в код gRPC.
func ErrorInterceptor(ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
resp, err := handler(ctx, req)
switch {
case err == nil:
return resp, nil
case errors.Is(err, ErrNotFound):
return nil, status.Error(codes.NotFound, err.Error())
case errors.Is(err, ErrPaid):
return nil, status.Error(codes.FailedPrecondition, err.Error())
case errors.Is(err, context.Canceled):
return nil, status.Error(codes.Canceled, err.Error())
}
// Наружу не отдаём ни текст, ни тип: только Internal.
return nil, status.Error(codes.Internal, "internal error")
}
Тест на такой интерцептор проверяет две вещи сразу: что нужный код доезжает до клиента и что без интерцептора всё плохо. Второй прогон стоит держать в репозитории, он показывает цену пропуска:
$ go test ./internal/orderapi/ -v -run TestGetOrder
=== RUN TestGetOrder_NotFound
bufconn_test.go:74: code=NotFound message="get order 42: order: не найден"
--- PASS: TestGetOrder_NotFound (0.00s)
=== RUN TestGetOrder_NoInterceptor
bufconn_test.go:85: без интерцептора: code=Unknown message="get order 42: order: не найден"
--- PASS: TestGetOrder_NoInterceptor (0.00s)
Забыли интерцептор - клиент получает Unknown и внутренний текст ошибки целиком.
Код Unknown для клиента не значит ничего: по нему нельзя решить, повторять
запрос или сдаваться. А текст утекает наружу вместе с именами таблиц и идентификаторами.
Код здесь не украшение: по нему клиент решает, повторять ли вызов. Повторять можно
Unavailable и DeadlineExceeded (если вызов идемпотентный) и нельзя
NotFound, FailedPrecondition, InvalidArgument,
Unauthenticated. Тест на маппинг ошибок пишется табличным: доменная ошибка
на входе, ожидаемый код на выходе, по строке на каждый сентинель.
Второй интерцептор, который есть почти везде, проверяет машинный токен из метаданных. Прогон на нём показывает подвох, про который редко думают:
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "нет метаданных")
}
got := md.Get("authorization")
if len(got) == 0 || got[0] != "Bearer "+token {
return nil, status.Error(codes.Unauthenticated, "плохой токен")
}
return handler(ctx, req)
$ go test ./internal/orderapi/ -v -run TestAuthInterceptor
=== RUN TestAuthInterceptor
bufconn_test.go:110: без метаданных: code=Unauthenticated message="плохой токен"
--- PASS: TestAuthInterceptor (0.00s)
Клиент не отправлял ни одного заголовка, а сработала ветка «плохой токен», а не
«нет метаданных». Потому что в grpc-go входящие метаданные на сервере есть у любого
вызова: туда попадают служебные :authority, content-type,
user-agent. Ветка !ok в серверном интерцепторе недостижима,
и написана она на память о клиентской стороне, где FromOutgoingContext
действительно может вернуть false.
Третьим на границе проверяют дедлайн. Клиент ставит
context.WithTimeout, grpc-go превращает его в заголовок
grpc-timeout, сервер разворачивает обратно в дедлайн контекста. Это работает
и на bufconn, в чём легко убедиться. А вот что при этом увидит сервер, угадывают неверно:
$ go test ./internal/orderapi/ -v -run TestDeadlinePropagates -count=3
=== RUN TestDeadlinePropagates
stream_test.go:56: на сервере: deadline set=true, осталось 38ms
stream_test.go:57: клиент видит: DeadlineExceeded
stream_test.go:58: сервер видит: ctx.Err() = context canceled
--- PASS: TestDeadlinePropagates (0.04s)
Дедлайн до сервера доезжает: ctx.Deadline() в хендлере возвращает
ok=true и почти те же 40 мс, что поставил клиент. Но когда время выходит,
клиент почти всегда успевает закрыть поток первым, и grpc-go (проверено на v1.84.0) гасит
контекст хендлера отменой, а не дедлайном. Поэтому серверный код вида
if errors.Is(ctx.Err(), context.DeadlineExceeded) { metrics.Timeouts.Inc() }
срабатывает хорошо если раз на сотню вызовов, и счётчик таймаутов на дашборде так и стоит
около нуля. Проверяй на сервере ctx.Err() != nil, а различать причину - только по
ctx.Deadline(), если она вообще нужна.
Валидация запроса на границе
Проверять входные данные внутри хендлера можно, но тогда правило живёт в Go, а клиенты
на других языках о нём не знают. protovalidate переносит правила в
.proto: они становятся частью контракта, генерируются вместе с ним и
проверяются одним интерцептором на весь сервис.
message PayOrderRequest {
int64 id = 1 [(buf.validate.field).int64.gt = 0];
string idempotency_key = 2 [(buf.validate.field).string = {
min_len: 8
max_len: 64
}];
}
Интерцептор на три строки: привести req к proto.Message,
позвать v.Validate(msg) и на ошибке вернуть
status.Error(codes.InvalidArgument, err.Error()).
$ go test ./internal/orderapi/ -v -run TestValidateInterceptor
=== RUN TestValidateInterceptor/валидный
validate_test.go:36: code=OK message=""
=== RUN TestValidateInterceptor/нулевой_id
validate_test.go:36: code=InvalidArgument message="validation error: id: must be greater than 0"
=== RUN TestValidateInterceptor/короткий_ключ
validate_test.go:36: code=InvalidArgument message="validation error: idempotency_key: must be at least 8 characters"
--- PASS: TestValidateInterceptor (0.00s)
Снято на buf.build/go/protovalidate v1.4.0. Тест на валидацию пишется
табличным: по строке на каждое правило плюс граничные значения, как в главе 5.1.
Порядок интерцепторов важен: grpc.ChainUnaryInterceptor(A, B, C) входит в них
слева направо, а выходит справа налево (прогон печатает
[->A ->B ->C C-> B-> A->]). Валидацию ставят после авторизации, иначе
неаутентифицированный клиент по разнице между InvalidArgument и
Unauthenticated узнает форму твоих сообщений.
Стримы: где именно заканчивается вызов
Со стримом (stream, поток сообщений внутри одного вызова) тест устроен иначе: у него нет одной точки «вернулось значение». Проверять приходится три вещи: что пришло, как вызов закончился и что стало с сервером, когда клиент ушёл.
stream, err := c.WatchOrders(ctx, &orderv1.WatchOrdersRequest{UserId: 7})
for range 3 {
ev, err := stream.Recv() // читаем ровно столько, сколько ждём
...
}
cancel() // клиент уходит
_, err = stream.Recv()
if status.Code(err) != codes.Canceled {
t.Fatalf("хотим Canceled, получили %s", status.Code(err))
}
if errors.Is(err, io.EOF) {
t.Fatal("io.EOF - это штатное завершение, а не отмена")
}
$ go test ./internal/orderapi/ -v -run TestWatchOrders
=== RUN TestWatchOrders_ServerCloses
stream_test.go:119: получили события: [1 2 3]
stream_test.go:123: после отмены Recv вернул: rpc error: code = Canceled desc = context canceled (code=Canceled)
--- PASS: TestWatchOrders_ServerCloses (0.00s)
=== RUN TestWatchOrders_ClientLeaves
stream_test.go:148: сервер остановился на 1-м событии
--- PASS: TestWatchOrders_ClientLeaves (0.00s)
Вызов c.WatchOrders(ctx, req) возвращает ошибку только про транспорт:
сервер недоступен, соединение не поднялось. Всё, что решил сам хендлер, доезжает
первым Recv, потому что до него по проводу не ушло ни байта ответа.
Хендлер, который сразу отвечает PermissionDenied:
$ go test ./internal/orderapi/ -v -run TestFact_StreamHandlerError
=== RUN TestFact_StreamHandlerError
facts_test.go:103: WatchOrders вернул: err=<nil>
facts_test.go:108: первый Recv вернул: err=rpc error: code = PermissionDenied desc = чужие заказы смотреть нельзя (code=PermissionDenied)
--- PASS: TestFact_StreamHandlerError (0.00s)
Тест, который проверил ошибку открытия стрима и успокоился, не проверил ни одного отказа.
Есть и вторая половина той же истории: штатный конец потока. Когда хендлер возвращает
nil, клиент получает из Recv ровно io.EOF
(errors.Is(err, io.EOF) даёт true), и это не сбой. Код вида
if err != nil { return err } в цикле чтения превращает нормальное завершение
в ошибку сервиса.
Второй тест проверяет обратное направление: клиент отменил контекст - заметил ли это
сервер. Хендлер стрима, который шлёт события в цикле и не смотрит в
stream.Context().Done(), после ухода клиента продолжает работать:
Send вернёт ошибку, но если её игнорировать, горутина останется навсегда.
Проверяется это тем же приёмом, что и в главе 5.2: сервер пишет в канал, докуда дошёл,
тест ждёт этот сигнал с таймаутом.
Контракт между сервисами: что ломается при выкатке по одному
Два сервиса не переключаются на новую версию одновременно. Сначала уезжает один, потом второй, и минуты между ними новый провайдер отвечает старому потребителю, а старый - новому. Rolling deploy добавляет вторую ось: часть подов уже новая, часть ещё старая, и балансировщик раскидывает запросы между ними.
Отсюда и следствие. Тест провайдера - не тест контракта. Он проверяет, что сервис ведёт себя так, как думает его команда, и ничего не знает ни про поле, которое читает потребитель, ни про то, что клиент собран три месяца назад. Так и выходит: у провайдера всё зелено, а на проде красно.
buf breaking: машинная проверка совместимости схемы
Первая линия обороны дешёвая и полностью автоматическая. buf breaking сравнивает
текущий .proto с версией из git и находит изменения, несовместимые с уже
собранными клиентами. Настройка занимает четыре строки в buf.yaml
(breaking: use: [FILE], самый строгий набор). Переименовали поле и поменяли
его тип со строки на enum, обычная «косметика» в MR:
$ buf breaking --against '.git#branch=main'
proto/order/v1/order.proto:14:3:Field "3" with name "total_amount" on message "Order" changed option "json_name" from "amount" to "totalAmount".
proto/order/v1/order.proto:14:9:Field "3" on message "Order" changed name from "amount" to "total_amount".
proto/order/v1/order.proto:15:3:Field "4" with name "status" on message "Order" changed type from "string" to "enum".
$ echo $?
100
Код возврата 100 роняет джобу, MR не вливается. Проверено на buf v1.73.0. В CI это
отдельная быстрая стадия рядом с buf lint, и она не требует ни базы, ни докера.
Чего эта проверка не видит:
$ git diff proto/order/v1/order.proto # добавили поле, которое сервер теперь требует
message GetOrderRequest {
int64 id = 1;
+ string currency = 2;
}
$ buf breaking --against '.git#branch=main'
$ echo $?
0
Только форму: номера и типы полей, имена, наличие методов, совместимость
сгенерированного кода. Всё, что живёт в смысле, для него невидимо: новое поле,
ставшее обязательным по факту; значение строкового статуса, поменявшееся с
paid на PAID; поле, которое раньше сервер всегда заполнял,
а теперь иногда оставляет пустым; ужесточившееся правило валидации. Формулировка
для собеседования: buf breaking закрывает совместимость схемы, а совместимость
поведения - нет, и второе ловится только тестом со стороны потребителя.
Схемы событий: почему бинарный формат прощает, а JSON - нет
С событиями в очереди всё то же самое, только хуже: потребитель не отвечает, и о поломке узнают из графика ошибок консьюмера. Разница между форматами тут не вкусовая, её видно прогоном. Продюсер уехал на новую схему (поле 2 переименовано, добавлено поле 4), консьюмер остался на старой:
$ go test ./schema/ -v
=== RUN TestWireCompat/binary
schema_test.go:26: старый консьюмер прочитал: order_id=1 amount=500 status="paid"
schema_test.go:28: неизвестные поля сохранены: 5 байт
=== RUN TestWireCompat/json
schema_test.go:36: продюсер отправил: {"orderId":"1","totalAmount":"500","status":"paid","currency":"RUB"}
schema_test.go:40: старый консьюмер: err = proto: (line 1:16): unknown field "totalAmount"
schema_test.go:47: с DiscardUnknown: order_id=1 amount=0 status="paid"
--- PASS: TestWireCompat (0.00s)
Три разных исхода одного и того же изменения. В бинарном protobuf номер поля - и есть
контракт, имя в нём не передаётся: старый консьюмер спокойно прочитал сумму, а незнакомое
поле сохранил как неизвестное и вернёт обратно при пересылке. В JSON контрактом становится
имя, и строгий разбор честно падает. А вот третья строка - самая опасная:
DiscardUnknown: true, который ставят «чтобы не падало», превращает поломку
в amount=0. Сервис зелёный, метрики зелёные, суммы нулевые.
Схема события - такой же контракт, как API, и жить она должна в том же репозитории схем,
с теми же проверками совместимости (buf breaking для protobuf, реестр схем
с проверкой совместимости для Avro и JSON Schema). Поля добавляют, а не
переименовывают; удаляют через reserved; тип не меняют никогда. А тест на
совместимость пишется ровно так, как выше: взять сообщение, собранное новой схемой,
и разобрать его старой. Полсотни строк, которые ловят целый класс инцидентов.
Контрактные тесты: ожидания потребителя как артефакт
Обе проверки выше смотрят на схему. Контрактный тест смотрит на ожидания: потребитель записывает, какие запросы он шлёт и какие поля ответа читает, а провайдер прогоняет эту запись по своему настоящему коду. Этим занимается Pact; идея простая настолько, что её видно на пятидесяти строках своего кода.
Тест потребителя выглядит как обычный тест клиента из главы 5.2 - заглушка на
httptest.Server, - только заглушка ещё и дописывает взаимодействие
(метод, путь, заголовки, тело запроса, код и тело ответа) в файл
pacts/orders-paygate.json. Тест провайдера читает тот же файл и проигрывает
его по настоящему хендлеру шлюза:
for _, in := range p.Interactions {
t.Run(in.Description, func(t *testing.T) {
req := httptest.NewRequest(in.Method, in.Path, bytes.NewReader(in.ReqBody))
for k, v := range in.ReqHeaders {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
provider.Handler().ServeHTTP(rec, req) // настоящий хендлер шлюза
if rec.Code != in.Status {
t.Fatalf("код %d, потребитель ждёт %d", rec.Code, in.Status)
}
// Сверяем не байты, а поля, которые потребитель реально читает.
var want, have map[string]any
_ = json.Unmarshal(in.RespBody, &want)
if err := json.Unmarshal(rec.Body.Bytes(), &have); err != nil {
t.Fatalf("провайдер ответил не JSON: %q", rec.Body.Bytes())
}
for k := range want {
if _, ok := have[k]; !ok {
t.Fatalf("нет поля %q, а потребитель его читает", k)
}
}
})
}
Теперь команда шлюза переименовывает в ответе id в charge_id:
$ go test ./pact/ -run TestProvider -v
=== RUN TestProvider_HonoursPact/успешное_списание
provider_test.go:36: POST /v1/charges -> 200 {"charge_id":"tx-42","status":"succeeded"}
provider_test.go:49: в ответе провайдера нет поля "id", а потребитель его читает
--- FAIL: TestProvider_HonoursPact (0.00s)
--- FAIL: TestProvider_HonoursPact/успешное_списание (0.00s)
FAIL
FAIL example.com/shop/pact 0.321s
Ни один сервис при этом не поднимался, база не нужна, прогон занял треть секунды. Настоящий
Pact добавляет к этому брокер, который хранит контракты и связывает их с версиями
сервисов, матчеры на типы вместо точных значений и состояния провайдера
(provider states). За это платят: библиотека тянет нативную зависимость,
на каждый контракт нужны договорённости между командами, а файлы контрактов надо
где-то версионировать. Считать надо так: Pact окупается там, где потребителей
много и они в других командах. Для двух своих сервисов в одном репозитории
общий пакет с типами и buf breaking дают почти то же за копейки.
| Способ | Что ловит | Чего не ловит | Цена |
|---|---|---|---|
| Тесты провайдера | Что сервис делает то, что задумала его команда | Всё, что важно потребителю и о чём провайдер не знает | ноль, они и так есть |
buf breaking / реестр схем | Несовместимые правки схемы: номера, типы, имена, удаление методов | Смысл: новое обязательное поле, поменявшееся значение статуса, ужесточённую валидацию | стадия в CI на секунды |
| Тест совместимости схем | Что сообщение новой версии разбирается старым кодом | Поведение сервиса, коды ошибок | десятки строк на формат |
| Контрактный тест (Pact) | Поля и коды, которые реально читает потребитель | Бизнес-правила, нагрузку, порядок событий | брокер, договорённости между командами |
| E2E двух сервисов | Что связка работает целиком | Редкие ветки; первым начинает мигать | минуты прогона и постоянный уход |
Свой клиент к чужому API
В клиенте к чужому сервису отказывают не твои гипотезы, а чужие. Проверяют в нём не «успешный ответ разобрался», а то, что будет, когда чужая сторона поведёт себя плохо. Обязательный список короткий, и бизнес-логики в нём нет.
| Что проверяем | Как моделируем | Что должно случиться |
|---|---|---|
| Таймаут запроса | time.Sleep в хендлере заглушки | ошибка приходит за отведённое время, net.Error.Timeout() истинна |
| Отмена по контексту | cancel() из соседней горутины | в цепочке лежит context.Canceled, ретраев нет |
| Ретрай идемпотентного вызова | две 503 подряд, потом 200 | успех с третьей попытки, ключ идемпотентности тот же |
| Отказ, который повторять нельзя | 400 с телом ошибки | ровно одна попытка, доменная ошибка наружу |
| Не-JSON вместо JSON | HTML-страница от прокси | внятная ошибка с типом контента и началом тела |
| 200 без нужных полей | тело {} | ошибка, а не «успех с пустым txID» |
$ go test ./paygate/ -v
=== RUN TestCharge_Timeout
client_test.go:39: за 30ms вернулось: charge: после 1 попыток: запрос к шлюзу: Post "http://127.0.0.1:64344/v1/charges": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
client_test.go:43: это таймаут: net.Error.Timeout() = true
--- PASS: TestCharge_Timeout (0.30s)
=== RUN TestCharge_ContextCancel
client_test.go:62: err = запрос к шлюзу: Post "http://127.0.0.1:64348/v1/charges": context canceled
--- PASS: TestCharge_ContextCancel (0.30s)
=== RUN TestCharge_RetryOn503
client_test.go:89: tx=tx-42, попыток=3, ключи=[key-1 key-1 key-1]
--- PASS: TestCharge_RetryOn503 (0.01s)
=== RUN TestCharge_NoRetryOn400
client_test.go:104: попыток=1, err=paygate: отказ шлюза: 400 card_declined/недостаточно средств
--- PASS: TestCharge_NoRetryOn400 (0.00s)
=== RUN TestCharge_HTMLInsteadOfJSON
client_test.go:123: err = разобрать ответ (text/html; charset=utf-8, 79 байт, начало "<html><head><title>504 Gateway Time-out</title></head><body>nginx</body></html>"): invalid character '<' looking for beginning of value
--- PASS: TestCharge_HTMLInsteadOfJSON (0.00s)
=== RUN TestCharge_EmptyBody200
client_test.go:138: err = шлюз вернул 200 без id: "{}"
--- PASS: TestCharge_EmptyBody200 (0.00s)
Два теста из шести проверяют не клиент, а качество его диагностики. Когда между тобой
и шлюзом встанет прокси и отдаст страницу nginx, в логе должно быть видно тип контента
и первые сто двадцать байт тела, а не голое
invalid character '<' looking for beginning of value - по нему дежурный
ночью не поймёт ничего. То же с ответом {}: молчаливый успех с пустым
идентификатором транзакции хуже честной ошибки.
Отдельно стоит строка ключи=[key-1 key-1 key-1]. Ретраить чужой вызов можно
только тогда, когда чужая сторона умеет отличать повтор от нового запроса, то есть когда
у запроса есть ключ идемпотентности и он не меняется между попытками. Тест,
который собирает заголовки всех попыток и сравнивает их, стоит трёх строк и закрывает
самый дорогой класс ошибок в этом коде: списание два раза вместо одного.
client.Do возвращает ошибку и когда вышел http.Client.Timeout,
и когда отменили родительский контекст. Первое повторять на идемпотентном вызове можно,
второе - нельзя: наверху уже сдались, повтор только сожжёт соединения и квоту. Разбирать
текст ошибки тут ненадёжно, проверяй ctx.Err() != nil перед решением о повторе.
Второй частый промах рядом - ретраить на 400 и 409: ответ не изменится, а счётчик
запросов у чужого API конечный.
Подмена транспорта против поднятия сервера
httptest.NewServer и подмена http.RoundTripper решают одну задачу
разными путями. Сервер поднимает настоящий сокет и прогоняет весь net/http:
разбор URL, заголовки, keep-alive, сжатие. RoundTripper встаёт ниже уровня адреса -
клиент даже не подозревает, что сети нет.
type rtFunc func(*http.Request) (*http.Response, error)
func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
rt := rtFunc(func(r *http.Request) (*http.Response, error) {
n++
if n < 3 {
// Такое заглушкой-сервером не изобразить: соединения нет вообще.
return nil, errors.New("dial tcp 10.0.0.7:443: connect: connection refused")
}
return &http.Response{StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"tx-7"}`))}, nil
})
c := paygate.New("https://api.paygate.example", "tok", &http.Client{Transport: rt}, det())
tx, err := c.Charge(t.Context(), 7, 500, "key-1") // tx=tx-7, попыток=3, err=<nil>
RoundTripper берут, когда нужно смоделировать то, чего сервер изобразить не может:
отказ в соединении, обрыв на середине тела, ошибку TLS, - и когда клиент не даёт подменить
базовый URL. Сервер берут во всех остальных
случаях, потому что он проверяет и то, как клиент собирает запрос: если поменять
r.URL.Path на неверный, заглушка это увидит, а RoundTripper, который смотрит
только на счётчик, - нет.
Запись и воспроизведение настоящих ответов
У заглушки на httptest.Server есть встроенный срок годности: тело ответа
в ней написал ты, а не чужой сервис. Через полгода шлюз добавит поле, переименует статус
или начнёт отдавать amount строкой - твоя заглушка будет отвечать
по-старому, и тесты останутся зелёными ровно до выкатки.
Лечится это тем же приёмом, что и golden-файлы из главы 5.1, только записывают не свой
вывод, а чужой ответ. Транспорт с двумя режимами: с флагом -record он ходит
в настоящий API и складывает ответ целиком в testdata/, без флага - читает
оттуда.
func (c cassette) RoundTrip(r *http.Request) (*http.Response, error) {
path := c.file(r)
if *record {
resp, err := c.real.RoundTrip(r) // настоящий транспорт
if err != nil {
return nil, err
}
dump, err := httputil.DumpResponse(resp, true) // true = вместе с телом
if err != nil {
return nil, err
}
if err := os.WriteFile(path, dump, 0o644); err != nil {
return nil, err
}
return http.ReadResponse(bufio.NewReader(bytes.NewReader(dump)), r)
}
dump, err := os.ReadFile(path) // сети не касаемся
if err != nil {
c.t.Fatalf("записи нет, перепиши прогоном с -record: %v", err)
}
return http.ReadResponse(bufio.NewReader(bytes.NewReader(dump)), r)
}
Платёжный шлюз в книгу не положишь, поэтому пример снят с настоящего чужого API, до которого дотянется любой читатель, - прокси-сервера модулей Go. Механика от этого не меняется:
$ go test ./modproxy/ -v -run TestInfo -record # сходили в настоящий proxy.golang.org
=== RUN TestInfo
record_test.go:67: version=v1.36.12 time=2026-08-10T13:29:45Z
--- PASS: TestInfo (0.35s)
$ ls testdata/
GET_v1.36.12.info.http
$ go test ./modproxy/ -v -run TestInfo -count=1 # сети не касаемся
=== RUN TestInfo
record_test.go:67: version=v1.36.12 time=2026-08-10T13:29:45Z
--- PASS: TestInfo (0.00s)
В записанном файле лежит обычный HTTP-ответ с заголовками, его видно в ревью и в
git diff. Три правила, без которых приём разваливается. Записи
перезаписывают по расписанию, иначе они гниют ровно как заглушка, только
незаметнее. Секреты, токены и персональные данные из тел и заголовков
вычищают при записи, а не «потом». И записи не заменяют тестов на сбои:
как чужой API отвечает пятисоткой, лучше по-прежнему моделировать заглушкой -
такое с прода не запишешь по заказу.
Консьюмер очереди: доставка дважды и идемпотентность
Брокеры, с которыми обычно работает бэкенд, дают at-least-once: сообщение дойдёт хотя бы раз, а сколько раз на самом деле, зависит от того, где порвётся. Самый частый разрыв: обработали сообщение, записали в базу, а под убили раньше, чем ушёл коммит offset. Новый под стартует с прежнего offset и приносит то же сообщение снова.
Для большей части этих тестов брокер не нужен вовсе: интерфейс консьюмера - это
Handle(ctx, []byte) error, и подавать в него сообщения можно руками.
Ошибка из Handle означает ровно одно: offset коммитить нельзя.
// Брокер доставляет at-least-once: одно и то же сообщение может прийти дважды.
m := msg(t, worker.PaidEvent{OrderID: 1, TxID: "tx-1", Key: "key-1"})
for range 3 {
if err := h.Handle(t.Context(), m); err != nil {
t.Fatalf("Handle: %v", err)
}
}
if len(st.Rows) != 1 { // получено=3 применено=1 дублей=2 строк=1
t.Fatalf("строк %d, хотим одну", len(st.Rows))
}
Дальше тест посильнее: идемпотентность проверяют не одним повтором, а как свойство - любая последовательность доставок даёт одно и то же состояние. Четыре строки данных вместо четырёх тестов:
$ go test ./worker/ -v
=== RUN TestHandle_IdempotentUnderAnyDelivery
worker_test.go:70: доставка [0 1 2] -> строк 3, применено 3, дублей 0
worker_test.go:70: доставка [0 0 1 1 2 2] -> строк 3, применено 3, дублей 3
worker_test.go:70: доставка [2 1 0 2 1 0] -> строк 3, применено 3, дублей 3
worker_test.go:70: доставка [0 1 0 2 1 2 0] -> строк 3, применено 3, дублей 4
--- PASS: TestHandle_IdempotentUnderAnyDelivery (0.00s)
=== RUN TestHandle_Poison
worker_test.go:83: err = worker: сообщение не разобрать: {"order_id": "не число"}: json: cannot unmarshal string into Go struct field PaidEvent.order_id of type int64
--- PASS: TestHandle_Poison (0.00s)
=== RUN TestHandle_StoreDown
worker_test.go:96: err = применить событие по заказу 1: connection reset by peer, ErrPoison? false
worker_test.go:106: получено=2 применено=1 ошибок=1 строк=1
--- PASS: TestHandle_StoreDown (0.00s)
=== RUN TestHandle_HeadOfLineBlocking
worker_test.go:134: проходов=5 offset=1 применено=1 строк=1
--- PASS: TestHandle_HeadOfLineBlocking (0.00s)
Последние три теста про разные виды «не получилось», и различать их обязательно.
TestHandle_Poison - сообщение, которое не станет лучше от повтора: его
надо увести в DLQ и сдвинуть offset. TestHandle_StoreDown - временный сбой,
повторять нужно, и тест доводит историю до конца: база вернулась, то же сообщение прошло.
Ошибки эти должны различаться типом, а не текстом, иначе консьюмер либо вечно
крутит битое сообщение, либо выбрасывает нормальное.
Третий, TestHandle_HeadOfLineBlocking, моделирует цикл консьюмера целиком:
пять проходов, а offset так и остался на единице. Одно сообщение, которое не удаётся
обработать, держит всю партицию: третье сообщение не применится, пока кто-то
не разберётся со вторым. На проде это видно по растущему lag при живом и зелёном консьюмере.
Настоящая Kafka в контейнере
Фейк не проверяет того, ради чего брокер и берут: групп, offset, партиций, ребалансировки.
Поэтому один-два теста пишут на настоящей Kafka под тегом integration.
Модуль testcontainers-go/modules/kafka тут не подойдёт: на v0.44.0 он ждёт
конфлюэнтовский образ вроде confluentinc/confluent-local и подкладывает
в контейнер стартовый скрипт, который зовёт /etc/confluent/docker/*.
С официальным apache/kafka контейнер падает с кодом 127. Поэтому
GenericContainer и свои переменные окружения.
Брокер сам сообщает клиенту свой адрес в ответе на запрос метаданных, и адрес этот
берётся из KAFKA_ADVERTISED_LISTENERS, то есть должен быть известен до старта
контейнера. Обычный приём testcontainers - спросить MappedPort после старта -
здесь не работает: порт станет известен, когда брокер уже объявил не тот адрес, и клиент
будет ходить в никуда с таймаутом. Обходят это либо стартовым скриптом, который ждёт файл
с настоящим портом (так сделан модуль kafka), либо простым способом: занять свободный порт
заранее, тут же отпустить и привязать его руками через HostConfigModifier.
// port получен через net.Listen("tcp", "127.0.0.1:0") и тут же отпущен,
// addr = "127.0.0.1:" + strconv.Itoa(port)
req := testcontainers.ContainerRequest{
Image: "apache/kafka:4.1.0",
Name: "dv-c4-kafka",
ExposedPorts: []string{"9092/tcp"},
HostConfigModifier: func(hc *container.HostConfig) {
hc.PortBindings = network.PortMap{
network.MustParsePort("9092/tcp"): []network.PortBinding{
{HostIP: netip.MustParseAddr("127.0.0.1"), HostPort: strconv.Itoa(port)},
},
}
},
Env: map[string]string{
"KAFKA_ADVERTISED_LISTENERS": "PLAINTEXT://" + addr + ",BROKER://localhost:9094",
// ... остальные девять переменных KRaft-конфигурации
},
// Ждём строку в логе, а не порт: порт брокер открывает раньше, чем готов.
WaitingFor: wait.ForLog("Kafka Server started").WithStartupTimeout(2 * time.Minute),
}
Дальше тест повторяет сценарий со схемы: два сообщения обработаны, offset не закоммичен, новый консьюмер той же группы стартует с прежнего места.
$ go test -tags=integration ./worker/ -v -run TestAtLeastOnce
kafka на [127.0.0.1:65214]
=== RUN TestAtLeastOnce_RedeliveryAfterCrash
kafka_integration_test.go:173: первый проход: прочитано=2 применено=2 строк=2
kafka_integration_test.go:177: второй проход: прочитано=2 получено всего=4 применено=2 дублей=2 строк=2
--- PASS: TestAtLeastOnce_RedeliveryAfterCrash (0.93s)
PASS
ok example.com/shop/worker 4.148s
Прочитано четыре раза, применено два, строк две. Это и есть проверенная идемпотентность, а не обещанная. Разница в цене видна тут же: те же тесты без тега идут 0,5 с, с тегом и брокером - 4,2 с, и почти всё это время занимает старт контейнера. Отсюда обычная раскладка: десяток быстрых тестов на фейке гоняются на каждый MR, один-два на настоящей Kafka - там же, где остальные интеграционные.
Тест сервиса целиком
Всё, что выше, проверяет по одной границе за раз. Остаётся вопрос, которого ни один такой тест не касается: собирается ли это вместе. Конфиг читается из окружения, пул открывается, миграции накатываются, роутер знает свои пути, сервис слушает выданный адрес и выключается по сигналу. Способ один - запустить тот же бинарь, который уедет в образ.
func TestMain(m *testing.M) {
// 1. Собираем тот же бинарь, что уезжает в образ.
build := exec.Command("go", "build", "-o", binPath, "./cmd/api")
build.Dir = ".."
// 2. Поднимаем зависимости, которые есть в проде.
pg, err := tcpostgres.Run(ctx, "postgres:18", /* ... */)
dsn, err = pg.ConnectionString(ctx, "sslmode=disable")
...
}
func startService(t *testing.T, paygateURL string, env ...string) *service {
cmd := exec.Command(binPath)
cmd.Env = append(os.Environ(),
"APP_HTTP_ADDR="+addr, "APP_DB_DSN="+dsn, "APP_PAYGATE_URL="+paygateURL)
cmd.Stdout, cmd.Stderr = out, out
_ = cmd.Start()
t.Cleanup(func() { _ = cmd.Process.Signal(syscall.SIGTERM); _ = cmd.Wait() })
// Готовность спрашиваем у самого сервиса, а не спим.
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if resp, err := http.Get("http://" + addr + "/readyz"); err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return s
}
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("сервис не стал готов за 30 с, лог:\n%s", out.String())
}
Чужой платёжный шлюз при этом остаётся заглушкой на httptest.Server -
и это не компромисс, а граница теста: своя база настоящая, свой код настоящий, чужие
сервисы подделаны. Иначе тест начнёт падать от чужих релизов и чужих сетей.
$ go test -tags=e2e ./e2e/ -v
=== RUN TestScenario_PayOrder
e2e_test.go:137: оплата: {"tx_id":"tx-e2e"}
e2e_test.go:145: заказ после оплаты: {"id":101,"user_id":7,"amount":500,"status":"paid","tx_id":"tx-e2e"}
--- PASS: TestScenario_PayOrder (0.38s)
=== RUN TestScenario_PaygateDown
e2e_test.go:164: ответ сервиса: шлюз вернул 500
e2e_test.go:175: заказ остался: {"id":102,"user_id":7,"amount":500,"status":"new"}
--- PASS: TestScenario_PaygateDown (0.06s)
=== RUN TestConfig_FailFast
e2e_test.go:237: exit=exit status 1, вывод: time=2026-09-20T23:44:15.584+03:00 level=ERROR msg=конфиг err="не задано: APP_DB_DSN, APP_PAYGATE_URL"
--- PASS: TestConfig_FailFast (0.01s)
Третий тест обычно забывают. Сервис с неполным конфигом обязан упасть на старте с читаемым сообщением, а не подняться и отвалиться через час на первом запросе к базе. Проверяется это дёшево: запустить бинарь без половины переменных и посмотреть на код возврата.
И последнее, ради чего этот тест держат: выключение. В Kubernetes под получает SIGTERM, а трафик к нему идёт, пока управляющий слой не уберёт его из эндпоинтов и пока это не доедет до узлов. Между этими событиями секунды. Сравним два поведения на одном и том же коде:
$ go test -tags=e2e ./e2e/ -v -run TestGracefulShutdown
=== RUN TestGracefulShutdown/без_паузы_перед_Shutdown
e2e_test.go:224: readyz во время выключения: Get "http://127.0.0.1:65132/readyz": dial tcp 127.0.0.1:65132: connect: connection refused
e2e_test.go:229: запрос в полёте завершился: code=200 err=<nil>
=== RUN TestGracefulShutdown/с_паузой_2s
e2e_test.go:222: readyz во время выключения: 503 "shutting down"
e2e_test.go:229: запрос в полёте завершился: code=200 err=<nil>
--- PASS: TestGracefulShutdown (3.04s)
В обоих прогонах запрос, который был в полёте, доработал и вернул 200: Shutdown
честно ждёт активные соединения. Разница в другом. Без паузы проба
/readyz не получает 503 - она вообще не может подключиться, потому что
Shutdown закрывает слушателя первым же делом. Для kubelet это не «под
не готов», а отказ пробы, и всё это время балансировщик ещё шлёт новые запросы в
закрытый порт. Порядок должен быть такой: снять readiness, подождать
(пауза в коде или preStop в чарте, обычно несколько секунд), и только потом
звать Shutdown. Проверяется это ровно тем же тестом, двумя строками разницы.
Сколько таких тестов держать: по одному на сценарий, который нельзя проверить ниже. Оплата заказа целиком, отказ внешнего шлюза, выключение, неполный конфиг - четыре штуки, и этого обычно хватает. Каждый следующий добавляет минуты к прогону и новую причину мигания: контейнер стартовал медленнее обычного, порт не освободился, чужой сервис ответил на сто миллисекунд позже. Мигать они начинают первыми не потому, что написаны плохо, а потому, что у них больше всего движущихся частей. Держи их мало, давай им щедрые таймауты и никогда не проверяй ими ветки бизнес-логики: для них есть юнит-тест, который идёт пять миллисекунд.
Вопросы
5bufconn - это net.Listener поверх буфера
в памяти. Сервер и клиент поднимаются настоящие, вызов проходит все слои gRPC, кроме транспорта:
ни порта, ни TLS, ни сети. Подменяется только транспорт, поэтому всё, что живёт выше, проверяется
честно, а всё, что ниже, не проверяется совсем.Как это выглядит
lis := bufconn.Listen(1024*1024), обычный grpc.NewServer() с
настоящими интерцепторами, s.Serve(lis) в горутине. Клиент получает диалер,
который вместо net.Dial зовёт lis.DialContext. Уборка через
t.Cleanup: закрыть соединение, остановить сервер, закрыть слушателя.
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
Схема passthrough:/// обязательна: у grpc.NewClient резолвер по
умолчанию - dns, и без неё клиент уходит искать хост bufnet, а
диалер не вызывается. Тест при этом не падает, а висит до дедлайна.
Что проверяется, а что нет
- Проверяется честно: кодирование protobuf, метаданные, интерцепторы и их порядок,
коды статуса, дедлайны (
grpc-timeoutдоезжает), стримы, лимитMaxRecvMsgSize- он даёт тот жеResourceExhausted, что и в бою. - Не проверяется: TLS и клиентские сертификаты, keepalive, обрыв соединения,
занятый порт, resolver и балансировка. И адрес клиента:
peer.Addrна bufconn - строкаbufconn, а не127.0.0.1:65170, так что код, который парсит адрес, в тесте идёт другой веткой.
Отсюда рабочая раскладка: почти все тесты gRPC на bufconn, плюс один тест на настоящем
net.Listen("tcp", "127.0.0.1:0") - там, где проверяется конфигурация TLS и то,
что сервис вообще поднимается.
Сказать, зачем это нужно помимо скорости (на моей машине 22 мкс против 46 мкс на вызов):
bufconn не занимает порт. Пакеты с интеграционными тестами go test ./...
гоняет параллельно, и десяток серверов на фиксированных портах в CI обязательно подерётся.
И упомянуть, что grpc.DialContext устарел, теперь grpc.NewClient.
buf breaking в CI) и тестом со стороны потребителя (контрактный тест). Первая
ловит форму, вторая - смысл.Почему тест провайдера не считается
Он написан командой провайдера и знает только про то, что эта команда считает контрактом. Про поле, которое потребитель читает, про статус, который он различает, и про клиент, собранный три месяца назад, тест не знает ничего. Плюс выкатка: сервисы уезжают по очереди, а при rolling deploy рядом какое-то время живут обе версии одного сервиса. Значит, совместимость нужна в обе стороны - новый сервер со старым клиентом и наоборот.
Что ставят в CI
buf breaking --against '.git#branch=main'- отдельная быстрая стадия, ни базы, ни докера. Ловит переименование поля, смену типа, смену номера, удаление метода; на находке возвращает 100 и роняет джобу.- Тест совместимости схемы события: собрать сообщение новой схемой, разобрать старой. Полсотни строк, а результат неочевидный - в бинарном protobuf контракт держится на номерах полей и старый код прочитает всё, а в JSON контракт держится на именах и разбор упадёт.
- Контрактный тест: потребитель записывает ожидания (какие запросы шлёт и какие поля ответа читает), провайдер проигрывает эту запись по своему настоящему хендлеру. Это и есть идея Pact, и она реализуется на пятидесяти строках без всякой библиотеки.
Чего buf breaking не видит
Только форму, и это надо сказать самому, не дожидаясь подвопроса. Невидимы:
новое поле, которое сервер начал требовать; значение строкового статуса, поменявшееся
с paid на PAID; поле, которое раньше всегда заполнялось;
ужесточившееся правило валидации. Всё это проходит проверку схемы с кодом 0.
Потребитель событий, который разбирает JSON с DiscardUnknown: true «чтобы
не падало». Продюсер переименовал amount в total_amount,
строгий разбор дал бы unknown field "totalAmount", а мягкий тихо кладёт
amount=0. Сервис зелёный, метрики зелёные, суммы нулевые. Поля добавляют,
а не переименовывают; удаляют через reserved; тип не меняют никогда.
httptest.Server, либо своим http.RoundTripper.Обязательный список
- Таймаут на запрос. Тест меряет, что ошибка пришла за отведённое время, и что это
именно таймаут (
net.Error.Timeout()), а не «что-то сломалось». - Отмена по контексту. В цепочке должен лежать
context.Canceled, и повторов быть не должно: наверху уже сдались. - Ретраи только для идемпотентных вызовов, с backoff и джиттером, и обязательно
проверка, что ключ идемпотентности не менялся между попытками. Заглушка копит
заголовки, тест сравнивает:
ключи=[key-1 key-1 key-1]. - Отказ, который повторять нельзя (400, 409): ровно одна попытка.
- Не-JSON вместо JSON. Прокси отдал страницу nginx - в ошибке должны быть тип
контента и начало тела, иначе дежурный ночью увидит только
invalid character '<'. - 200 без нужных полей. Тело
{}обязано стать ошибкой, а не успехом с пустым идентификатором транзакции.
RoundTripper или заглушка-сервер
httptest.NewServer | подмена RoundTripper | |
|---|---|---|
| Что прогоняется | весь net/http: URL, заголовки, keep-alive | только код клиента выше транспорта |
| Проверяет сборку запроса | да, заглушка видит путь и тело | только если сам это проверишь |
| Отказ в соединении, обрыв, ошибка TLS | изобразить трудно | одна строка return nil, err |
| Клиент не даёт подменить URL | не поможет | работает |
Чем плоха заглушка через полгода
Тело ответа в ней написал ты, а не чужой сервис. Шлюз добавит поле или начнёт отдавать сумму
строкой - заглушка ответит по-старому, и тесты будут зелёными до самой выкатки. Лечится
записью настоящих ответов: транспорт с флагом -record ходит в живой API и
складывает ответ целиком (httputil.DumpResponse) в testdata/,
без флага читает оттуда. Дальше три правила: записи перезаписывать по расписанию, секреты
вычищать при записи, а сбои (500, 429, обрыв) по-прежнему моделировать заглушкой - такое
с прода по заказу не снимешь.
Почему дубли вообще приходят
Брокер даёт at-least-once. Классический разрыв: обработали сообщение, записали в базу, а под убили раньше, чем ушёл коммит offset. Новый под стартует с прежнего offset и приносит то же самое. Сюда же ребалансировка группы, повтор продюсера по таймауту, перезапуск при выкатке.
Как это выглядит тестом
Интерфейс консьюмера - Handle(ctx, []byte) error, и брокер для большинства тестов
не нужен. Сильная форма проверки - не один повтор, а свойство: любая последовательность
доставок даёт одно состояние.
доставка [0 1 2] -> строк 3, применено 3, дублей 0
доставка [0 0 1 1 2 2] -> строк 3, применено 3, дублей 3
доставка [2 1 0 2 1 0] -> строк 3, применено 3, дублей 3
доставка [0 1 0 2 1 2 0] -> строк 3, применено 3, дублей 4
Плюс один-два теста на настоящем брокере под тегом integration: обработать
сообщения, не закоммитить offset, поднять второго консьюмера той же группы. На Kafka 4.1.0
это даёт «получено всего=4, применено=2, дублей=2, строк=2» - идемпотентность проверенная,
а не обещанная. Цена: 0,5 с на фейке против 4,2 с с контейнером.
Застрявшее сообщение
- Битое (не разбирается, нет обязательного поля) от повторов лучше не станет: отдельный тип ошибки, отправка в DLQ, offset сдвигаем.
- Временный сбой (база отвалилась, дедлок) - ошибка повторяемая, offset не двигаем. Тест доводит историю до конца: база вернулась, то же сообщение прошло.
- Различать их надо типом, а не текстом, иначе консьюмер либо вечно крутит битое сообщение, либо выбрасывает нормальное.
Порядок внутри партиции - это и есть гарантия Kafka, а значит, пока offset не сдвинут,
следующие сообщения не обрабатываются. Тест на пять проходов цикла показывает
offset=1 при трёх сообщениях: второе не прошло, и третье не получит шанса.
На проде это выглядит как растущий lag при живом и зелёном консьюмере, без единой записи
в логе о падении.
Как он устроен
go build -o bin ./cmd/apiвTestMain: именно бинарь, а не вызов функции, иначе тест проверяет свою сборку графа, а не продовую.- Свои зависимости - настоящие (база в контейнере). Чужие сервисы остаются заглушкой
на
httptest.Server: иначе тест начнёт падать от чужих релизов. - Порт берём свободный (
net.Listenна:0и сразу закрыть), передаём через окружение. - Готовность спрашиваем у самого сервиса - опрос
/readyzс общим дедлайном, а неtime.Sleep(3s). - Четыре сценария, которые окупаются почти в любом сервисе: happy path целиком, отказ внешней зависимости (заказ не должен стать оплаченным), выключение по SIGTERM, неполный конфиг.
Неполный конфиг и выключение
Самый дешёвый из четырёх - конфиг: запустить бинарь без половины переменных и убедиться, что
он вышел с ненулевым кодом и читаемым сообщением вроде
не задано: APP_DB_DSN, APP_PAYGATE_URL, а не поднялся и отвалился через час.
Выключение проверяют так: держать запрос в полёте, послать SIGTERM, убедиться, что запрос
дожил и вернул 200, а процесс вышел с нулевым кодом.
http.Server.Shutdown закрывает слушателя сразу и только потом ждёт
активные соединения. Поэтому «снял readiness и позвал Shutdown» не работает: проба
/readyz получает не 503, а отказ в соединении, и всё это время балансировщик
ещё шлёт новые запросы в закрытый порт. Правильный порядок - снять readiness, подождать
несколько секунд (пауза в коде или preStop в чарте) и только потом звать
Shutdown. Разница видна тем же тестом: без паузы
connect: connection refused, с паузой 503 "shutting down".
Почему они мигают
У них больше всего движущихся частей: контейнер стартовал медленнее обычного, порт не освободился, заглушка ответила на сто миллисекунд позже, параллельный пакет съел все соединения к базе. Ни одна из причин не связана с кодом, который тест проверяет. Отсюда практика: держать таких тестов единицы, давать им щедрые таймауты, не проверять ими ветки бизнес-логики и выносить в отдельную джобу - иначе команда начнёт перезапускать пайплайн не глядя, а это дороже, чем отсутствие теста.
Что стоит запомнить
- bufconn прогоняет grpc codec и interceptors без сетевого порта.
- Контракт должен выдерживать старую и новую версии рядом.
- Повтор доставки требует идемпотентного consumer.
Частые ошибки
- Прямой handler вызов пропускает transport semantics.
- Успешный client call без проверки status/metadata неполон.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Разбери проверку
Какие слои gRPC проходит этот bufconn test и какие ресурсы/ошибки он пока не обрабатывает?
func TestCreate(t *testing.T) {
lis := bufconn.Listen(1024 * 1024)
srv := grpc.NewServer()
RegisterItemsServer(srv, implementation)
go srv.Serve(lis)
conn, _ := grpc.DialContext(ctx, "bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return lis.Dial() }))
_ = conn
}
Ответ и разбор
Сам по себе bufconn заменяет сетевой listener in-process соединением. Но этот тест ещё не проверяет RPC: он не вызывает метод клиента. Кроме того, у DialContext нет transport credentials, поэтому gRPC возвращает ошибку настройки безопасности, которую код игнорирует. Для локального bufconn явно добавь grpc.WithTransportCredentials(insecure.NewCredentials()), проверь ошибку подключения, вызови RPC и проверь ответ/status. Тогда выполняются protobuf codec и настроенные interceptors; физическая сеть всё равно не тестируется. Заверши connection, server и listener через cleanup с ограниченным временем.
Код-ревью
2. Найди дефект и исправь
Тест вызывает service implementation напрямую, поэтому не проверяет RPC transport. Если контракт включает protobuf codec, status code и interceptors, что заменить?
func TestCreate(t *testing.T) {
resp, err := implementation.Create(ctx, req)
if err != nil { t.Fatal(err) }
if resp.ID == "" { t.Fatal("missing id") }
}
Ответ и разбор
Подними настоящий gRPC server и вызови его через client поверх bufconn. Проверь response и статус, а при контракте metadata/interceptors проверь и их. Прямой вызов можно оставить отдельным unit test доменной логики, но он не доказывает корректность транспортного пути.
Применение
3. Реши сценарий
Старый consumer читает name, новый producer начинает передавать region, а следующая версия consumer уже требует это поле. Как проверить безопасный rollout?
Ответ и разбор
Сначала выкатай consumer, который понимает region, но продолжает работать без него. Затем producer начинает передавать region; только когда старые consumers выведены, новая consumer logic может требовать его. Контрактный тест проверь для старого consumer с новым payload и нового consumer без region.
Применение
4. Проверь альтернативу
Очередь доставляет сообщение повторно после timeout, хотя первая обработка уже записала результат в БД. Какой контракт нужен consumer и что проверить интеграционно?
Ответ и разбор
Запись результата и отметка idempotency/message key должны фиксироваться атомарно. Повтор с тем же key возвращает прежний результат и не применяет эффект второй раз. Интеграционный тест отправляет один ключ дважды (в том числе после simulated timeout/restart) и проверяет ровно один бизнес-эффект.