Authors unit tests for gRPC interceptor logic: Go grpc.UnaryServerInterceptor/UnaryClientInterceptor, Java ServerInterceptor/ClientInterceptor, and grpc-js client interceptors. Covers auth (Unauthenticated on bad token), retry (backoff on Unavailable), logging/tracing (metadata extraction + propagation), error-mapping (status translation), and chained interceptor ordering - by calling the interceptor directly with a spy handler, no live backend. Use when a gRPC interceptor is written or modified. Different test surface from grpc-streaming-test-author (multi-message stream sequences) and grpc-mock (service handler logic) - use those, not this, for streams or handlers.
74
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
All Go examples construct the interceptor function directly and invoke it with a
spy handler (the next leg). No live backend. Always assert on status.Code(),
never on error message strings (message text is not part of the gRPC contract).
Type signatures per pkg.go.dev/google.golang.org/grpc#UnaryServerInterceptor:
type UnaryServerInterceptor func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error)Test pattern: pass a ctx with missing/bad authorization metadata
and verify the interceptor returns codes.Unauthenticated (code 16 per
pkg.go.dev/google.golang.org/grpc/codes)
without calling the handler.
package auth_test
import (
"context"
"testing"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// authInterceptor returns Unauthenticated when "authorization" header is absent.
func authInterceptor(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
// metadata.FromIncomingContext docs: all keys are lowercase.
if !ok || len(md.Get("authorization")) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization header")
}
return handler(ctx, req)
}
func TestAuthInterceptor_MissingToken_ReturnsUnauthenticated(t *testing.T) {
handlerCalled := false
spy := func(ctx context.Context, req any) (any, error) {
handlerCalled = true
return "ok", nil
}
ctx := context.Background() // no metadata attached
_, err := authInterceptor(ctx, nil, nil, spy)
if handlerCalled {
t.Fatal("handler must not be called when token is absent")
}
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Fatalf("got %v, want Unauthenticated", st.Code())
}
}
func TestAuthInterceptor_ValidToken_CallsHandler(t *testing.T) {
handlerCalled := false
spy := func(ctx context.Context, req any) (any, error) {
handlerCalled = true
return "ok", nil
}
md := metadata.Pairs("authorization", "Bearer valid-token")
ctx := metadata.NewIncomingContext(context.Background(), md)
_, err := authInterceptor(ctx, nil, nil, spy)
if err != nil {
t.Fatal(err)
}
if !handlerCalled {
t.Fatal("handler must be called for valid token")
}
}codes.Unavailable (code 14) is the canonical "transient, retry"
signal per
pkg.go.dev/google.golang.org/grpc/codes.
A retry interceptor wraps a grpc.UnaryClientInterceptor:
type UnaryClientInterceptor func(
ctx context.Context,
method string,
req, reply any,
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) errorTest: count how many times invoker is called and confirm backoff
delays using a fake clock.
func TestRetryInterceptor_RetriesOnUnavailable(t *testing.T) {
callCount := 0
invoker := func(ctx context.Context, method string, req, reply any,
cc *grpc.ClientConn, opts ...grpc.CallOption) error {
callCount++
if callCount < 3 {
return status.Error(codes.Unavailable, "overloaded")
}
return nil
}
interceptor := retryInterceptor(maxRetries(3), noSleep()) // inject fake sleep
err := interceptor(context.Background(), "/svc/Method", nil, nil, nil, invoker)
if err != nil {
t.Fatalf("expected success after retries, got %v", err)
}
if callCount != 3 {
t.Fatalf("expected 3 invocations, got %d", callCount)
}
}
func TestRetryInterceptor_DoesNotRetryPermissionDenied(t *testing.T) {
callCount := 0
invoker := func(_ context.Context, _ string, _, _ any,
_ *grpc.ClientConn, _ ...grpc.CallOption) error {
callCount++
return status.Error(codes.PermissionDenied, "denied")
}
interceptor := retryInterceptor(maxRetries(3), noSleep())
err := interceptor(context.Background(), "/svc/Method", nil, nil, nil, invoker)
st, _ := status.FromError(err)
if st.Code() != codes.PermissionDenied {
t.Fatalf("got %v, want PermissionDenied", st.Code())
}
if callCount != 1 {
t.Fatalf("must not retry on PermissionDenied, got %d calls", callCount)
}
}The noSleep() option injects a no-op sleep function to keep tests
fast. Never use time.Sleep inside interceptor tests.
Per
pkg.go.dev/google.golang.org/grpc/metadata#FromIncomingContext,
metadata keys are always lowercase. A logging interceptor reads
x-trace-id and x-request-id from incoming metadata and attaches
them to the logger context.
func TestLoggingInterceptor_PropagatesTraceID(t *testing.T) {
var capturedTraceID string
spy := func(ctx context.Context, req any) (any, error) {
// The interceptor must enrich ctx with trace ID before calling handler.
capturedTraceID = traceIDFromContext(ctx) // your helper
return "ok", nil
}
md := metadata.Pairs("x-trace-id", "trace-abc-123")
ctx := metadata.NewIncomingContext(context.Background(), md)
_, err := loggingInterceptor(ctx, nil, nil, spy)
if err != nil {
t.Fatal(err)
}
if capturedTraceID != "trace-abc-123" {
t.Fatalf("trace ID not propagated: got %q", capturedTraceID)
}
}For client-side propagation use
metadata.AppendToOutgoingContext per
pkg.go.dev/google.golang.org/grpc/metadata#AppendToOutgoingContext:
ctx = metadata.AppendToOutgoingContext(ctx, "x-trace-id", traceID)Per
pkg.go.dev/google.golang.org/grpc#ChainUnaryInterceptor,
the first interceptor passed to grpc.ChainUnaryInterceptor is the
outermost (called first). Test ordering explicitly when auth must
run before logging:
func TestChainOrder_AuthBeforeLogging(t *testing.T) {
var callOrder []string
authInt := func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
callOrder = append(callOrder, "auth")
return handler(ctx, req)
}
logInt := func(ctx context.Context, req any, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (any, error) {
callOrder = append(callOrder, "log")
return handler(ctx, req)
}
// Build a chain and invoke it with a no-op handler.
chained := chainUnary(authInt, logInt) // your thin wrapper around ChainUnaryInterceptor
_, _ = chained(context.Background(), nil, nil,
func(ctx context.Context, req any) (any, error) { return nil, nil })
if callOrder[0] != "auth" || callOrder[1] != "log" {
t.Fatalf("wrong order: %v", callOrder)
}
}grpc.StreamServerInterceptor signature per
pkg.go.dev/google.golang.org/grpc#StreamServerInterceptor:
type StreamServerInterceptor func(
srv any,
ss grpc.ServerStream,
info *grpc.StreamServerInfo,
handler grpc.StreamHandler,
) errorTest using a fake grpc.ServerStream that captures the metadata
header sent before the first message:
type fakeStream struct {
grpc.ServerStream
ctx context.Context
headers metadata.MD
}
func (f *fakeStream) Context() context.Context { return f.ctx }
func (f *fakeStream) SendHeader(md metadata.MD) error {
f.headers = md
return nil
}
func TestStreamAuthInterceptor_MissingToken(t *testing.T) {
fs := &fakeStream{ctx: context.Background()} // no metadata
err := streamAuthInterceptor(nil, fs, nil, func(srv any, stream grpc.ServerStream) error {
t.Fatal("handler must not be called")
return nil
})
st, _ := status.FromError(err)
if st.Code() != codes.Unauthenticated {
t.Fatalf("got %v, want Unauthenticated", st.Code())
}
}