Testland
Browse all skills & agents

grpc-interceptor-test-author

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.

Install with skills.sh (any agent)

npx skills add testland/qa --skill grpc-interceptor-test-author
View source

grpc-interceptor-test-author

Overview

gRPC interceptors apply cross-cutting behavior (auth, retry, logging, error mapping) to every RPC without touching service logic. They are one of the primary sources of subtle gRPC bugs: silent metadata drops, wrong ordering in a chain, and retry storms that ignore backoff. This skill produces isolated unit tests for each interceptor behavior.

Per the gRPC interceptors guide (opens in new window), interceptors are "per-call" and are split into client-side and server-side variants, each further divided into unary and streaming forms.

Differentiation from sibling skills:

  • grpc-mock authors tests for service handler logic using an in-process server. This skill tests the interceptor layer itself, not the handler.
  • grpc-streaming-test-author covers multi-message stream sequences. This skill covers interceptors that wrap streams (e.g., a server stream interceptor that injects a header before the first message).

Interceptor taxonomy

VariantGo type (pkg.go.dev/google.golang.org/grpc)Java type (grpc-java javadoc)grpc-js
Server unarygrpc.UnaryServerInterceptorServerInterceptor.interceptCallN/A (server-only via grpc package)
Server streaminggrpc.StreamServerInterceptorServerInterceptor.interceptCallN/A
Client unarygrpc.UnaryClientInterceptorClientInterceptor.interceptCallInterceptorProvider option
Client streaminggrpc.StreamClientInterceptorClientInterceptor.interceptCallInterceptorProvider option

Full type signatures are at the reference links in each language playbook.

Authoring strategy: call the interceptor directly

The canonical test pattern for all languages is:

  1. Construct the interceptor function/object directly.
  2. Invoke it with a crafted context/metadata and a spy or stub handler (the next leg in the chain).
  3. Assert on: what the handler received, what status code was returned, and what metadata was set.

This avoids spinning up a full in-process server just to test cross-cutting logic. Use the in-process server from grpc-mock only when testing the interaction between an interceptor and a handler.

Language playbooks

Each playbook holds the full type signatures, test patterns, and runnable examples for one language surface:

How to use

  1. Identify the interceptor under test and its variant (server/client, unary/streaming) using the taxonomy table.
  2. Open the matching language playbook (Go, Java, or grpc-js).
  3. Construct the interceptor directly and craft the input context/metadata for the behavior under test (auth, retry, logging, error-mapping, ordering).
  4. Wire a spy/stub handler (the next leg) that records what it received and whether it was called at all.
  5. Invoke the interceptor and assert on status code, handler invocation count, and set/propagated metadata - never on error message strings.
  6. Add the negative case (e.g., a non-transient code must not be retried) and a fresh metadata map per test to prevent bleed.
  7. Run with the language's isolation flags (-race, -count=1) and wire into CI.

Worked example

Scenario: a Go client retry interceptor must retry transient failures but never retry an auth failure.

  1. Under test: retryInterceptor(maxRetries(3), noSleep()), a grpc.UnaryClientInterceptor.
  2. Craft a spy invoker that returns codes.Unavailable on the first two calls and nil on the third, incrementing callCount each time.
  3. Invoke the interceptor with context.Background() and the spy; assert err == nil and callCount == 3 - it retried twice, then succeeded.
  4. Add the negative case: a spy invoker that always returns codes.PermissionDenied; assert st.Code() == codes.PermissionDenied and callCount == 1 - a non-transient code is surfaced immediately, not retried.
  5. Run go test ./... -run TestRetry -race -count=1. Result: two passing tests proving backoff fires on Unavailable and is skipped on PermissionDenied, with no auth-storm regression.

Full code for both tests is in references/go-interceptors.md.

Running

These tests run as ordinary unit tests in each language:

go test ./... -run TestAuth -race   # Go: -race catches metadata races
mvn test -Dtest=AuthInterceptorTest  # Java / Maven
npx jest --testPathPattern=interceptor  # Node / Jest

Use -race in Go: concurrent ctx + metadata access in interceptors surfaces races that pass without the flag.

CI integration

jobs:
  interceptor-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-go@v5
        with: { go-version: stable }
      - run: go test ./... -race -count=1 -timeout=30s

-count=1 disables the test cache so metadata-mutation tests are not silently skipped on re-runs.

Anti-patterns

Anti-patternWhy it failsFix
Asserting on error message stringsText is not part of the gRPC contract; changes with i18nAssert on status.Code() only
Testing the interceptor only via an end-to-end callChain bugs and ordering issues are invisible when everything succeedsCall the interceptor function directly with a spy handler
Assuming intercept() and interceptForward() are identicalJava ServerInterceptors.intercept() applies interceptors in reverse order per the javadocUse interceptForward() when declaration order must match execution order
time.Sleep inside retry-interceptor testsSlow tests; sleep duration is arbitraryInject a fake sleep function via an option or dependency parameter
Sharing a single metadata map across test casesMap mutation bleeds between casesConstruct a fresh metadata.MD / Metadata per test
Not testing the "does not retry" case for non-transient codesRetry interceptors that retry PermissionDenied cause auth-storm bugsAdd explicit tests for codes.PermissionDenied and codes.InvalidArgument
Embedding real tokens in test metadataSecrets in source historyUse constant placeholder strings like "Bearer test-token-value"

Limitations

  • Does not cover wire-level fault injection. For testing that an interceptor survives partial bytes or TCP resets, use a real network with toxiproxy.
  • Streaming interceptors need fake ServerStream / ClientStream implementations. Minimal fakes satisfy most tests; complex multi-message sequences belong in grpc-streaming-test-author.
  • grpc-js server interceptors are not in scope. The @grpc/grpc-js server does not expose a ServerInterceptor extension point in the same way the Java or Go servers do.
  • grpc.ChainUnaryInterceptor ordering only applies to the server. Client chaining uses grpc.WithChainUnaryInterceptor; the two have the same semantics but different registration functions per pkg.go.dev/google.golang.org/grpc (opens in new window).

References

Go interceptor tests

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

Auth interceptor - rejects bad token

Type signatures per pkg.go.dev/google.golang.org/grpc#UnaryServerInterceptor (opens in new window):

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 (opens in new window)) 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")
    }
}

Retry interceptor - exponential backoff on Unavailable

codes.Unavailable (code 14) is the canonical "transient, retry" signal per pkg.go.dev/google.golang.org/grpc/codes (opens in new window). 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,
) error

Test: 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.

Logging/tracing - metadata propagation

Per pkg.go.dev/google.golang.org/grpc/metadata#FromIncomingContext (opens in new window), 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 (opens in new window):

ctx = metadata.AppendToOutgoingContext(ctx, "x-trace-id", traceID)

Chained interceptor ordering

Per pkg.go.dev/google.golang.org/grpc#ChainUnaryInterceptor (opens in new window), 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)
    }
}

Streaming server interceptor

grpc.StreamServerInterceptor signature per pkg.go.dev/google.golang.org/grpc#StreamServerInterceptor (opens in new window):

type StreamServerInterceptor func(
    srv any,
    ss grpc.ServerStream,
    info *grpc.StreamServerInfo,
    handler grpc.StreamHandler,
) error

Test 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())
    }
}

grpc-js client interceptor tests

View source (opens in new window)

grpc-js client interceptor tests

@grpc/grpc-js exposes client interceptors as a channel option. The package README confirms "Client Interceptors" as a supported feature at github.com/grpc/grpc-node/tree/master/packages/grpc-js (opens in new window). An interceptor is a function (options, nextCall) => InterceptingCall.

Client interceptor - auth-header injection

Test an auth-header injector by building an InterceptingCall with a RequesterBuilder that captures the outbound metadata:

import * as grpc from "@grpc/grpc-js";
import { InterceptingCall, InterceptorOptions, NextCall } from "@grpc/grpc-js";

function authInterceptor(token: string) {
    return (options: InterceptorOptions, nextCall: NextCall): InterceptingCall => {
        return new InterceptingCall(nextCall(options), {
            start(metadata, listener, next) {
                metadata.add("authorization", `Bearer ${token}`);
                next(metadata, listener);
            },
        });
    };
}

// Test using a spy on the nextCall layer
test("authInterceptor injects Authorization header", () => {
    let capturedMetadata: grpc.Metadata | undefined;

    const fakeNext: NextCall = (_options) =>
        new InterceptingCall(null as any, {
            start(metadata, _listener, _next) {
                capturedMetadata = metadata;
            },
        });

    const interceptorFn = authInterceptor("my-token");
    const call = interceptorFn({} as InterceptorOptions, fakeNext);
    call.start(new grpc.Metadata(), {} as grpc.Listener);

    expect(capturedMetadata?.get("authorization")).toEqual(["Bearer my-token"]);
});

Register on a channel:

const client = new UserServiceClient(address, credentials, {
    interceptors: [authInterceptor("my-token")],
});

Java interceptor tests

View source (opens in new window)

Java interceptor tests

Java interceptors are objects; call interceptCall directly with stubbed ServerCall / Channel collaborators (Mockito) and assert on captured Status and Metadata.

ServerInterceptor - auth rejection

ServerInterceptor.interceptCall signature per grpc-java javadoc (opens in new window):

<ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
    ServerCall<ReqT, RespT> call,
    Metadata headers,
    ServerCallHandler<ReqT, RespT> next)

Test with a ServerCall stub that captures the close() call:

import io.grpc.*;
import org.junit.Test;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

public class AuthInterceptorTest {

    private final ServerInterceptor interceptor = new AuthInterceptor();

    @SuppressWarnings("unchecked")
    @Test
    public void missingAuthHeader_closesWithUnauthenticated() {
        ServerCall<Object, Object> call = mock(ServerCall.class);
        Metadata headers = new Metadata(); // no authorization key
        ServerCallHandler<Object, Object> next = mock(ServerCallHandler.class);

        interceptor.interceptCall(call, headers, next);

        verify(call).close(
            argThat(s -> s.getCode() == Status.Code.UNAUTHENTICATED),
            any(Metadata.class));
        verifyNoInteractions(next);
    }
}

Registration per grpc-java javadoc ServerInterceptors.intercept (opens in new window)

  • note intercept() applies interceptors in reverse order (last interceptor's interceptCall fires first); use interceptForward() to preserve declaration order:
// Last-listed interceptor fires first:
ServerServiceDefinition def =
    ServerInterceptors.intercept(serviceImpl, authInterceptor, loggingInterceptor);

// First-listed interceptor fires first:
ServerServiceDefinition def =
    ServerInterceptors.interceptForward(serviceImpl, authInterceptor, loggingInterceptor);

ClientInterceptor - outbound token injection

ClientInterceptor.interceptCall signature per grpc-java javadoc (opens in new window):

<ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(
    MethodDescriptor<ReqT, RespT> method,
    CallOptions callOptions,
    Channel next)

Test that the interceptor attaches the authorization key to outbound headers by capturing Metadata passed to ClientCall.start():

@Test
public void tokenInjector_attachesAuthorizationHeader() {
    ClientInterceptor interceptor = new TokenInjectorInterceptor("Bearer tok");
    Channel channel = mock(Channel.class);
    ClientCall<Object, Object> innerCall = mock(ClientCall.class);
    when(channel.newCall(any(), any())).thenReturn(innerCall);

    ClientCall<Object, Object> call =
        interceptor.interceptCall(methodDescriptor(), CallOptions.DEFAULT, channel);

    Metadata headers = new Metadata();
    call.start(mock(ClientCall.Listener.class), headers);

    String auth = headers.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER));
    assertEquals("Bearer tok", auth);
}

Related skills

buf-cli-lint-breaking-build

Wraps the buf CLI for protobuf PR gating: `buf build` (compile .proto), `buf lint` (STANDARD rules: snake_case fields, Service suffix), `buf breaking --against {ref}` (detect wire/codegen breakage vs a git/BSR baseline), and `buf format`. Use as the CI proto-lint + breaking-change gate, or to debug a breaking failure by rule ID (e.g. FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED) and pick the FILE/PACKAGE/WIRE_JSON/WIRE ruleset per consumer. This is the detection TOOL that enforces the rules; for the catalog of what is breaking and why use protobuf-versioning-strategy-reference, and for cross-service schema contract testing use protobuf-compat-checking - not this.

ghz-load

Wraps ghz, the gRPC load testing tool, for throughput and latency benchmarking. Covers test invocation (--proto + --call + host:port; or --protoset for compiled descriptors), load parameters (-n total requests, -c concurrency, -r RPS rate limit, -z duration), output formats (json/csv/html/influx-summary for CI consumption), the metrics reported (RPS achieved, latency p50/p95/p99, status-code distribution, errors), and CI integration patterns for regression gating. Use when benchmarking a gRPC service's throughput or detecting latency regressions in CI.

grpc-mock

Wraps gRPC server-mocking patterns for client-side tests: Go bufconn (in-memory net.Listener via google.golang.org/grpc/test/bufconn) + mockgen-generated interface mocks, Python pytest-grpc fixtures + unittest.mock patching of stubs, JVM grpc-mock library / in-process gRPC server (InProcessServerBuilder), Node @grpc/grpc-js fake server with NewServer-on-port-0. Use when writing client-side tests that need a controllable gRPC server response (success cases, error cases per grpc-status-code-mapping-reference, timeouts, and single-response error injection) without spinning up a real backend. For multi-message streaming-sequence tests (server-streaming, bidi), use grpc-streaming-test-author instead. Distinct from grpcurl-cli (ad-hoc CLI invocation against a real server) and ghz-load (perf against a real server).

grpc-status-code-mapping-reference

Pure-reference catalog of gRPC standard status codes - the 17 canonical codes (OK..UNAUTHENTICATED), their numeric values, semantics, retry behaviour per AIP-194 (only UNAVAILABLE is auto-retry-safe), and the gRPC-to-HTTP status mapping used by grpc-gateway (NOT_FOUND→404, INVALID_ARGUMENT→400, PERMISSION_DENIED→403, UNAUTHENTICATED→401, RESOURCE_EXHAUSTED→429, FAILED_PRECONDITION→400 not 412, ABORTED→409, UNAVAILABLE→503, DEADLINE_EXCEEDED→504, etc.). Use when designing a gRPC service's error vocabulary, writing assertions in gRPC client tests, configuring retry policies, or mapping gRPC errors to HTTP via a gateway. Consumed by buf-cli-lint-breaking-build, ghz-load, grpcurl-cli, grpc-mock, grpc-streaming-test-author.

grpc-streaming-test-author

Workflow-driven skill that builds gRPC streaming-RPC test suites from a proto definition. Classifies each RPC by pattern (unary, server-streaming, client-streaming, bidi), then emits the required categories per pattern - ordering preservation, completion semantics (server close after stream end, client half-close), cancellation, deadline handling, partial-stream failure. Produces skeletons for Go (bufconn + Send/Recv), Python (iterators), JVM (StreamObserver), Node (call.write/end). Use when adding tests for a new streaming RPC or auditing a suite for uncovered categories. Different test surface from grpc-interceptor-test-author (interceptor layer) and grpc-mock (harness); for wire-level streaming semantics use grpc-streaming-tests, not this.

grpcurl-cli

Wraps grpcurl, the curl-equivalent CLI for gRPC. Covers descriptor sources (server reflection default, --import-path + --proto for proto files, --protoset for compiled descriptor sets), service discovery (`list`, `describe`), invoking unary RPCs (`-d '{...}'`, `-d @file.json`, `-d @` for stdin), streaming RPCs (newline-delimited JSON via stdin), TLS configuration (--cacert, --cert, --key, --insecure, --plaintext), header injection (-H 'Authorization: Bearer ...'), and exit codes. Use for ad-hoc gRPC debugging, smoke testing, scriptable PR-time gates, and CLI-based interaction with reflective gRPC services.

protobuf-versioning-strategy-reference

Pure-reference catalog of protobuf3 versioning and breaking-change rules: field-number reservation (reserve on delete; 1..536870911; 19000-19999 reserved), wire-safe vs wire-incompatible changes (add/remove safe with reservation; changing a field number always breaks), compatible type conversions (int32/uint32/int64/uint64/bool; sint32/sint64; string/bytes for UTF-8; enum/int), oneof + map constraints, and buf's four breaking categories (FILE/PACKAGE/WIRE_JSON/WIRE) with rule IDs. Use when designing a schema change or picking a buf breaking ruleset. This is the catalog of what is breaking and why, not a scanner; to detect changes in CI use buf-cli-lint-breaking-build, for the gRPC status-code vocabulary use grpc-status-code-mapping-reference, and for cross-service contract testing use protobuf-compat-checking.