gRPC Reflection, Testing, and Debugging

Building a gRPC service is one thing. Knowing it actually works in production is another. gRPC's binary protocol, code-generated stubs, and streaming semantics make it fundamentally different from REST APIs when it comes to testing and debugging. You cannot just open a browser or fire off a curl command. You need specialized tools, deliberate strategies, and a clear understanding of what can go wrong at every layer of the stack. This guide covers the full spectrum: from ad-hoc exploration with server reflection and grpcurl, through structured unit and integration testing, to production debugging with distributed tracing and channelz.

Server Reflection: Exploring Services at Runtime

With REST, you can discover an API by reading OpenAPI specs or just hitting endpoints and observing responses. gRPC's binary encoding and schema-first design mean you cannot do that without the .proto files — unless the server supports reflection.

gRPC server reflection is a standard service (defined in grpc.reflection.v1alpha.ServerReflection) that exposes the server's protobuf schema at runtime. When enabled, any client can query the server to discover which services are available, what methods they expose, and the full structure of their request and response messages — all without needing local proto files.

grpcurl / Client No .proto files gRPC Server UserService OrderService ReflectionService ListServices() [UserService, OrderService] Server reflection lets clients discover services without .proto files

Enabling reflection is typically a one-liner in your server setup. In Go with the standard grpc package:

import "google.golang.org/grpc/reflection"

s := grpc.NewServer()
pb.RegisterUserServiceServer(s, &userServer{})
reflection.Register(s)  // one line to enable
s.Serve(lis)

In Python with grpcio-reflection:

from grpc_reflection.v1alpha import reflection

SERVICE_NAMES = (
    user_pb2.DESCRIPTOR.services_by_name['UserService'].full_name,
    reflection.SERVICE_NAME,
)
reflection.enable_server_reflection(SERVICE_NAMES, server)

With reflection enabled, you can list all registered services, describe any method's input and output types, and even inspect nested message fields — entirely at runtime. This makes reflection indispensable during development and staging. In production, you may want to gate it behind authentication or disable it entirely to avoid leaking your API surface.

grpcurl: The curl for gRPC

grpcurl is a command-line tool that lets you interact with gRPC servers the way curl lets you interact with HTTP APIs. It supports server reflection natively, so if the server has reflection enabled, you need nothing more than the server address to start exploring.

Listing Services and Methods

# List all services
$ grpcurl -plaintext localhost:50051 list
grpc.reflection.v1alpha.ServerReflection
myapp.UserService
myapp.OrderService

# Describe a specific service
$ grpcurl -plaintext localhost:50051 describe myapp.UserService
myapp.UserService is a service:
service UserService {
  rpc GetUser ( .myapp.GetUserRequest ) returns ( .myapp.User );
  rpc ListUsers ( .myapp.ListUsersRequest ) returns ( stream .myapp.User );
  rpc CreateUser ( .myapp.CreateUserRequest ) returns ( .myapp.User );
}

# Describe a message type
$ grpcurl -plaintext localhost:50051 describe myapp.User
myapp.User is a message:
message User {
  string id = 1;
  string name = 2;
  string email = 3;
  int64 created_at = 4;
}

Making Calls

# Unary call
$ grpcurl -plaintext -d '{"id": "user-123"}' \
    localhost:50051 myapp.UserService/GetUser

# Server streaming call
$ grpcurl -plaintext -d '{"page_size": 10}' \
    localhost:50051 myapp.UserService/ListUsers

# With metadata (headers)
$ grpcurl -plaintext \
    -H 'authorization: Bearer tok_abc123' \
    -d '{"name": "Alice", "email": "[email protected]"}' \
    localhost:50051 myapp.UserService/CreateUser

If the server does not have reflection enabled, you can supply the proto files directly:

$ grpcurl -proto user.proto -import-path ./protos \
    -d '{"id": "user-123"}' \
    localhost:50051 myapp.UserService/GetUser

grpcurl also supports TLS, client certificates, unix domain sockets, and deadline configuration. It is the single most important tool for ad-hoc gRPC debugging.

GUI Tools: BloomRPC and Postman

For developers who prefer a visual interface, several GUI tools provide point-and-click gRPC interaction.

BloomRPC

BloomRPC (now largely succeeded by its spiritual successor, kreya and similar tools) was one of the first desktop GUI clients for gRPC. You import your .proto files, and it presents a form-based interface for each RPC method. You fill in request fields, click send, and see the response with syntax highlighting. It supports all four gRPC call types including bidirectional streaming, TLS configuration, and metadata headers.

Postman

Postman added gRPC support starting in 2022. You can create a gRPC request by importing proto files or using server reflection, then build requests with Postman's familiar interface. Postman's gRPC support includes method invocation for all streaming types, saved request collections, environment variables for switching between staging and production, and the ability to write test scripts against responses. This makes it particularly useful for teams that already use Postman for REST APIs and want a unified tool.

Other notable GUI tools include Evans (a REPL-style CLI with interactive mode), grpcui (a web-based UI that auto-generates forms from reflection), and Kreya (a cross-platform desktop client with environment management).

Unit Testing with In-Process Servers

The most effective strategy for unit-testing gRPC services is to run the server in-process — start the gRPC server within the test process itself, bind it to a local port or an in-memory transport, and connect a real gRPC client to it. This tests the full serialization/deserialization path and interceptor chain without any network overhead or port management complexity.

Test Process (single binary) Test Code setup() client.GetUser() assert(resp.name) teardown() In-Process gRPC Server bufconn / in-memory transport Interceptors & Middleware Service Implementation Mock Dependencies bufconn

In Go, the google.golang.org/grpc/test/bufconn package provides an in-memory connection that bypasses the network entirely:

func TestGetUser(t *testing.T) {
    // Create an in-memory listener
    lis := bufconn.Listen(1024 * 1024)

    // Start the gRPC server
    s := grpc.NewServer()
    pb.RegisterUserServiceServer(s, &userServer{
        db: newMockDB(), // inject mock dependencies
    })
    go s.Serve(lis)
    defer s.Stop()

    // Connect the client via bufconn
    conn, err := grpc.DialContext(ctx, "bufnet",
        grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
            return lis.Dial()
        }),
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    )
    if err != nil {
        t.Fatalf("dial: %v", err)
    }
    defer conn.Close()

    client := pb.NewUserServiceClient(conn)
    resp, err := client.GetUser(ctx, &pb.GetUserRequest{Id: "user-123"})
    if err != nil {
        t.Fatalf("GetUser: %v", err)
    }
    if resp.Name != "Alice" {
        t.Errorf("got name %q, want Alice", resp.Name)
    }
}

This approach has significant advantages over testing the service implementation struct directly. It exercises the full protobuf serialization round-trip, any interceptors or middleware you have registered (authentication, logging, validation), and the correct propagation of metadata and status codes. If your interceptor rejects unauthenticated requests, that behavior is tested naturally.

Integration Testing Strategies

Unit tests with in-process servers cover individual services well. Integration tests need to verify that multiple services work together correctly, including real database access, inter-service communication, and end-to-end request flows.

Docker Compose Test Environments

The most reliable integration testing strategy uses Docker Compose to spin up all services plus their dependencies (databases, caches, message queues) in an isolated environment. Each test run gets a fresh environment, eliminating flaky state-dependent failures.

# docker-compose.test.yml
services:
  user-service:
    build: ./user-service
    environment:
      - DB_HOST=postgres
      - ORDER_SERVICE_ADDR=order-service:50051
  order-service:
    build: ./order-service
    environment:
      - DB_HOST=postgres
  postgres:
    image: postgres:16
    environment:
      - POSTGRES_DB=testdb
  test-runner:
    build: ./integration-tests
    depends_on:
      - user-service
      - order-service
    environment:
      - USER_SERVICE_ADDR=user-service:50051
      - ORDER_SERVICE_ADDR=order-service:50051

Contract Testing

In microservice architectures, contract testing verifies that service interfaces remain compatible across independent deployments. For gRPC, protobuf's schema evolution rules provide some safety — adding fields is backward-compatible, removing required fields is not. But contract tests go further by verifying that the semantics of the interface are preserved: that a particular request produces a response with specific field values, that error conditions return the correct status codes, and that streaming behavior meets expectations.

Tools like Pact support gRPC via plugins. The consumer (client) records its expectations as a "pact," and the provider (server) verifies it can satisfy them. This catches breaking changes before deployment without requiring both services to be running simultaneously.

Testcontainers

For language-specific integration tests, Testcontainers lets you programmatically start Docker containers from within your test code. You spin up a Postgres container, run migrations, start your gRPC server against the real database, and test against it — all within a standard test function. When the test finishes, the containers are destroyed automatically.

Mocking gRPC Services

When testing a service that depends on other gRPC services, you need to mock those dependencies. There are several approaches, each with different tradeoffs.

Interface-Based Mocking

gRPC code generators produce both client and server interfaces. In Go, the generated client interface can be mocked directly:

type mockOrderClient struct {
    pb.UnimplementedOrderServiceClient
    orders map[string]*pb.Order
}

func (m *mockOrderClient) GetOrder(ctx context.Context,
    req *pb.GetOrderRequest, opts ...grpc.CallOption) (*pb.Order, error) {
    order, ok := m.orders[req.Id]
    if !ok {
        return nil, status.Errorf(codes.NotFound, "order %s not found", req.Id)
    }
    return order, nil
}

// Inject the mock when constructing the service under test
svc := &userServer{
    orderClient: &mockOrderClient{
        orders: map[string]*pb.Order{
            "order-1": {Id: "order-1", UserId: "user-123", Total: 4999},
        },
    },
}

Mock Servers

Sometimes you want to test the full network path including serialization and interceptors on the client side. In that case, run a mock gRPC server that returns canned responses. This is essentially the in-process server pattern, but the service implementation returns hardcoded data rather than hitting real backends.

Traffic Recording and Replay

For complex integration scenarios, some teams record real gRPC traffic and replay it in tests. Tools like grpc-wireshark can decode gRPC traffic, and custom interceptors can log request/response pairs for later replay. This is particularly useful for reproducing production bugs in a test environment.

Load Testing gRPC Services

gRPC's HTTP/2 transport, multiplexed streams, and binary encoding make its performance characteristics very different from REST. Load testing tools designed for HTTP/1.1 do not work well with gRPC. You need tools that speak native HTTP/2 and understand the gRPC framing protocol.

Load Generator ghz / locust / k6 concurrency: 50 duration: 30s RPS target: 5000 HTTP/2 gRPC Server avg latency: 4.2ms p99 latency: 18.7ms errors: 0.02% throughput: 4,980 rps Load testing requires native HTTP/2 + gRPC framing support

ghz

ghz is a purpose-built gRPC benchmarking tool, similar in spirit to Apache Bench or wrk but for gRPC. It supports reflection-based service discovery, configurable concurrency and request rates, and detailed latency histograms.

# Basic load test with ghz
$ ghz --insecure \
    --proto user.proto \
    --call myapp.UserService.GetUser \
    -d '{"id": "user-123"}' \
    -c 50 -n 10000 \
    localhost:50051

Summary:
  Count:        10000
  Total:        2.03 s
  Slowest:      24.31 ms
  Fastest:      0.38 ms
  Average:      4.21 ms
  Requests/sec: 4926.11

Latency distribution:
  10 % in 1.82 ms
  25 % in 2.64 ms
  50 % in 3.89 ms
  75 % in 5.41 ms
  90 % in 7.23 ms
  95 % in 9.87 ms
  99 % in 18.72 ms

Status code distribution:
  [OK]  10000 responses

ghz also supports outputting results as JSON or CSV for integration with dashboards, and it can read request data from files for varied payloads across requests.

Locust

Locust is a Python-based load testing framework with a web UI for real-time monitoring. While it is primarily designed for HTTP, the grpc-locust plugin (or custom User classes using the grpcio Python client) lets you write gRPC load tests with Locust's flexible scripting model:

from locust import User, task, between
import grpc
import user_pb2
import user_pb2_grpc

class GrpcUser(User):
    wait_time = between(0.5, 2)

    def on_start(self):
        self.channel = grpc.insecure_channel("localhost:50051")
        self.stub = user_pb2_grpc.UserServiceStub(self.channel)

    @task(3)
    def get_user(self):
        self.stub.GetUser(user_pb2.GetUserRequest(id="user-123"))

    @task(1)
    def list_users(self):
        for user in self.stub.ListUsers(user_pb2.ListUsersRequest(page_size=10)):
            pass  # consume the stream

k6 with gRPC

k6 by Grafana Labs has built-in gRPC support via its k6/net/grpc module. It can load proto files, make gRPC calls, and apply the same sophisticated load patterns (ramp-up, constant arrival rate, staged load) that make k6 popular for HTTP load testing. k6 also integrates natively with Grafana for real-time dashboards.

Distributed Tracing with OpenTelemetry

In a microservice architecture where a single user request may traverse five or ten gRPC services, you need distributed tracing to understand the full request lifecycle. OpenTelemetry (OTel) is the standard framework for this, and it has first-class gRPC support via interceptors.

trace_id: 4bf92f3577b34da6a3ce929d0e0e4736 API Gateway 120ms UserService.GetUser 85ms DB SELECT 12ms OrderService.ListOrders 62ms DB SELECT 45ms cache OpenTelemetry propagates trace context through gRPC metadata Each service adds spans; backends like Jaeger / Tempo visualize the full trace

OpenTelemetry gRPC interceptors automatically create spans for every RPC call, record metadata like the method name, status code, and latency, and propagate the trace context through gRPC metadata headers (traceparent / grpc-trace-bin). Setting it up in Go:

import (
    "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
)

// Server side: add OTel interceptor
s := grpc.NewServer(
    grpc.StatsHandler(otelgrpc.NewServerHandler()),
)

// Client side: add OTel interceptor
conn, err := grpc.Dial(addr,
    grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)

With these interceptors in place, every gRPC call between services is automatically traced. The resulting trace shows the full call tree: how long each service took, which downstream calls it made, and where time was spent. Backends like Jaeger, Grafana Tempo, and Zipkin visualize these traces as flame graphs or waterfall diagrams, making it trivial to identify the slow service in a chain of ten.

Beyond automatic instrumentation, you can add custom spans for database queries, cache lookups, and business logic within your service handlers. The key insight is that the trace context flows through context.Context, so any span created within a gRPC handler automatically becomes a child of the RPC span.

Channelz: Runtime Channel Diagnostics

Channelz is a gRPC-native diagnostic service that exposes detailed runtime information about all channels (client connections), subchannels, and sockets managed by the gRPC runtime. It is the equivalent of looking at netstat or ss, but with gRPC-level semantics: you see not just TCP connections but the logical gRPC channels, their connectivity states, the number of calls started/succeeded/failed, and the last time each channel changed state.

Channelz Runtime View Channel #1 → order-svc:50051 state: READY calls_started: 142,857 calls_succeeded: 142,811 calls_failed: 46 last_call: 2ms ago Channel #2 → auth-svc:50052 state: TRANSIENT_FAILURE calls_started: 98,421 calls_succeeded: 97,200 calls_failed: 1,221 last_call: 340ms ago Subchannels & Sockets sub#1 10.0.1.5:50051 READY sub#2 10.0.1.6:50051 READY sub#3 10.0.1.7:50051 READY sub#4 10.0.2.3:50052 FAILURE sub#5 10.0.2.4:50052 CONNECTING sub#6 10.0.2.5:50052 READY

Channelz is enabled by registering the channelz service on your gRPC server. You can then query it via grpcurl or programmatically through the channelz gRPC API:

# List all top-level channels
$ grpcurl -plaintext localhost:50051 \
    grpc.channelz.v1.Channelz/GetTopChannels

# Get details for a specific channel
$ grpcurl -plaintext -d '{"channel_id": 1}' \
    localhost:50051 grpc.channelz.v1.Channelz/GetChannel

# List all servers
$ grpcurl -plaintext localhost:50051 \
    grpc.channelz.v1.Channelz/GetServers

Channelz is particularly valuable for diagnosing connectivity issues in production. If a service is experiencing intermittent failures, channelz can reveal that one of five subchannels is in TRANSIENT_FAILURE state, pointing to a specific backend instance. Without channelz, you would only see the aggregate error rate without understanding the underlying cause.

Admin Services and Health Checking

gRPC defines a standard health checking protocol (grpc.health.v1.Health) that load balancers and orchestrators like Kubernetes use to determine if a service is ready to handle traffic. The health service supports per-service health status, so a server can report itself healthy for one service but unhealthy for another (for example, if a specific database connection pool is exhausted).

// Go: register the health service
import "google.golang.org/grpc/health"
import healthpb "google.golang.org/grpc/health/grpc_health_v1"

healthServer := health.NewServer()
healthpb.RegisterHealthServer(s, healthServer)

// Set per-service health
healthServer.SetServingStatus("myapp.UserService", healthpb.HealthCheckResponse_SERVING)
healthServer.SetServingStatus("myapp.OrderService", healthpb.HealthCheckResponse_NOT_SERVING)

In Kubernetes, you configure gRPC health checks in your pod spec:

livenessProbe:
  grpc:
    port: 50051
  initialDelaySeconds: 10
  periodSeconds: 5
readinessProbe:
  grpc:
    port: 50051
    service: "myapp.UserService"
  periodSeconds: 3

The admin service pattern goes further by exposing operational endpoints on a separate port — typically bound only to localhost or an internal network. Admin services commonly include channelz, health, reflection, and custom endpoints for cache invalidation, log-level adjustment, or feature flag toggling. Running admin services on a separate port means they can be protected by network-level controls without affecting the main serving path.

Common Debugging Patterns

gRPC has a well-defined set of status codes that, unlike HTTP's somewhat ambiguous codes, map precisely to specific failure modes. Learning to diagnose these efficiently is a core gRPC debugging skill.

DEADLINE_EXCEEDED (Code 4)

This is the most common production issue. The client set a deadline (timeout) for the RPC, and the server did not respond in time. Debugging this requires understanding where time was spent:

// Go: set a deadline on the client side
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

resp, err := client.GetUser(ctx, req)
if err != nil {
    st, _ := status.FromError(err)
    if st.Code() == codes.DeadlineExceeded {
        log.Printf("GetUser timed out after 5s")
    }
}

RESOURCE_EXHAUSTED (Code 8)

This indicates the server or client has run out of some resource. The most common causes:

UNAVAILABLE (Code 14)

UNAVAILABLE means the server is not reachable or not ready to handle requests. It is the gRPC equivalent of "connection refused" or "503 Service Unavailable." Key debugging steps:

UNIMPLEMENTED (Code 12)

The client is calling a method that the server does not recognize. This usually means a version mismatch — the client was generated from a newer proto file that includes a method the server has not implemented yet. Verify with reflection:

# Check if the method exists on the server
$ grpcurl -plaintext localhost:50051 describe myapp.UserService

INTERNAL (Code 13)

This is the catch-all for server bugs. Common causes include nil pointer dereferences, serialization failures (malformed protobuf messages), and unhandled panics. Server-side logs are essential here. Consider adding a recovery interceptor that catches panics and returns INTERNAL with a sanitized error message rather than crashing the server process:

// Go: panic recovery interceptor
func RecoveryInterceptor(ctx context.Context, req interface{},
    info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("panic in %s: %v\n%s", info.FullMethod, r, debug.Stack())
        }
    }()
    return handler(ctx, req)
}

Structured Logging for gRPC

Effective gRPC debugging requires structured logging that captures the right fields for every RPC. A logging interceptor should record:

// Example structured log output (JSON)
{
  "level": "info",
  "method": "/myapp.UserService/GetUser",
  "code": "OK",
  "duration_ms": 4.2,
  "req_size": 28,
  "resp_size": 142,
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "peer": "10.0.1.42:38291"
}

Several open-source interceptor libraries provide this out of the box. In Go, go-grpc-middleware includes logging interceptors for zap and logrus. In Java, grpc-spring-boot-starter includes auto-configured logging. The key is to ensure every RPC leaves a structured log entry, so you can query your log aggregation system (Loki, Elasticsearch, Datadog) by method name, status code, or trace ID.

Debugging gRPC in Production: A Workflow

When a gRPC service starts misbehaving in production, a systematic approach saves time. Here is a proven workflow that moves from broad to narrow:

  1. Check metrics. Look at the gRPC method-level dashboards for error rate, latency percentiles, and throughput. Identify which method or methods are affected and when the problem started.
  2. Check recent deploys. Correlate the problem timeline with deployments. Most production issues are caused by the most recent change.
  3. Pull traces. Find traces that show the failure. OpenTelemetry traces reveal the exact call chain and where the delay or error originated.
  4. Check channelz. If the issue looks like a connectivity problem (UNAVAILABLE errors, elevated latency), use channelz to inspect the channel and subchannel states. Look for subchannels in TRANSIENT_FAILURE.
  5. Check logs. Search structured logs for the failing method and time window. Look for patterns: is it one client, one backend instance, or all traffic?
  6. Reproduce with grpcurl. Once you have narrowed down the failing method and conditions, use grpcurl to manually call the method and observe the response, headers, and trailers.
  7. Check resource pressure. Look at CPU, memory, file descriptors, and goroutine/thread counts on the affected instances. RESOURCE_EXHAUSTED often correlates with one of these hitting a limit.

This workflow is structured as a funnel: each step narrows the problem space until you have a specific cause and can deploy a fix.

Network-Level Debugging

When application-level tools do not provide enough information, you may need to drop to the network level. Because gRPC runs over HTTP/2, standard HTTP debugging techniques apply with some caveats.

Wireshark can decode gRPC traffic if you provide the proto files. Filter on http2 and look for frames with the content-type: application/grpc header. For TLS-encrypted traffic, you will need to configure Wireshark with the server's private key or use the SSLKEYLOGFILE environment variable to capture the session keys.

The gRPC libraries themselves support extensive environment variable logging. In Go, setting GRPC_GO_LOG_SEVERITY_LEVEL=info and GRPC_GO_LOG_VERBOSITY_LEVEL=2 produces detailed logs about connection state changes, name resolution, and load balancing decisions. In C-core based implementations (Python, Ruby, C++), GRPC_VERBOSITY=DEBUG and GRPC_TRACE=all provide similar detail. These are extremely verbose and should only be used for targeted debugging, not in production.

Putting It All Together

A well-instrumented gRPC service stack looks like this:

The investment in testing and observability infrastructure pays for itself the first time you get paged at 2 AM for a DEADLINE_EXCEEDED spike and can identify the root cause in five minutes instead of fifty. gRPC's structured protocol, typed status codes, and standardized auxiliary services (reflection, channelz, health) provide better debugging primitives than REST — but only if you set them up.

To understand the protocol fundamentals that underpin all of this, see How gRPC Works. For a deep dive into the status codes referenced throughout this guide, see gRPC Error Handling.

See BGP routing data in real time

Open Looking Glass
More Articles
How gRPC Works
How Protocol Buffers Work
How gRPC-Web Works
gRPC Load Balancing: Strategies and Patterns
gRPC and Service Mesh: Istio, Envoy, and Linkerd
gRPC Security: Authentication, TLS, and Authorization