voidlearn
 

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().

Один и тот же вызов GetOrder: где путь общий, а где подменён транспорт Вызов c.GetOrder(ctx, req) Кодек: proto.Marshal, сжатие Клиентские интерцепторы, метаданные HTTP/2: HEADERS + DATA, grpc-timeout bufconn буфер в памяти на 1 МБ ни порта, ни TLS, ни обрывов около 22 мкс на вызов настоящий слушающий сокет TCP на 127.0.0.1, при желании TLS порт, keepalive, буферы ядра около 46 мкс на вызов HTTP/2 на сервере: разбор фреймов Интерцепторы: auth, валидация, коды Хендлер GetOrder Одинаково: кодек, интерцепторы, метаданные, дедлайны, коды статуса, лимит размера сообщения. Подменено: TLS и клиентские сертификаты, адрес клиента, обрыв связи, занятый порт, resolver и балансировка.
Что bufconn проверяет, а что подменяет. Вызов проходит все слои gRPC, кроме транспорта: в середине вместо сокета лежит буфер в памяти. Отсюда и выигрыш в скорости, и список того, что такой тест увидеть не может.

Список подменённого не абстрактный, его видно прогоном. Хендлер спрашивает адрес клиента через 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 и настоящим сокетом

На 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)
Клиент видит DeadlineExceeded, а сервер - context canceled

Дедлайн до сервера доезжает: 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
Что именно проверяет buf breaking

Только форму: номера и типы полей, имена, наличие методов, совместимость сгенерированного кода. Всё, что живёт в смысле, для него невидимо: новое поле, ставшее обязательным по факту; значение строкового статуса, поменявшееся с 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 вместо JSONHTML-страница от проксивнятная ошибка с типом контента и началом тела
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 и приносит то же сообщение снова.

Одно сообщение, две доставки: что решает ключ идемпотентности 1. Продюсер событие order.paid легло в offset 7 2. Консьюмер обработал Handle(msg) прошёл, строка в базе появилась 3. Под убит коммит offset не ушёл, в группе всё ещё 7 4. Новый под читает с offset 7: то же сообщение снова Обработчик без ключа INSERT INTO payments (order_id, tx_id) VALUES (1, 'tx-1'); Вторая доставка вставляет вторую строку. Две записи об одном платеже, сверка с банком не сходится, деньги «списаны» дважды. Обработчик с ключом INSERT INTO payments (key, order_id, tx_id) VALUES (...) ON CONFLICT (key) DO NOTHING; Вторая доставка ничего не меняет. applied = false, состояние то же самое, сколько бы раз сообщение ни пришло. Как это выглядит тестом Подать обработчику одно и то же сообщение несколько раз и сравнить состояние, а не число вызовов. На фейке в памяти, одно событие: получено=3 применено=1 дублей=2 строк=1. На настоящей Kafka, два события и падение до коммита: получено=4 применено=2 дублей=2 строк=2. Первый прогон идёт 0,5 с и не требует докера, второй - 4,2 с вместе со стартом брокера.
Повторная доставка и её цена. Между «под убит» и «новый под читает с того же 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 нельзя дать случайный порт

Брокер сам сообщает клиенту свой адрес в ответе на запрос метаданных, и адрес этот берётся из 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)
http.Server.Shutdown закрывает слушателя сразу

В обоих прогонах запрос, который был в полёте, доработал и вернул 200: Shutdown честно ждёт активные соединения. Разница в другом. Без паузы проба /readyz не получает 503 - она вообще не может подключиться, потому что Shutdown закрывает слушателя первым же делом. Для kubelet это не «под не готов», а отказ пробы, и всё это время балансировщик ещё шлёт новые запросы в закрытый порт. Порядок должен быть такой: снять readiness, подождать (пауза в коде или preStop в чарте, обычно несколько секунд), и только потом звать Shutdown. Проверяется это ровно тем же тестом, двумя строками разницы.

Сколько таких тестов держать: по одному на сценарий, который нельзя проверить ниже. Оплата заказа целиком, отказ внешнего шлюза, выключение, неполный конфиг - четыре штуки, и этого обычно хватает. Каждый следующий добавляет минуты к прогону и новую причину мигания: контейнер стартовал медленнее обычного, порт не освободился, чужой сервис ответил на сто миллисекунд позже. Мигать они начинают первыми не потому, что написаны плохо, а потому, что у них больше всего движущихся частей. Держи их мало, давай им щедрые таймауты и никогда не проверяй ими ветки бизнес-логики: для них есть юнит-тест, который идёт пять миллисекунд.

Вопросы

5
Суть: bufconn - это 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; тип не меняют никогда.

Суть: проверяют не разбор успешного ответа, а поведение при всех способах чужой стороны сломаться: таймаут, отмена по контексту, ретрай только идемпотентного вызова с неизменным ключом, отказ без ретрая, не-JSON вместо JSON, 200 без нужных полей. Подменяют либо заглушкой на 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, обрыв) по-прежнему моделировать заглушкой - такое с прода по заказу не снимешь.

Суть: идемпотентность проверяется сравнением состояния, а не числа вызовов: подать обработчику одно и то же сообщение несколько раз и убедиться, что строк столько же, сколько после одной доставки. Застрявшее сообщение различают по типу ошибки: битое уходит в DLQ, временный сбой повторяется.

Почему дубли вообще приходят

Брокер даёт 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 при живом и зелёном консьюмере, без единой записи в логе о падении.

Суть: тест собирает тот же бинарь, что уезжает в образ, запускает его с конфигом из окружения рядом с настоящими зависимостями и гоняет сценарий по HTTP. Он один закрывает то, чего не видит ни один тест ниже: сборку графа, чтение конфига, миграции, роутинг и выключение по сигналу. Держат их единицы.

Как он устроен

  • 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, а процесс вышел с нулевым кодом.

Про graceful shutdown спрашивают именно это

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) и проверяет ровно один бизнес-эффект.